mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes
348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, relative } from 'node:path';
|
|
import ts from 'typescript';
|
|
|
|
const PROJECT_SOURCE_ROOT = join(process.cwd(), 'libs/playback/util/src');
|
|
const FORBIDDEN_IMPORT_PREFIXES = [
|
|
'@angular/',
|
|
'@iptvnator/ui/',
|
|
'electron',
|
|
'@ngx-pwa/',
|
|
] as const;
|
|
const FORBIDDEN_GLOBALS = new Set([
|
|
'window',
|
|
'document',
|
|
'localStorage',
|
|
'sessionStorage',
|
|
'MediaSource',
|
|
'MediaError',
|
|
'HTMLMediaElement',
|
|
'HTMLElement',
|
|
'navigator',
|
|
'indexedDB',
|
|
]);
|
|
|
|
describe('playback util boundary', () => {
|
|
it('keeps production sources independent of UI and browser globals', () => {
|
|
const violations = collectProductionSources(
|
|
PROJECT_SOURCE_ROOT
|
|
).flatMap(findBoundaryViolations);
|
|
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
['import declaration', "import '@angular/core';", '@angular/core'],
|
|
[
|
|
're-export declaration',
|
|
"export * from '@iptvnator/ui/components';",
|
|
'@iptvnator/ui/components',
|
|
],
|
|
[
|
|
'import type node',
|
|
"type AngularType = import('@angular/core').Type;",
|
|
'@angular/core',
|
|
],
|
|
[
|
|
'dynamic import',
|
|
"void import('@ngx-pwa/service-worker');",
|
|
'@ngx-pwa/service-worker',
|
|
],
|
|
[
|
|
'import-equals declaration',
|
|
"import Electron = require('electron'); void Electron;",
|
|
'electron',
|
|
],
|
|
])('rejects forbidden sources in a %s', (_label, source, moduleName) => {
|
|
const violations = findFixtureViolations(source);
|
|
|
|
expect(violations).toEqual([expect.stringContaining(moduleName)]);
|
|
});
|
|
|
|
it('allows non-forbidden sources in every supported import form', () => {
|
|
const violations = findFixtureViolations(`
|
|
import './imported';
|
|
export * from './exported';
|
|
type LocalType = import('./typed').Type;
|
|
void import('./dynamic');
|
|
import Local = require('./required');
|
|
void Local;
|
|
`);
|
|
|
|
expect(violations).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
['direct identifier', 'void navigator;', 'navigator'],
|
|
['direct storage identifier', 'void indexedDB;', 'indexedDB'],
|
|
[
|
|
'globalThis property access',
|
|
'void globalThis.MediaSource;',
|
|
'MediaSource',
|
|
],
|
|
['self property access', 'void self.indexedDB;', 'indexedDB'],
|
|
[
|
|
'globalThis element access',
|
|
"void globalThis['localStorage'];",
|
|
'localStorage',
|
|
],
|
|
[
|
|
'self element access',
|
|
"void self['sessionStorage'];",
|
|
'sessionStorage',
|
|
],
|
|
[
|
|
'globalThis destructuring',
|
|
'const { navigator } = globalThis;',
|
|
'navigator',
|
|
],
|
|
[
|
|
'self destructuring',
|
|
'const { indexedDB: database } = self; void database;',
|
|
'indexedDB',
|
|
],
|
|
])(
|
|
'rejects forbidden browser globals through %s',
|
|
(_label, source, name) => {
|
|
const violations = findFixtureViolations(source);
|
|
|
|
expect(violations).toEqual([expect.stringContaining(name)]);
|
|
}
|
|
);
|
|
|
|
it.each([
|
|
['property access', 'void source.navigator;'],
|
|
['element access', "void source['indexedDB'];"],
|
|
['destructuring', 'const { navigator } = source;'],
|
|
[
|
|
'property declaration',
|
|
'const config = { MediaSource: source }; void config;',
|
|
],
|
|
['shadowed self', 'const self = source; void self.MediaSource;'],
|
|
[
|
|
'local forbidden-name binding',
|
|
'const navigator = source.navigator; void navigator;',
|
|
],
|
|
])('allows ordinary local %s names', (_label, source) => {
|
|
const violations = findFixtureViolations(source);
|
|
|
|
expect(violations).toEqual([]);
|
|
});
|
|
});
|
|
|
|
function findFixtureViolations(source: string): string[] {
|
|
const directory = mkdtempSync(join(tmpdir(), 'playback-util-boundary-'));
|
|
const filePath = join(directory, 'fixture.ts');
|
|
try {
|
|
writeFileSync(filePath, `${source}\nexport {};`);
|
|
return findBoundaryViolations(filePath);
|
|
} finally {
|
|
rmSync(directory, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function collectProductionSources(directory: string): string[] {
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return collectProductionSources(path);
|
|
}
|
|
return entry.isFile() &&
|
|
entry.name.endsWith('.ts') &&
|
|
!entry.name.endsWith('.spec.ts') &&
|
|
!entry.name.endsWith('.test.ts')
|
|
? [path]
|
|
: [];
|
|
});
|
|
}
|
|
|
|
function findBoundaryViolations(filePath: string): string[] {
|
|
const program = ts.createProgram({
|
|
rootNames: [filePath],
|
|
options: {
|
|
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'],
|
|
module: ts.ModuleKind.ESNext,
|
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
noEmit: true,
|
|
noResolve: true,
|
|
skipLibCheck: true,
|
|
target: ts.ScriptTarget.ESNext,
|
|
},
|
|
});
|
|
const sourceFile = program.getSourceFile(filePath);
|
|
if (!sourceFile) {
|
|
throw new Error(`Unable to parse ${filePath}`);
|
|
}
|
|
const checker = program.getTypeChecker();
|
|
const violations: string[] = [];
|
|
|
|
sourceFile.forEachChild(function visit(node): void {
|
|
const moduleSpecifier = getExternalModuleSpecifier(node);
|
|
if (moduleSpecifier && isForbiddenModuleSpecifier(moduleSpecifier)) {
|
|
violations.push(formatViolation(sourceFile, node, moduleSpecifier));
|
|
}
|
|
for (const globalName of getForbiddenGlobalPropertyAccesses(
|
|
node,
|
|
checker,
|
|
sourceFile
|
|
)) {
|
|
violations.push(formatViolation(sourceFile, node, globalName));
|
|
}
|
|
if (
|
|
ts.isIdentifier(node) &&
|
|
FORBIDDEN_GLOBALS.has(node.text) &&
|
|
isAmbientIdentifierReference(node, checker, sourceFile)
|
|
) {
|
|
violations.push(formatViolation(sourceFile, node, node.text));
|
|
}
|
|
node.forEachChild(visit);
|
|
});
|
|
|
|
return violations;
|
|
}
|
|
|
|
function getExternalModuleSpecifier(node: ts.Node): string | undefined {
|
|
if (
|
|
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
node.moduleSpecifier
|
|
) {
|
|
return getStaticString(node.moduleSpecifier);
|
|
}
|
|
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
return getStaticString(node.argument.literal);
|
|
}
|
|
if (
|
|
ts.isCallExpression(node) &&
|
|
node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
) {
|
|
return getStaticString(node.arguments[0]);
|
|
}
|
|
if (
|
|
ts.isImportEqualsDeclaration(node) &&
|
|
ts.isExternalModuleReference(node.moduleReference)
|
|
) {
|
|
return getStaticString(node.moduleReference.expression);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getStaticString(node?: ts.Node): string | undefined {
|
|
return node &&
|
|
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
? node.text
|
|
: undefined;
|
|
}
|
|
|
|
function isForbiddenModuleSpecifier(moduleSpecifier: string): boolean {
|
|
return FORBIDDEN_IMPORT_PREFIXES.some((prefix) =>
|
|
moduleSpecifier.startsWith(prefix)
|
|
);
|
|
}
|
|
|
|
function getForbiddenGlobalPropertyAccesses(
|
|
node: ts.Node,
|
|
checker: ts.TypeChecker,
|
|
sourceFile: ts.SourceFile
|
|
): string[] {
|
|
if (
|
|
ts.isPropertyAccessExpression(node) &&
|
|
isAmbientGlobalRoot(node.expression, checker, sourceFile) &&
|
|
FORBIDDEN_GLOBALS.has(node.name.text)
|
|
) {
|
|
return [node.name.text];
|
|
}
|
|
if (
|
|
ts.isElementAccessExpression(node) &&
|
|
isAmbientGlobalRoot(node.expression, checker, sourceFile)
|
|
) {
|
|
const propertyName = getStaticString(node.argumentExpression);
|
|
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
|
|
? [propertyName]
|
|
: [];
|
|
}
|
|
if (
|
|
ts.isVariableDeclaration(node) &&
|
|
ts.isObjectBindingPattern(node.name) &&
|
|
node.initializer &&
|
|
isAmbientGlobalRoot(node.initializer, checker, sourceFile)
|
|
) {
|
|
return node.name.elements.flatMap((element) => {
|
|
const propertyName = getBindingPropertyName(element);
|
|
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
|
|
? [propertyName]
|
|
: [];
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function getBindingPropertyName(
|
|
element: ts.BindingElement
|
|
): string | undefined {
|
|
const property = element.propertyName ?? element.name;
|
|
return ts.isIdentifier(property) || ts.isStringLiteral(property)
|
|
? property.text
|
|
: undefined;
|
|
}
|
|
|
|
function isAmbientGlobalRoot(
|
|
expression: ts.Expression,
|
|
checker: ts.TypeChecker,
|
|
sourceFile: ts.SourceFile
|
|
): boolean {
|
|
return (
|
|
ts.isIdentifier(expression) &&
|
|
(expression.text === 'globalThis' || expression.text === 'self') &&
|
|
isAmbientIdentifier(expression, checker, sourceFile)
|
|
);
|
|
}
|
|
|
|
function isAmbientIdentifierReference(
|
|
identifier: ts.Identifier,
|
|
checker: ts.TypeChecker,
|
|
sourceFile: ts.SourceFile
|
|
): boolean {
|
|
return (
|
|
!isPropertyName(identifier) &&
|
|
isAmbientIdentifier(identifier, checker, sourceFile)
|
|
);
|
|
}
|
|
|
|
function isAmbientIdentifier(
|
|
identifier: ts.Identifier,
|
|
checker: ts.TypeChecker,
|
|
sourceFile: ts.SourceFile
|
|
): boolean {
|
|
const symbol = checker.getSymbolAtLocation(identifier);
|
|
return !symbol?.declarations?.some(
|
|
(declaration) => declaration.getSourceFile() === sourceFile
|
|
);
|
|
}
|
|
|
|
function isPropertyName(identifier: ts.Identifier): boolean {
|
|
const { parent } = identifier;
|
|
return (
|
|
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
|
(ts.isBindingElement(parent) &&
|
|
(parent.name === identifier ||
|
|
parent.propertyName === identifier)) ||
|
|
((ts.isPropertyAssignment(parent) ||
|
|
ts.isPropertyDeclaration(parent) ||
|
|
ts.isPropertySignature(parent) ||
|
|
ts.isMethodDeclaration(parent) ||
|
|
ts.isMethodSignature(parent) ||
|
|
ts.isEnumMember(parent)) &&
|
|
parent.name === identifier)
|
|
);
|
|
}
|
|
|
|
function formatViolation(
|
|
sourceFile: ts.SourceFile,
|
|
node: ts.Node,
|
|
detail: string
|
|
): string {
|
|
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
|
|
return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}: ${detail}`;
|
|
}
|