mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
* chore(lint): hold tests to their own max-lines ceiling The flat 400-line cap treated a spec like a component. A spec is a flat list of independent cases, so hitting the cap there produces arbitrary `-2.spec.ts` splits and hides coverage instead of surfacing design debt — 65 of the 138 files over the limit were tests. Production code keeps 400. Tests (`**/*.spec.ts`, `**/*.e2e.ts`, and everything under `apps/*-e2e/**`) get 1200. Blank lines and comments no longer count, so a docblock can't be the reason a file must be split. Both limits now live in tools/eslint/max-lines-config.mjs, imported by eslint.config.mjs and the baseline generator alike. The generator decides who belongs on the list by running ESLint's own max-lines rule instead of counting lines itself — a private reimplementation would disagree with the rule the moment either side changed (a `//` inside a template literal is enough) and yield a baseline that turns CI red while looking correct. The baseline drops 126 -> 68 entries with nothing added, and six now-dead `eslint-disable max-lines` directives are removed. A new eslint-tools test asserts the committed baseline still matches what the generator produces, so a stale entry or a forgotten regeneration fails CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(lint): classify eslint-tools in the coverage policy A project with a `test` target must be assigned a coverage tier, so adding eslint-tools broke `coverage:policy:check` before the unit suite even ran. Tier B alongside packaging and release-tools: these are Node tests over lint tooling, and a coverage percentage across a generated list would not mean anything. CI runs Tier B/C through its own `--run-non-tier-a` step, so the baseline-consistency test executes there rather than being skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
636 lines
22 KiB
TypeScript
636 lines
22 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { once } from 'node:events';
|
|
import {
|
|
access,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import { createServer, type Server } from 'node:http';
|
|
import { createServer as createTcpServer } from 'node:net';
|
|
import { tmpdir } from 'node:os';
|
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
|
|
import { expect, type ConsoleMessage, type Page } from '@playwright/test';
|
|
|
|
import {
|
|
closeElectronApp,
|
|
launchElectronApp,
|
|
openSources,
|
|
sourceRowByTitle,
|
|
waitForM3uCatalog,
|
|
type LaunchedElectronApp,
|
|
} from '../electron-test-fixtures';
|
|
import type {
|
|
CancellationBenchmarkManifest,
|
|
CancellationIterationResult,
|
|
PerformanceIterationKind,
|
|
} from './m3u-refresh-cancellation-contract';
|
|
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
|
|
import {
|
|
createCancellationBenchmarkSummary,
|
|
createCancellationIterationResult,
|
|
} from './m3u-refresh-cancellation-report';
|
|
import { validateDiagnosticPlaylistWorkerCapture } from './diagnostic-playlist-worker-capture';
|
|
import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight';
|
|
import {
|
|
installMainCapture,
|
|
readMainCaptureStatus,
|
|
rolloverMainCapture,
|
|
startMainCapture,
|
|
stopMainCapture,
|
|
} from './m3u-refresh-main-capture';
|
|
import { startRendererCapture } from './m3u-refresh-renderer-capture';
|
|
import type { RendererWindowIdentity } from './renderer-window-rss-session';
|
|
import {
|
|
createSyntheticM3uFixture,
|
|
SYNTHETIC_M3U_CHANNEL_COUNT,
|
|
SYNTHETIC_M3U_SEED,
|
|
type SyntheticM3uFixture,
|
|
} from './synthetic-m3u';
|
|
|
|
const SCENARIO = 'm3u-refresh-cancel';
|
|
const SOURCE_TITLE = 'Synthetic M3U cancellation 100k';
|
|
const RESOURCE_PATH = '/synthetic-performance.m3u';
|
|
const DEFAULT_MEASURED_RUNS = 5;
|
|
const DEFAULT_WARMUP_RUNS = 1;
|
|
const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
const RENDERER_CDP_PORT = 9222;
|
|
|
|
interface SyntheticServer {
|
|
readonly resourceUrl: string;
|
|
close(): Promise<void>;
|
|
serveLargeFixture(): void;
|
|
serveSeedFixture(): void;
|
|
}
|
|
|
|
interface BenchmarkConfiguration {
|
|
readonly electronVersion: string;
|
|
readonly gitCommit: string;
|
|
readonly gitDiffSha256: string;
|
|
readonly gitDirty: boolean;
|
|
readonly measuredRuns: number;
|
|
readonly outputDirectory: string;
|
|
readonly variant: string;
|
|
readonly warmupRuns: number;
|
|
readonly workspaceRoot: string;
|
|
}
|
|
|
|
interface IterationDefinition {
|
|
readonly kind: PerformanceIterationKind;
|
|
readonly runId: string;
|
|
}
|
|
|
|
export async function runM3uRefreshCancellationBenchmark(): Promise<void> {
|
|
const config = await resolveConfiguration();
|
|
const fixture = createSyntheticM3uFixture();
|
|
const server = await startSyntheticServer(fixture);
|
|
const iterations: CancellationIterationResult[] = [];
|
|
|
|
try {
|
|
for (const definition of createIterationDefinitions(config)) {
|
|
console.log(
|
|
`[performance] ${config.variant}/${definition.runId} starting`
|
|
);
|
|
const result = await runIteration(config, definition, server);
|
|
iterations.push(result);
|
|
console.log(
|
|
`[performance] ${definition.runId} total=${
|
|
result.phases.totalMs?.toFixed(3) ?? 'n/a'
|
|
}ms cancelEffect=${result.cancellationEffectObserved}`
|
|
);
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
}
|
|
|
|
const manifest: CancellationBenchmarkManifest = Object.freeze({
|
|
channelCount: SYNTHETIC_M3U_CHANNEL_COUNT,
|
|
fixtureBytes: fixture.bytes,
|
|
fixtureSha256: fixture.sha256,
|
|
gitCommit: config.gitCommit,
|
|
gitDiffSha256: config.gitDiffSha256,
|
|
gitDirty: config.gitDirty,
|
|
measuredRuns: config.measuredRuns,
|
|
memoryAccounting: Object.freeze({
|
|
mainRss:
|
|
'electron-main-process-including-worker-threads-and-native-memory',
|
|
rendererRss: 'separate-renderer-process',
|
|
workerJsHeap: 'separate-v8-isolate',
|
|
workerRss: 'unavailable-per-thread',
|
|
}),
|
|
scenario: SCENARIO,
|
|
syntheticSeed: SYNTHETIC_M3U_SEED,
|
|
runtime: Object.freeze({
|
|
arch: process.arch,
|
|
electronVersion: config.electronVersion,
|
|
harnessNodeVersion: process.versions.node,
|
|
platform: process.platform,
|
|
}),
|
|
variant: config.variant,
|
|
warmupRuns: config.warmupRuns,
|
|
});
|
|
const summary = createCancellationBenchmarkSummary(manifest, iterations);
|
|
await writeJson(join(config.outputDirectory, 'manifest.json'), manifest);
|
|
await writeJson(join(config.outputDirectory, 'summary.json'), summary);
|
|
const diagnosticIteration = iterations.find(
|
|
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC
|
|
);
|
|
if (!diagnosticIteration) {
|
|
throw new Error('Diagnostic iteration is missing');
|
|
}
|
|
if (!diagnosticIteration.cancellationEffectObserved) {
|
|
throw new Error(
|
|
'Cancellation effect was not observed in the diagnostic run'
|
|
);
|
|
}
|
|
await validateDiagnosticPlaylistWorkerCapture(
|
|
diagnosticIteration.main,
|
|
join(config.outputDirectory, diagnosticIteration.runId)
|
|
);
|
|
if (summary.cancellationEffectRate !== 1) {
|
|
throw new Error(
|
|
`Cancellation effect was not observed in every measured run: ${summary.cancellationEffectRate}`
|
|
);
|
|
}
|
|
if (!summary.validity.rendererRss.validForBenchmark) {
|
|
throw new Error(
|
|
`Renderer RSS capture is invalid: ${JSON.stringify(
|
|
summary.validity.rendererRss.invalidMeasuredRuns
|
|
)}`
|
|
);
|
|
}
|
|
if (!summary.validity.databaseWorkerPostGc.validForBenchmark) {
|
|
throw new Error(
|
|
`Database worker post-GC capture is invalid: ${JSON.stringify(
|
|
summary.validity.databaseWorkerPostGc.invalidMeasuredRuns
|
|
)}`
|
|
);
|
|
}
|
|
console.log(
|
|
`[performance] completed: ${join(
|
|
config.outputDirectory,
|
|
'summary.json'
|
|
)}`
|
|
);
|
|
}
|
|
|
|
async function runIteration(
|
|
config: BenchmarkConfiguration,
|
|
definition: IterationDefinition,
|
|
server: SyntheticServer
|
|
): Promise<CancellationIterationResult> {
|
|
const iterationDirectory = join(config.outputDirectory, definition.runId);
|
|
await assertPerformanceArtifactCapacity(iterationDirectory);
|
|
await mkdir(iterationDirectory, {
|
|
mode: 0o700,
|
|
recursive: false,
|
|
});
|
|
const dataDirectory = await mkdtemp(
|
|
join(tmpdir(), 'iptvnator-m3u-performance-')
|
|
);
|
|
let app: LaunchedElectronApp | null = null;
|
|
let rendererConsoleListener: ((message: ConsoleMessage) => void) | null =
|
|
null;
|
|
|
|
try {
|
|
await assertTcpPortAvailable(RENDERER_CDP_PORT);
|
|
server.serveSeedFixture();
|
|
app = await launchElectronApp(dataDirectory, {
|
|
args: [
|
|
'--js-flags=--expose-gc',
|
|
'--remote-debugging-address=127.0.0.1',
|
|
`--remote-debugging-port=${RENDERER_CDP_PORT}`,
|
|
`--user-data-dir=${join(dataDirectory, 'user-data')}`,
|
|
],
|
|
environmentInheritance: 'runtime-only',
|
|
env: {
|
|
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0',
|
|
IPTVNATOR_PERF_CAPTURE: '1',
|
|
IPTVNATOR_PERF_WORKER_PROFILING: '1',
|
|
IPTVNATOR_TRACE_RENDERER_CONSOLE:
|
|
process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0',
|
|
},
|
|
});
|
|
await assertRendererCdpTarget(app.mainWindow);
|
|
app.mainWindow.setDefaultTimeout(120_000);
|
|
await installMainCapture(app.electronApp);
|
|
const rendererWindowIdentity = await resolveRendererWindowIdentity(app);
|
|
await startMainCapture(app.electronApp, {
|
|
diagnostic: false,
|
|
outputDirectory: iterationDirectory,
|
|
rendererWindowIdentity,
|
|
});
|
|
await seedPlaylist(app.mainWindow, server.resourceUrl);
|
|
await waitForSeedSettlement(app);
|
|
server.serveLargeFixture();
|
|
const rendererRefreshErrors: string[] = [];
|
|
rendererConsoleListener = (message) => {
|
|
if (
|
|
message.type() === 'error' &&
|
|
message.text().includes('Error refreshing playlist:')
|
|
) {
|
|
rendererRefreshErrors.push(message.text());
|
|
}
|
|
};
|
|
app.mainWindow.on('console', rendererConsoleListener);
|
|
|
|
const diagnostic =
|
|
definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC;
|
|
const seedRollover = await rolloverMainCapture(app.electronApp, {
|
|
diagnostic,
|
|
outputDirectory: iterationDirectory,
|
|
rendererWindowIdentity,
|
|
});
|
|
await writeJson(
|
|
join(iterationDirectory, 'seed-main-capture.json'),
|
|
seedRollover.completedCapture
|
|
);
|
|
if (!seedRollover.nextCaptureStarted) {
|
|
throw new Error(
|
|
`Seed capture could not atomically arm the measured generation: ${seedRollover.nextCaptureUnavailableReason}`
|
|
);
|
|
}
|
|
const renderer = await startRendererCapture(app.mainWindow, {
|
|
diagnostic,
|
|
outputDirectory: iterationDirectory,
|
|
sourceTitle: SOURCE_TITLE,
|
|
});
|
|
|
|
await renderer.markOperationStart();
|
|
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first();
|
|
await row.locator('.refresh-btn').click();
|
|
await renderer.waitForCancelClick();
|
|
await Promise.all([
|
|
renderer.waitForTerminal(),
|
|
waitForOperationSettlement(app),
|
|
]);
|
|
expect(rendererRefreshErrors).toEqual([]);
|
|
|
|
const [rendererMetrics, mainMetrics] = await Promise.all([
|
|
renderer.stop(),
|
|
stopMainCapture(app.electronApp),
|
|
]);
|
|
const result = createCancellationIterationResult({
|
|
kind: definition.kind,
|
|
main: mainMetrics,
|
|
renderer: rendererMetrics,
|
|
runId: definition.runId,
|
|
});
|
|
await writeJson(join(iterationDirectory, 'result.json'), result);
|
|
return result;
|
|
} finally {
|
|
if (app) {
|
|
if (rendererConsoleListener) {
|
|
app.mainWindow.off('console', rendererConsoleListener);
|
|
}
|
|
await closeElectronApp(app).catch((error: unknown) => {
|
|
console.error('[performance] Electron cleanup failed', error);
|
|
});
|
|
}
|
|
await rm(dataDirectory, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
async function resolveRendererWindowIdentity(
|
|
app: LaunchedElectronApp
|
|
): Promise<RendererWindowIdentity> {
|
|
const browserWindowHandle = await app.electronApp.browserWindow(
|
|
app.mainWindow
|
|
);
|
|
try {
|
|
return await browserWindowHandle.evaluate((browserWindow) => {
|
|
const exactWindow = browserWindow as unknown as {
|
|
readonly id: number;
|
|
readonly webContents: { readonly id: number };
|
|
};
|
|
return {
|
|
browserWindowId: exactWindow.id,
|
|
webContentsId: exactWindow.webContents.id,
|
|
};
|
|
});
|
|
} finally {
|
|
await browserWindowHandle.dispose();
|
|
}
|
|
}
|
|
|
|
async function seedPlaylist(page: Page, playlistUrl: string): Promise<void> {
|
|
await page.getByRole('button', { name: 'Add playlist' }).first().click();
|
|
const dialog = page.locator('mat-dialog-container').last();
|
|
await expect(
|
|
dialog.getByRole('heading', { name: 'Add playlist' })
|
|
).toBeVisible();
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' })
|
|
.fill(playlistUrl);
|
|
await dialog
|
|
.getByRole('textbox', { name: 'Playlist title' })
|
|
.fill(SOURCE_TITLE);
|
|
await dialog.getByRole('button', { name: 'Add playlist' }).last().click();
|
|
await expect(dialog).toBeHidden();
|
|
await waitForM3uCatalog(page);
|
|
await openSources(page);
|
|
await expect(sourceRowByTitle(page, SOURCE_TITLE).first()).toBeVisible();
|
|
await waitForTwoAnimationFrames(page);
|
|
}
|
|
|
|
async function waitForSeedSettlement(app: LaunchedElectronApp): Promise<void> {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const status = await readMainCaptureStatus(app.electronApp);
|
|
return (
|
|
status.databaseRequests > 0 &&
|
|
status.databaseUpsertsCompleted > 0 &&
|
|
status.databasePending === 0
|
|
);
|
|
},
|
|
{ timeout: 120_000 }
|
|
)
|
|
.toBe(true);
|
|
await waitForTwoAnimationFrames(app.mainWindow);
|
|
}
|
|
|
|
async function waitForOperationSettlement(
|
|
app: LaunchedElectronApp
|
|
): Promise<void> {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const status = await readMainCaptureStatus(app.electronApp);
|
|
if (status.playlistResponsesSucceeded > 0) {
|
|
return (
|
|
status.databaseUpsertsCompleted > 0 &&
|
|
status.databasePending === 0
|
|
);
|
|
}
|
|
return (
|
|
status.playlistTerminated > 0 &&
|
|
(status.playlistResponses === 0 ||
|
|
status.playlistResponsesFailed > 0)
|
|
);
|
|
},
|
|
{ timeout: 120_000 }
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
async function waitForTwoAnimationFrames(page: Page): Promise<void> {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolvePromise) => {
|
|
requestAnimationFrame(() =>
|
|
requestAnimationFrame(() => resolvePromise())
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
function createIterationDefinitions(
|
|
config: BenchmarkConfiguration
|
|
): readonly IterationDefinition[] {
|
|
const definitions: IterationDefinition[] = [];
|
|
for (let index = 0; index < config.warmupRuns; index += 1) {
|
|
definitions.push({
|
|
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
|
runId: `warmup-${String(index + 1).padStart(2, '0')}`,
|
|
});
|
|
}
|
|
for (let index = 0; index < config.measuredRuns; index += 1) {
|
|
definitions.push({
|
|
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
|
runId: `run-${String(index + 1).padStart(2, '0')}`,
|
|
});
|
|
}
|
|
definitions.push({
|
|
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
|
runId: 'diagnostic',
|
|
});
|
|
return Object.freeze(definitions);
|
|
}
|
|
|
|
async function resolveConfiguration(): Promise<BenchmarkConfiguration> {
|
|
const workspaceRoot = resolve(__dirname, '../../../..');
|
|
const performanceRoot = join(workspaceRoot, 'dist', 'performance');
|
|
const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR'];
|
|
if (!requestedRoot) {
|
|
throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required');
|
|
}
|
|
const outputRoot = resolve(requestedRoot);
|
|
if (
|
|
!isAbsolute(requestedRoot) ||
|
|
!isStrictDescendant(performanceRoot, outputRoot)
|
|
) {
|
|
throw new Error(
|
|
'Performance output must be an absolute path below dist/performance'
|
|
);
|
|
}
|
|
const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline';
|
|
if (!OUTPUT_VARIANT_PATTERN.test(variant)) {
|
|
throw new Error('IPTVNATOR_PERF_VARIANT is invalid');
|
|
}
|
|
const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1';
|
|
const sourceState = readGitSourceState(workspaceRoot);
|
|
if (!smoke && sourceState.dirty) {
|
|
throw new Error(
|
|
'Formal performance runs require a clean Git worktree; commit or stash changes first'
|
|
);
|
|
}
|
|
const electronPackage = JSON.parse(
|
|
await readFile(
|
|
join(workspaceRoot, 'node_modules', 'electron', 'package.json'),
|
|
'utf8'
|
|
)
|
|
) as { version?: unknown };
|
|
if (typeof electronPackage.version !== 'string') {
|
|
throw new Error('Unable to resolve the Electron runtime version');
|
|
}
|
|
const outputDirectory = join(outputRoot, variant);
|
|
await assertMissing(outputDirectory);
|
|
await assertPerformanceArtifactCapacity(outputDirectory);
|
|
await mkdir(outputDirectory, {
|
|
mode: 0o700,
|
|
recursive: true,
|
|
});
|
|
return Object.freeze({
|
|
electronVersion: electronPackage.version,
|
|
gitCommit: sourceState.commit,
|
|
gitDiffSha256: sourceState.diffSha256,
|
|
gitDirty: sourceState.dirty,
|
|
measuredRuns: smoke ? 1 : DEFAULT_MEASURED_RUNS,
|
|
outputDirectory,
|
|
variant,
|
|
warmupRuns: DEFAULT_WARMUP_RUNS,
|
|
workspaceRoot,
|
|
});
|
|
}
|
|
|
|
function readGitSourceState(workspaceRoot: string): {
|
|
readonly commit: string;
|
|
readonly diffSha256: string;
|
|
readonly dirty: boolean;
|
|
} {
|
|
const git = (args: readonly string[]): string =>
|
|
execFileSync('git', [...args], {
|
|
cwd: workspaceRoot,
|
|
encoding: 'utf8',
|
|
});
|
|
const commit = git(['rev-parse', 'HEAD']).trim();
|
|
const status = git(['status', '--porcelain=v1', '--untracked-files=all']);
|
|
const trackedDiff = git(['diff', '--binary', 'HEAD']);
|
|
const stagedDiff = git(['diff', '--binary', '--cached', 'HEAD']);
|
|
const diffSha256 = createHash('sha256')
|
|
.update(status)
|
|
.update('\0')
|
|
.update(trackedDiff)
|
|
.update('\0')
|
|
.update(stagedDiff)
|
|
.digest('hex');
|
|
return Object.freeze({
|
|
commit,
|
|
diffSha256,
|
|
dirty: status.trim().length > 0,
|
|
});
|
|
}
|
|
|
|
function isStrictDescendant(parent: string, candidate: string): boolean {
|
|
const pathFromParent = relative(resolve(parent), resolve(candidate));
|
|
return (
|
|
pathFromParent.length > 0 &&
|
|
pathFromParent !== '..' &&
|
|
!pathFromParent.startsWith(`..${sep}`) &&
|
|
!isAbsolute(pathFromParent)
|
|
);
|
|
}
|
|
|
|
async function assertTcpPortAvailable(port: number): Promise<void> {
|
|
const server = createTcpServer();
|
|
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
server.once('error', rejectPromise);
|
|
server.listen(
|
|
{ exclusive: true, host: '127.0.0.1', port },
|
|
resolvePromise
|
|
);
|
|
});
|
|
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
server.close((error) =>
|
|
error ? rejectPromise(error) : resolvePromise()
|
|
);
|
|
});
|
|
}
|
|
|
|
async function assertRendererCdpTarget(page: Page): Promise<void> {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
try {
|
|
const response = await fetch(
|
|
`http://127.0.0.1:${RENDERER_CDP_PORT}/json/list`
|
|
);
|
|
if (!response.ok) {
|
|
return false;
|
|
}
|
|
const targets = (await response.json()) as unknown;
|
|
return (
|
|
Array.isArray(targets) &&
|
|
targets.some(
|
|
(target) =>
|
|
typeof target === 'object' &&
|
|
target !== null &&
|
|
(target as Record<string, unknown>)['type'] ===
|
|
'page' &&
|
|
(target as Record<string, unknown>)['url'] ===
|
|
page.url()
|
|
)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
{ timeout: 10_000 }
|
|
)
|
|
.toBe(true);
|
|
}
|
|
|
|
async function assertMissing(path: string): Promise<void> {
|
|
try {
|
|
await access(path);
|
|
} catch {
|
|
return;
|
|
}
|
|
throw new Error(`Performance output already exists: ${path}`);
|
|
}
|
|
|
|
async function startSyntheticServer(
|
|
fixture: SyntheticM3uFixture
|
|
): Promise<SyntheticServer> {
|
|
const seedBody = createSeedPlaylistBody();
|
|
let body = seedBody;
|
|
const server = createServer((request, response) => {
|
|
if (
|
|
request.method !== 'GET' ||
|
|
new URL(request.url ?? '/', 'http://127.0.0.1').pathname !==
|
|
RESOURCE_PATH
|
|
) {
|
|
response.writeHead(404, { 'Content-Length': '0' });
|
|
response.end();
|
|
return;
|
|
}
|
|
const bytes = Buffer.byteLength(body, 'utf8');
|
|
response.writeHead(200, {
|
|
'Cache-Control': 'no-store',
|
|
'Content-Length': String(bytes),
|
|
'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8',
|
|
});
|
|
response.end(body);
|
|
});
|
|
server.listen({ exclusive: true, host: '127.0.0.1', port: 0 });
|
|
await once(server, 'listening');
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
await closeServer(server);
|
|
throw new Error('Synthetic M3U server did not bind IPv4 loopback');
|
|
}
|
|
return Object.freeze({
|
|
close: () => closeServer(server),
|
|
resourceUrl: `http://127.0.0.1:${address.port}${RESOURCE_PATH}`,
|
|
serveLargeFixture: () => {
|
|
body = fixture.body;
|
|
},
|
|
serveSeedFixture: () => {
|
|
body = seedBody;
|
|
},
|
|
});
|
|
}
|
|
|
|
function createSeedPlaylistBody(): string {
|
|
const lines = ['#EXTM3U'];
|
|
for (let index = 1; index <= 100; index += 1) {
|
|
lines.push(
|
|
`#EXTINF:-1 group-title="Synthetic Seed",Synthetic Seed ${String(
|
|
index
|
|
).padStart(3, '0')}`
|
|
);
|
|
lines.push(`http://127.0.0.1/seed/${index}`);
|
|
}
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
async function closeServer(server: Server): Promise<void> {
|
|
if (!server.listening) {
|
|
return;
|
|
}
|
|
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
server.close((error) =>
|
|
error ? rejectPromise(error) : resolvePromise()
|
|
);
|
|
});
|
|
}
|
|
|
|
async function writeJson(path: string, value: unknown): Promise<void> {
|
|
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
}
|