Release Twitch VOD Manager 1.0.18
Harden update, system-check, queue, cutter, streamer and shutdown state transitions. Add multi-user installer recovery, secret-safe config migration, provider fallback handling, managed-tool validation and real media export coverage. Refresh the English public documentation, release notes and 1.0.18 product screenshot.
This commit is contained in:
+6
-2
@@ -1,11 +1,15 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { watch } from 'node:fs';
|
||||
import { readFileSync, watch } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const rootDirectory = resolve(dirname(scriptPath), '..');
|
||||
const developmentAppVersion = JSON.parse(readFileSync(resolve(rootDirectory, 'package.json'), 'utf8')).version;
|
||||
if (typeof developmentAppVersion !== 'string' || developmentAppVersion.trim().length === 0) {
|
||||
throw new Error('package.json version must be a non-empty string');
|
||||
}
|
||||
const typescriptCli = resolve(rootDirectory, 'node_modules', 'typescript', 'bin', 'tsc');
|
||||
const electronSourceExecutable = process.platform === 'win32'
|
||||
? resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'electron.exe')
|
||||
@@ -93,7 +97,7 @@ if (process.platform === 'win32') {
|
||||
sourcePath: electronSourceExecutable,
|
||||
destinationPath: resolve(rootDirectory, 'node_modules', 'electron', 'dist', 'Twitch VOD Manager.exe'),
|
||||
iconPath: resolve(rootDirectory, 'build', 'icon.ico'),
|
||||
version: '1.0.17',
|
||||
version: developmentAppVersion,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,65 @@
|
||||
"src/tools.ts",
|
||||
"src/types.ts",
|
||||
"src/workspace.css",
|
||||
"scripts/smoke-test-cutter-media-matrix.js",
|
||||
"scripts/smoke-test-cutter-media-matrix.test.js",
|
||||
"scripts/smoke-test-installer.test.js",
|
||||
"scripts/smoke-test-live-integration-contract.js",
|
||||
"scripts/smoke-test-live-integration.js",
|
||||
"scripts/smoke-test-live-integration.test.js",
|
||||
"scripts/smoke-test-managed-tools-live.js",
|
||||
"scripts/smoke-test-managed-tools-live.test.js",
|
||||
"src/cutter-workspace-actions.production-path.test.ts",
|
||||
"src/german-source-text.production-path.test.ts",
|
||||
"src/main-runtime.production-path.test.ts",
|
||||
"src/main-shutdown.production-path.test.ts",
|
||||
"src/main/cutter/index.ts",
|
||||
"src/main/domain/config-import.production-path.test.ts",
|
||||
"src/main/domain/config-input.test.ts",
|
||||
"src/main/domain/config-input.ts",
|
||||
"src/main/domain/cutter-vfr.production-path.test.ts",
|
||||
"src/main/domain/external-error.test.ts",
|
||||
"src/main/domain/external-error.ts",
|
||||
"src/main/domain/last-good-cache.test.ts",
|
||||
"src/main/domain/last-good-cache.ts",
|
||||
"src/main/domain/merge-recovery.test.ts",
|
||||
"src/main/domain/merge-recovery.ts",
|
||||
"src/main/domain/merge-split.production-path.test.ts",
|
||||
"src/main/domain/phase-boundary-process.test.ts",
|
||||
"src/main/domain/phase-boundary-process.ts",
|
||||
"src/main/domain/phase-boundary.production-path.test.ts",
|
||||
"src/main/domain/provider-payload.test.ts",
|
||||
"src/main/domain/provider-payload.ts",
|
||||
"src/main/domain/queue-addition.production-path.test.ts",
|
||||
"src/main/domain/queue-addition.test.ts",
|
||||
"src/main/domain/queue-addition.ts",
|
||||
"src/main/domain/queue-runtime.test.ts",
|
||||
"src/main/domain/queue-runtime.ts",
|
||||
"src/main/domain/refresh-result.test.ts",
|
||||
"src/main/domain/refresh-result.ts",
|
||||
"src/main/domain/runtime-safety.test.ts",
|
||||
"src/main/domain/runtime-safety.ts",
|
||||
"src/main/domain/twitch-refresh.production-path.test.ts",
|
||||
"src/main/queue/index.ts",
|
||||
"src/main/storage/index.ts",
|
||||
"src/main/twitch/app-token.test.ts",
|
||||
"src/main/twitch/app-token.ts",
|
||||
"src/main/twitch/index.ts",
|
||||
"src/main/twitch/provider-refresh.test.ts",
|
||||
"src/main/twitch/provider-refresh.ts",
|
||||
"src/main/updates/index.ts",
|
||||
"src/main/updates/update-lifecycle.production-path.test.ts",
|
||||
"src/main/updates/update-lifecycle.test.ts",
|
||||
"src/main/updates/update-lifecycle.ts",
|
||||
"src/renderer-profile.production-path.test.ts",
|
||||
"src/renderer-queue.production-path.test.ts",
|
||||
"src/renderer-settings.production-path.test.ts",
|
||||
"src/renderer-streamers.state-regressions.test.ts",
|
||||
"src/renderer-vod-hover.lifecycle.test.ts",
|
||||
"src/style-modules.production-path.test.ts",
|
||||
"src/styles-overlays.css",
|
||||
"src/styles-workflows.css",
|
||||
"src/workspace-refinements.css",
|
||||
"tsconfig.json",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
|
||||
@@ -9,12 +9,401 @@ function check(condition, message) {
|
||||
if (!condition) failures.push(message);
|
||||
}
|
||||
|
||||
function addField(fields, key, value) {
|
||||
if (!fields.has(key)) fields.set(key, []);
|
||||
fields.get(key).push(value);
|
||||
}
|
||||
|
||||
function singleField(fields, key) {
|
||||
const values = fields.get(key) || [];
|
||||
return values.length === 1 ? values[0] : undefined;
|
||||
}
|
||||
|
||||
function recordDuplicateFields(duplicates, fields, prefix) {
|
||||
for (const [key, values] of fields) {
|
||||
if (values.length > 1) duplicates.add(`${prefix}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMappingField(line, indentation, listItem = false) {
|
||||
let leadingSpaces = 0;
|
||||
while (line[leadingSpaces] === ' ') leadingSpaces += 1;
|
||||
if (leadingSpaces !== indentation) return undefined;
|
||||
let body = line.slice(indentation);
|
||||
if (listItem) {
|
||||
if (!body.startsWith('- ')) return undefined;
|
||||
body = body.slice(2);
|
||||
}
|
||||
const colon = body.indexOf(':');
|
||||
if (colon <= 0) return undefined;
|
||||
const key = body.slice(0, colon);
|
||||
if (![...key].every((character) => /[A-Za-z0-9_-]/.test(character))) return undefined;
|
||||
return [key, body.slice(colon + 1).trimStart()];
|
||||
}
|
||||
|
||||
function parseWorkflow(source) {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const duplicateKeys = new Set();
|
||||
const topLevelFields = new Map();
|
||||
for (const line of lines) {
|
||||
const field = parseMappingField(line, 0);
|
||||
if (field) addField(topLevelFields, field[0], field[1]);
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, topLevelFields, 'workflow');
|
||||
const jobsStart = lines.findIndex((line) => line === 'jobs:');
|
||||
const jobs = new Map();
|
||||
if (jobsStart >= 0) {
|
||||
const jobsEndOffset = lines.slice(jobsStart + 1).findIndex((line) => /^\S/.test(line));
|
||||
const jobsEnd = jobsEndOffset < 0 ? lines.length : jobsStart + 1 + jobsEndOffset;
|
||||
const jobStarts = [];
|
||||
for (let index = jobsStart + 1; index < jobsEnd; index += 1) {
|
||||
const field = parseMappingField(lines[index], 2);
|
||||
if (field?.[1] === '') jobStarts.push({ index, name: field[0] });
|
||||
}
|
||||
const jobNames = new Map();
|
||||
for (const jobStart of jobStarts) addField(jobNames, jobStart.name, '');
|
||||
recordDuplicateFields(duplicateKeys, jobNames, 'jobs');
|
||||
for (let jobIndex = 0; jobIndex < jobStarts.length; jobIndex += 1) {
|
||||
const start = jobStarts[jobIndex].index;
|
||||
const end = jobStarts[jobIndex + 1]?.index || jobsEnd;
|
||||
const fields = new Map();
|
||||
for (let index = start + 1; index < end; index += 1) {
|
||||
const field = parseMappingField(lines[index], 4);
|
||||
if (field) addField(fields, field[0], field[1]);
|
||||
}
|
||||
const jobPath = `jobs.${jobStarts[jobIndex].name}`;
|
||||
recordDuplicateFields(duplicateKeys, fields, jobPath);
|
||||
const env = new Map();
|
||||
const envStart = lines.findIndex((line, index) => index > start && index < end && line === ' env:');
|
||||
if (envStart >= 0) {
|
||||
for (let index = envStart + 1; index < end; index += 1) {
|
||||
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
|
||||
const field = parseMappingField(lines[index], 6);
|
||||
if (field) addField(env, field[0], field[1]);
|
||||
}
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, env, `${jobPath}.env`);
|
||||
const stepsStart = lines.findIndex((line, index) => index > start && index < end && line === ' steps:');
|
||||
const steps = [];
|
||||
if (stepsStart >= 0) {
|
||||
const stepStarts = [];
|
||||
for (let index = stepsStart + 1; index < end; index += 1) {
|
||||
if (/^ {6}-\s+/.test(lines[index])) stepStarts.push(index);
|
||||
}
|
||||
for (let stepIndex = 0; stepIndex < stepStarts.length; stepIndex += 1) {
|
||||
const stepStart = stepStarts[stepIndex];
|
||||
const stepEnd = stepStarts[stepIndex + 1] || end;
|
||||
const stepFields = new Map();
|
||||
const firstField = parseMappingField(lines[stepStart], 6, true);
|
||||
if (firstField) addField(stepFields, firstField[0], firstField[1]);
|
||||
for (let index = stepStart + 1; index < stepEnd; index += 1) {
|
||||
const field = parseMappingField(lines[index], 8);
|
||||
if (field) addField(stepFields, field[0], field[1]);
|
||||
}
|
||||
const stepPath = `${jobPath}.steps[${stepIndex}]`;
|
||||
recordDuplicateFields(duplicateKeys, stepFields, stepPath);
|
||||
const stepEnv = new Map();
|
||||
const stepEnvStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' env:');
|
||||
if (stepEnvStart >= 0) {
|
||||
for (let index = stepEnvStart + 1; index < stepEnd; index += 1) {
|
||||
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
|
||||
const field = parseMappingField(lines[index], 10);
|
||||
if (field) addField(stepEnv, field[0], field[1]);
|
||||
}
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, stepEnv, `${stepPath}.env`);
|
||||
const stepWith = new Map();
|
||||
const stepWithStart = lines.findIndex((line, index) => index > stepStart && index < stepEnd && line === ' with:');
|
||||
if (stepWithStart >= 0) {
|
||||
for (let index = stepWithStart + 1; index < stepEnd; index += 1) {
|
||||
if (lines[index].trim() && !lines[index].startsWith(' ')) break;
|
||||
const field = parseMappingField(lines[index], 10);
|
||||
if (field) addField(stepWith, field[0], field[1]);
|
||||
}
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, stepWith, `${stepPath}.with`);
|
||||
steps.push({
|
||||
env: stepEnv,
|
||||
fields: stepFields,
|
||||
name: singleField(stepFields, 'name'),
|
||||
raw: lines.slice(stepStart, stepEnd).join('\n'),
|
||||
with: stepWith
|
||||
});
|
||||
}
|
||||
}
|
||||
jobs.set(jobStarts[jobIndex].name, {
|
||||
env,
|
||||
fields,
|
||||
header: lines.slice(start + 1, stepsStart >= 0 ? stepsStart : end).join('\n'),
|
||||
name: jobStarts[jobIndex].name,
|
||||
steps
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchInputs = new Map();
|
||||
const onStart = lines.findIndex((line) => line === 'on:');
|
||||
if (onStart >= 0) {
|
||||
const onEndOffset = lines.slice(onStart + 1).findIndex((line) => /^\S/.test(line));
|
||||
const onEnd = onEndOffset < 0 ? lines.length : onStart + 1 + onEndOffset;
|
||||
const dispatchStart = lines.findIndex((line, index) => index > onStart && index < onEnd && line === ' workflow_dispatch:');
|
||||
const inputsStart = lines.findIndex((line, index) => index > dispatchStart && index < onEnd && line === ' inputs:');
|
||||
if (dispatchStart >= 0 && inputsStart >= 0) {
|
||||
for (let index = inputsStart + 1; index < onEnd; index += 1) {
|
||||
const field = parseMappingField(lines[index], 6);
|
||||
if (field?.[1] === '') addField(dispatchInputs, field[0], '');
|
||||
}
|
||||
recordDuplicateFields(duplicateKeys, dispatchInputs, 'on.workflow_dispatch.inputs');
|
||||
}
|
||||
}
|
||||
|
||||
return { dispatchInputs, duplicateKeys: [...duplicateKeys], jobs };
|
||||
}
|
||||
|
||||
function positiveTimeout(fields) {
|
||||
const values = fields.get('timeout-minutes') || [];
|
||||
return values.length === 1 && /^\d+$/.test(values[0]) && Number(values[0]) > 0;
|
||||
}
|
||||
|
||||
function validateTimeouts(workflow, label, errors) {
|
||||
for (const job of workflow.jobs.values()) {
|
||||
if (!positiveTimeout(job.fields)) errors.push(`${label} job ${job.name} must define exactly one positive job-level timeout-minutes`);
|
||||
for (let index = 0; index < job.steps.length; index += 1) {
|
||||
const step = job.steps[index];
|
||||
const executionFields = (step.fields.get('run') || []).length + (step.fields.get('uses') || []).length;
|
||||
if (executionFields === 0) continue;
|
||||
const stepLabel = step.name || `unnamed step ${index + 1}`;
|
||||
if (executionFields !== 1) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one of run or uses`);
|
||||
if (!positiveTimeout(step.fields)) errors.push(`${label} ${job.name} ${stepLabel} must define exactly one positive timeout-minutes`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateCheckouts(workflow, label, errors) {
|
||||
for (const job of workflow.jobs.values()) {
|
||||
const checkouts = job.steps.filter((step) => /^actions\/checkout@/.test(singleField(step.fields, 'uses') || ''));
|
||||
if (checkouts.length !== 1 || singleField(checkouts[0].fields, 'uses') !== 'actions/checkout@v4') {
|
||||
errors.push(`${label} job ${job.name} must define exactly one actions/checkout@v4 step`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSetupNode(workflow, label, errors) {
|
||||
for (const job of workflow.jobs.values()) {
|
||||
const setupSteps = job.steps.filter((step) => /^actions\/setup-node@/.test(singleField(step.fields, 'uses') || ''));
|
||||
if (setupSteps.length !== 1 || singleField(setupSteps[0].fields, 'uses') !== 'actions/setup-node@v4' || singleField(setupSteps[0].with, 'node-version') !== "'24.11.1'") {
|
||||
errors.push(`${label} job ${job.name} must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stepByName(job, name) {
|
||||
const matches = job?.steps.filter((step) => step.name === name) || [];
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
}
|
||||
|
||||
function stepIndexByRun(job, command) {
|
||||
return job?.steps.findIndex((step) => singleField(step.fields, 'run') === command) ?? -1;
|
||||
}
|
||||
|
||||
function hasTrimmedLine(step, expected) {
|
||||
return step?.raw.split(/\r?\n/).some((line) => line.trim() === expected) || false;
|
||||
}
|
||||
|
||||
const strictVersionPattern = "'^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$'";
|
||||
|
||||
function hasStrictUpdaterVersions(step) {
|
||||
return hasTrimmedLine(step, '$sourceVersionText = $env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.Trim()')
|
||||
&& hasTrimmedLine(step, '$updateVersionText = $env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.Trim()')
|
||||
&& hasTrimmedLine(step, `$versionPattern = ${strictVersionPattern}`)
|
||||
&& hasTrimmedLine(step, 'if ($sourceVersionText -notmatch $versionPattern) {')
|
||||
&& hasTrimmedLine(step, 'if ($updateVersionText -notmatch $versionPattern) {')
|
||||
&& hasTrimmedLine(step, '$sourceVersion = [version]$sourceVersionText')
|
||||
&& hasTrimmedLine(step, '$updateVersion = [version]$updateVersionText')
|
||||
&& !step.raw.includes('TrimStart');
|
||||
}
|
||||
|
||||
function hasExactChainedCommand(script, expected) {
|
||||
if (typeof script !== 'string') return false;
|
||||
return script.split(/\s*&&\s*/).filter((command) => command === expected).length === 1;
|
||||
}
|
||||
|
||||
function validateManualGate(job, gate, label, errors) {
|
||||
const expected = `github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == '${gate}'`;
|
||||
if (singleField(job?.fields || new Map(), 'if') !== expected) errors.push(`${label} ${job?.name || gate} must be manual-only for ${gate}`);
|
||||
if (singleField(job?.fields || new Map(), 'needs') !== 'verify') errors.push(`${label} ${job?.name || gate} must require verify`);
|
||||
}
|
||||
|
||||
function findSecretLeaks(job, allowedStep, secretNames) {
|
||||
const leaks = [];
|
||||
for (const name of secretNames) {
|
||||
if (job.env.has(name) || job.header.includes(`secrets.${name}`)) leaks.push(`job:${name}`);
|
||||
for (let index = 0; index < job.steps.length; index += 1) {
|
||||
const step = job.steps[index];
|
||||
if (step === allowedStep) continue;
|
||||
if (step.env.has(name) || step.raw.includes(`secrets.${name}`)) leaks.push(`step:${index + 1}:${name}`);
|
||||
}
|
||||
}
|
||||
return leaks;
|
||||
}
|
||||
|
||||
const parserFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: named
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 11
|
||||
- run: npm test`);
|
||||
const parserFixtureErrors = [];
|
||||
validateTimeouts(parserFixture, 'fixture', parserFixtureErrors);
|
||||
check(parserFixture.jobs.get('fixture')?.steps.length === 2, 'CI parser does not recognize an unnamed step boundary');
|
||||
check(parserFixtureErrors.includes('fixture fixture named must define exactly one positive timeout-minutes'), 'CI timeout contract accepts duplicate step timeouts');
|
||||
check(parserFixtureErrors.includes('fixture fixture unnamed step 2 must define exactly one positive timeout-minutes'), 'CI timeout contract accepts a missing timeout hidden by another step');
|
||||
|
||||
const gateFixture = parseWorkflow(`jobs:
|
||||
updater-live-postpublish:
|
||||
if: false
|
||||
needs: verify
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- run: Write-Output "github.event_name == 'workflow_dispatch' && github.event.inputs.live_gate == 'updater-postpublish'"
|
||||
timeout-minutes: 10`);
|
||||
const gateFixtureErrors = [];
|
||||
validateManualGate(gateFixture.jobs.get('updater-live-postpublish'), 'updater-postpublish', 'fixture', gateFixtureErrors);
|
||||
check(gateFixtureErrors.includes('fixture updater-live-postpublish must be manual-only for updater-postpublish'), 'CI manual gate contract accepts a condition embedded in run text');
|
||||
|
||||
const secretNames = ['TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN', 'TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID'];
|
||||
const secretFixture = parseWorkflow(`jobs:
|
||||
twitch-live:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: provider
|
||||
run: npm run provider
|
||||
timeout-minutes: 10
|
||||
- run: npm run unrelated
|
||||
env:
|
||||
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: \${{ secrets.TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET }}
|
||||
timeout-minutes: 10`);
|
||||
const secretFixtureJob = secretFixture.jobs.get('twitch-live');
|
||||
check(findSecretLeaks(secretFixtureJob, stepByName(secretFixtureJob, 'provider'), secretNames).includes('step:2:TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET'), 'CI secret scope contract misses a leak in an unnamed following step');
|
||||
|
||||
const releaseCommandPrefixFixture = 'npm run build && npm run test:live-integration-contract-shadow';
|
||||
check(!hasExactChainedCommand(releaseCommandPrefixFixture, 'npm run test:live-integration-contract'), 'Release command contract accepts a longer command with the required command as a prefix');
|
||||
check(hasExactChainedCommand('npm run build && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract rejects an exact required command');
|
||||
check(!hasExactChainedCommand('npm run build && npm run test:unit', 'npm run test:live-integration-contract'), 'Release command contract accepts a missing required command');
|
||||
check(!hasExactChainedCommand('npm run test:live-integration-contract && npm run test:live-integration-contract', 'npm run test:live-integration-contract'), 'Release command contract accepts a duplicate required command');
|
||||
|
||||
const duplicateKeyFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CI: 'true'
|
||||
CI: 'false'
|
||||
env:
|
||||
OTHER: value
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: first
|
||||
ref: second
|
||||
with:
|
||||
fetch-depth: 1
|
||||
timeout-minutes: 10`);
|
||||
for (const duplicatePath of ['jobs.fixture.env', 'jobs.fixture.env.CI', 'jobs.fixture.steps[0].with', 'jobs.fixture.steps[0].with.ref']) {
|
||||
check(duplicateKeyFixture.duplicateKeys?.includes(duplicatePath), `CI parser does not report duplicate YAML key ${duplicatePath}`);
|
||||
}
|
||||
|
||||
const duplicateJobFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: npm test
|
||||
timeout-minutes: 10
|
||||
fixture:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: npm test
|
||||
timeout-minutes: 10`);
|
||||
check(duplicateJobFixture.duplicateKeys?.includes('jobs.fixture'), 'CI parser does not report a duplicate job key');
|
||||
|
||||
const permissiveVersionPreflightFixture = {
|
||||
raw: `$sourceVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION.TrimStart('v'))
|
||||
$updateVersion = [version]($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION.TrimStart('v'))`
|
||||
};
|
||||
check(!hasStrictUpdaterVersions(permissiveVersionPreflightFixture), 'CI version preflight contract accepts prefixes, suffixes or leading zeroes');
|
||||
|
||||
const missingCheckoutFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
timeout-minutes: 10`);
|
||||
const missingCheckoutErrors = [];
|
||||
validateCheckouts(missingCheckoutFixture, 'fixture', missingCheckoutErrors);
|
||||
check(missingCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts a job without checkout');
|
||||
|
||||
const extraCheckoutFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
timeout-minutes: 10
|
||||
- uses: actions/checkout@v3
|
||||
timeout-minutes: 10`);
|
||||
const extraCheckoutErrors = [];
|
||||
validateCheckouts(extraCheckoutFixture, 'fixture', extraCheckoutErrors);
|
||||
check(extraCheckoutErrors.includes('fixture job fixture must define exactly one actions/checkout@v4 step'), 'CI job action contract accepts an additional checkout version');
|
||||
|
||||
const extraSetupNodeFixture = parseWorkflow(`jobs:
|
||||
fixture:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-node@v4
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
node-version: '24.11.1'
|
||||
- uses: actions/setup-node@v3
|
||||
timeout-minutes: 10`);
|
||||
const extraSetupNodeErrors = [];
|
||||
validateSetupNode(extraSetupNodeFixture, 'fixture', extraSetupNodeErrors);
|
||||
check(extraSetupNodeErrors.includes('fixture job fixture must define exactly one actions/setup-node@v4 step pinned to Node 24.11.1'), 'CI job action contract accepts an additional setup-node version');
|
||||
|
||||
function hasSingleConditionalRetry(source, command) {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const commandIndexes = lines
|
||||
.map((line, index) => line.trim() === command ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
if (commandIndexes.length !== 2) return false;
|
||||
const [first, second] = commandIndexes;
|
||||
return second === first + 2
|
||||
&& lines[first + 1]?.trim() === 'if ($LASTEXITCODE -ne 0) {'
|
||||
&& lines[first + 3]?.trim() === 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }'
|
||||
&& lines[first + 4]?.trim() === '}';
|
||||
}
|
||||
|
||||
const retryFixture = (command) => `${command}\nif ($LASTEXITCODE -ne 0) {\n ${command}\n if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }\n}`;
|
||||
for (const command of ['npx install-electron --no', 'npm run pack', 'npm run dist:ci']) {
|
||||
check(hasSingleConditionalRetry(retryFixture(command), command), `CI retry contract rejects a valid ${command} retry`);
|
||||
check(!hasSingleConditionalRetry(`${retryFixture(command)}\n${command}`, command), `CI retry contract accepts more than one ${command} retry`);
|
||||
check(!hasSingleConditionalRetry(`${command}\n${command}`, command), `CI retry contract accepts an unconditional ${command} retry`);
|
||||
}
|
||||
|
||||
const requiredScripts = {
|
||||
lint: 'eslint .',
|
||||
'security:check': 'node scripts/security-check.js && node scripts/smoke-test-public-release-config.js',
|
||||
'test:security': 'node --test scripts/security-check.test.js',
|
||||
'test:lint-config': 'node --test scripts/lint-config.test.mjs',
|
||||
'test:ci-contract': 'node scripts/smoke-test-ci-contract.js',
|
||||
'test:installer-contract': 'node --test scripts/smoke-test-installer.test.js',
|
||||
'test:managed-tools-contract': 'node --test scripts/smoke-test-managed-tools-live.test.js',
|
||||
'test:cutter-matrix-contract': 'node --test scripts/smoke-test-cutter-media-matrix.test.js',
|
||||
'test:managed-tools-live': 'node scripts/smoke-test-managed-tools-live.js',
|
||||
'test:live-integration-contract': 'npm run build && node --test scripts/smoke-test-live-integration.test.js',
|
||||
'test:live:twitch': 'node scripts/smoke-test-live-integration.js twitch',
|
||||
'test:live:updater-postpublish': 'node scripts/smoke-test-live-integration.js updater',
|
||||
'test:e2e:cutter-matrix': 'npm run build && node scripts/smoke-test-cutter-media-matrix.js',
|
||||
'test:e2e:focused': 'npm run test:e2e:isolation && npm run test:e2e:workspace-ui',
|
||||
'test:packaged-launch': 'node scripts/smoke-test-packaged-launch.js',
|
||||
'test:installer': 'node scripts/smoke-test-installer.js',
|
||||
@@ -25,50 +414,110 @@ for (const [name, command] of Object.entries(requiredScripts)) {
|
||||
check(packageJson.scripts?.[name] === command, `package script ${name} is missing or changed`);
|
||||
}
|
||||
|
||||
for (const contract of ['test:installer-contract', 'test:managed-tools-contract', 'test:cutter-matrix-contract', 'test:live-integration-contract']) {
|
||||
check(hasExactChainedCommand(packageJson.scripts?.['test:e2e:release'], `npm run ${contract}`), `release verification does not include exactly one ${contract} command`);
|
||||
}
|
||||
|
||||
const workflowSources = new Map();
|
||||
for (const relativePath of ['.github/workflows/windows-ci.yml', '.gitea/workflows/windows-ci.yml']) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
check(fs.existsSync(absolutePath), `${relativePath} is missing`);
|
||||
if (!fs.existsSync(absolutePath)) continue;
|
||||
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
workflowSources.set(relativePath, source);
|
||||
const workflow = parseWorkflow(source);
|
||||
const verifyJob = workflow.jobs.get('verify');
|
||||
const twitchLiveJob = workflow.jobs.get('twitch-live');
|
||||
const updaterLiveJob = workflow.jobs.get('updater-live-postpublish');
|
||||
const requiredCommands = [
|
||||
'npm ci',
|
||||
'npx install-electron --no',
|
||||
'npm run lint',
|
||||
'npm run test:lint-config',
|
||||
'npm run security:check',
|
||||
'npm run test:security',
|
||||
'npm run test:ci-contract',
|
||||
'npm run test:installer-contract',
|
||||
'npm run test:managed-tools-contract',
|
||||
'npm run test:cutter-matrix-contract',
|
||||
'npm run test:live-integration-contract',
|
||||
'npm run test:unit',
|
||||
'npm run test:e2e:focused',
|
||||
'npm run build',
|
||||
'npm run pack',
|
||||
'node scripts/smoke-test-cutter-media-matrix.js',
|
||||
'npm run test:managed-tools-live',
|
||||
'npm run test:packaged-launch',
|
||||
'npm run dist:ci',
|
||||
'npm run test:installer'
|
||||
];
|
||||
|
||||
check(/runs-on:\s*windows-latest/.test(source), `${relativePath} does not use a Windows runner`);
|
||||
check(/node-version:\s*['"]?24\.11\.1['"]?/.test(source), `${relativePath} does not pin Node 24.11.1`);
|
||||
check(workflow.jobs.size === 3 && verifyJob && twitchLiveJob && updaterLiveJob, `${relativePath} must define exactly verify, twitch-live and updater-live-postpublish jobs`);
|
||||
check(workflow.duplicateKeys.length === 0, `${relativePath} contains duplicate YAML keys: ${workflow.duplicateKeys.join(', ')}`);
|
||||
validateTimeouts(workflow, relativePath, failures);
|
||||
validateCheckouts(workflow, relativePath, failures);
|
||||
validateSetupNode(workflow, relativePath, failures);
|
||||
for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) {
|
||||
check((workflow.dispatchInputs.get(input) || []).length === 1, `${relativePath} workflow_dispatch must define explicit ${input}`);
|
||||
}
|
||||
for (const job of workflow.jobs.values()) check(singleField(job.fields, 'runs-on') === 'windows-latest', `${relativePath} ${job.name} does not use a Windows runner`);
|
||||
for (const command of requiredCommands) {
|
||||
check(source.includes(command), `${relativePath} is missing ${command}`);
|
||||
check(stepIndexByRun(verifyJob, command) >= 0, `${relativePath} verify job is missing an exact ${command} run step`);
|
||||
}
|
||||
const verifyBuildIndex = stepIndexByRun(verifyJob, 'npm run build');
|
||||
const verifyLiveContractIndex = stepIndexByRun(verifyJob, 'npm run test:live-integration-contract');
|
||||
check(verifyBuildIndex >= 0 && verifyBuildIndex < verifyLiveContractIndex, `${relativePath} verify must build before the live integration contract`);
|
||||
check(verifyBuildIndex < stepIndexByRun(verifyJob, 'npm run test:managed-tools-live'), `${relativePath} runs the live managed-tools check before build`);
|
||||
for (const command of ['npm run pack', 'npm run dist:ci']) {
|
||||
check(source.match(new RegExp(command.replace(/[:]/g, '\\:'), 'g'))?.length >= 2, `${relativePath} does not retry transient ${command} failures`);
|
||||
check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', command), `${relativePath} does not retry transient ${command} failures exactly once`);
|
||||
}
|
||||
check(source.match(/npx install-electron --no/g)?.length >= 2, `${relativePath} does not retry Electron binary provisioning`);
|
||||
const runSteps = source.split(/\r?\n/).filter((line) => /^\s+run:\s+/.test(line));
|
||||
const timeoutSteps = source.split(/\r?\n/).filter((line) => /^\s+timeout-minutes:\s*10\s*$/.test(line));
|
||||
check(timeoutSteps.length >= runSteps.length, `${relativePath} does not cap every command at ten minutes`);
|
||||
check(!/test:[^\s]*authenticated|TWITCH_CLIENT_SECRET|DISCORD_WEBHOOK/i.test(source), `${relativePath} includes authenticated integration inputs`);
|
||||
check(hasSingleConditionalRetry(verifyJob?.steps.map((step) => step.raw).join('\n') || '', 'npx install-electron --no'), `${relativePath} does not retry Electron binary provisioning exactly once`);
|
||||
check(findSecretLeaks(verifyJob, undefined, secretNames).length === 0, `${relativePath} exposes Twitch live inputs to normal CI`);
|
||||
validateManualGate(twitchLiveJob, 'twitch', relativePath, failures);
|
||||
const twitchProviderStep = stepByName(twitchLiveJob, 'Twitch provider OAuth, Helix and bounded VOD gate');
|
||||
for (const name of secretNames) {
|
||||
check(singleField(twitchProviderStep?.env || new Map(), name) === `\${{ secrets.${name} }}`, `${relativePath} Twitch live ${name.toLowerCase()} is not scoped to the final provider step`);
|
||||
}
|
||||
check(findSecretLeaks(twitchLiveJob, twitchProviderStep, secretNames).length === 0, `${relativePath} exposes Twitch secrets outside the final provider step`);
|
||||
check(singleField(twitchProviderStep?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && singleField(twitchProviderStep?.fields || new Map(), 'run') === 'npm run test:live:twitch', `${relativePath} Twitch live gate is not explicitly opted in at the final provider step`);
|
||||
check(twitchLiveJob?.steps.some((step) => step.name === 'Provision pinned media tools' && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH') && step.raw.includes('TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH')), `${relativePath} Twitch live gate does not provision its real media tools`);
|
||||
check(stepIndexByRun(twitchLiveJob, 'npm run build') >= 0 && stepIndexByRun(twitchLiveJob, 'npm run build') < stepIndexByRun(twitchLiveJob, 'npm run test:live-integration-contract'), `${relativePath} Twitch live gate must build before its integration contract`);
|
||||
validateManualGate(updaterLiveJob, 'updater-postpublish', relativePath, failures);
|
||||
for (const input of ['source_version', 'source_sha256', 'update_version', 'update_sha512']) {
|
||||
check(singleField(updaterLiveJob?.env || new Map(), `TWITCH_VOD_MANAGER_LIVE_${input.toUpperCase()}`) === `\${{ github.event.inputs.${input} }}`, `${relativePath} updater live gate does not bind explicit ${input}`);
|
||||
}
|
||||
check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA') === '${{ github.sha }}', `${relativePath} updater live gate does not bind provenance to github.sha`);
|
||||
const updaterCheckout = updaterLiveJob?.steps.filter((step) => singleField(step.fields, 'uses') === 'actions/checkout@v4') || [];
|
||||
check(updaterCheckout.length === 1 && singleField(updaterCheckout[0].with, 'ref') === '${{ github.sha }}', `${relativePath} updater checkout is not explicitly bound to github.sha`);
|
||||
check(singleField(updaterLiveJob?.env || new Map(), 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION') === "'1'" && stepIndexByRun(updaterLiveJob, 'npm run test:live:updater-postpublish') >= 0, `${relativePath} updater live gate is not explicitly opted in`);
|
||||
check(stepIndexByRun(updaterLiveJob, 'npm run build') >= 0 && stepIndexByRun(updaterLiveJob, 'npm run build') < stepIndexByRun(updaterLiveJob, 'npm run test:live-integration-contract'), `${relativePath} updater live gate must build before its integration contract`);
|
||||
const updaterPreflight = stepByName(updaterLiveJob, 'Require explicit post-publish updater inputs');
|
||||
check(updaterPreflight && secretNames.every((name) => !updaterPreflight.raw.includes(name)), `${relativePath} updater preflight references Twitch credentials`);
|
||||
check(hasTrimmedLine(updaterPreflight, "'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA'") && hasTrimmedLine(updaterPreflight, "if ($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA -notmatch '^[0-9a-fA-F]{40}$') {") && hasTrimmedLine(updaterPreflight, 'if (-not [string]::Equals($env:TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA, $env:GITHUB_SHA, [StringComparison]::OrdinalIgnoreCase)) {'), `${relativePath} updater preflight does not verify commit provenance against GITHUB_SHA`);
|
||||
check(hasStrictUpdaterVersions(updaterPreflight) && hasTrimmedLine(updaterPreflight, 'if ($sourceVersion -ge $updateVersion) {'), `${relativePath} updater preflight does not require exact source_version < update_version`);
|
||||
check(hasTrimmedLine(updaterPreflight, '$packageVersion = (Get-Content -Raw -LiteralPath package.json | ConvertFrom-Json).version') && hasTrimmedLine(updaterPreflight, 'if ($updateVersionText -ne $packageVersion) {') && hasTrimmedLine(updaterPreflight, '$expectedRef = "refs/tags/v$updateVersionText"') && hasTrimmedLine(updaterPreflight, 'if ($env:GITHUB_REF -ne $expectedRef) {'), `${relativePath} updater preflight does not bind the exact update version text to package.json and its release tag`);
|
||||
const updaterExecutionStep = stepByName(updaterLiveJob, 'Verify published updater path');
|
||||
check(singleField(updaterExecutionStep?.fields || new Map(), 'timeout-minutes') === '25', `${relativePath} updater live gate does not allow 25 minutes for a real installer download`);
|
||||
}
|
||||
|
||||
check(workflowSources.get('.github/workflows/windows-ci.yml') === workflowSources.get('.gitea/workflows/windows-ci.yml'), 'GitHub and Gitea workflows are not byte-identical');
|
||||
|
||||
const liveIntegrationSource = fs.readFileSync(path.join(root, 'scripts/smoke-test-live-integration.js'), 'utf8');
|
||||
check(/dist['"],\s*['"]main['"],\s*['"]twitch['"]/.test(liveIntegrationSource), 'Twitch provider live gate does not load the built Twitch product module');
|
||||
check(liveIntegrationSource.includes('TwitchAppTokenService') && liveIntegrationSource.includes('requestTwitchAppAccessToken'), 'Twitch provider live gate bypasses the product token service');
|
||||
|
||||
for (const relativePath of [
|
||||
'scripts/security-check.js',
|
||||
'scripts/security-check.test.js',
|
||||
'scripts/lint-config.test.mjs',
|
||||
'scripts/smoke-test-packaged-launch.js',
|
||||
'scripts/smoke-test-installer.js'
|
||||
'scripts/smoke-test-installer.js',
|
||||
'scripts/smoke-test-installer.test.js',
|
||||
'scripts/smoke-test-managed-tools-live.js',
|
||||
'scripts/smoke-test-managed-tools-live.test.js',
|
||||
'scripts/smoke-test-live-integration-contract.js',
|
||||
'scripts/smoke-test-live-integration.js',
|
||||
'scripts/smoke-test-live-integration.test.js',
|
||||
'scripts/smoke-test-cutter-media-matrix.js',
|
||||
'scripts/smoke-test-cutter-media-matrix.test.js'
|
||||
]) {
|
||||
check(fs.existsSync(path.join(root, relativePath)), `${relativePath} is missing`);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,588 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
activateOfflineRunnerEnvironment,
|
||||
assertLockedTargetFailure,
|
||||
assertManagedExecutionDiagnostics,
|
||||
assertPinnedVersion,
|
||||
assertTrimBoundaryMarkers,
|
||||
closeElectronApp,
|
||||
createManagedMediaRuntime,
|
||||
estimatePcmFrequency,
|
||||
exportSource,
|
||||
prepareSource,
|
||||
provisionManagedCutterTools,
|
||||
runCutterMatrixLifecycle,
|
||||
sampleVideoRgb
|
||||
} = require('./smoke-test-cutter-media-matrix');
|
||||
|
||||
function createEnvironment() {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-cutter-toolchain-contract-'));
|
||||
const appDataDir = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager');
|
||||
fs.mkdirSync(appDataDir, { recursive: true });
|
||||
return { rootDir, appDataDir };
|
||||
}
|
||||
|
||||
function createProvisioningFixture() {
|
||||
const manifest = {
|
||||
streamlink: { id: 'streamlink', version: '8.4.0' },
|
||||
ffmpeg: { id: 'ffmpeg', version: '8.1.2' }
|
||||
};
|
||||
let initializedDirectories = null;
|
||||
const tools = {
|
||||
initToolDirs(streamlinkDirectory, ffmpegDirectory, getTemporaryDirectory) {
|
||||
initializedDirectories = {
|
||||
streamlinkDirectory,
|
||||
ffmpegDirectory,
|
||||
temporaryDirectory: getTemporaryDirectory()
|
||||
};
|
||||
},
|
||||
async repairManagedTools() {
|
||||
const streamlinkPath = path.join(initializedDirectories.streamlinkDirectory, 'bin', 'streamlink.exe');
|
||||
const ffmpegPath = path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffmpeg.exe');
|
||||
const ffprobePath = path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffprobe.exe');
|
||||
for (const filePath of [streamlinkPath, ffmpegPath, ffprobePath]) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, path.basename(filePath));
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
statuses: {
|
||||
streamlink: { state: 'verified', verified: true, version: manifest.streamlink.version },
|
||||
ffmpeg: { state: 'verified', verified: true, version: manifest.ffmpeg.version }
|
||||
}
|
||||
};
|
||||
},
|
||||
getStreamlinkPath() {
|
||||
return path.join(initializedDirectories.streamlinkDirectory, 'bin', 'streamlink.exe');
|
||||
},
|
||||
getFFmpegPath() {
|
||||
return path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffmpeg.exe');
|
||||
},
|
||||
getFFprobePath() {
|
||||
return path.join(initializedDirectories.ffmpegDirectory, 'bin', 'ffprobe.exe');
|
||||
}
|
||||
};
|
||||
return {
|
||||
manifest,
|
||||
tools,
|
||||
getInitializedDirectories: () => initializedDirectories
|
||||
};
|
||||
}
|
||||
|
||||
test('provisions and verifies the pinned product toolchain inside the isolated AppData tree', async (t) => {
|
||||
const environment = createEnvironment();
|
||||
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||
const fixture = createProvisioningFixture(environment);
|
||||
|
||||
const result = await provisionManagedCutterTools(environment, {
|
||||
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
|
||||
runVersionCheck: (executablePath, _args, label) => {
|
||||
if (label.includes('Streamlink')) return 'Streamlink 8.4.0';
|
||||
if (path.basename(executablePath).toLowerCase() === 'ffprobe.exe') return 'ffprobe version 8.1.2';
|
||||
return 'ffmpeg version 8.1.2';
|
||||
}
|
||||
});
|
||||
|
||||
const initialized = fixture.getInitializedDirectories();
|
||||
assert.deepEqual(initialized, {
|
||||
streamlinkDirectory: path.join(environment.appDataDir, 'tools', 'streamlink'),
|
||||
ffmpegDirectory: path.join(environment.appDataDir, 'tools', 'ffmpeg'),
|
||||
temporaryDirectory: path.join(environment.rootDir, 'managed-tools-temp')
|
||||
});
|
||||
assert.equal(result.statuses.streamlink.verified, true);
|
||||
assert.equal(result.statuses.ffmpeg.verified, true);
|
||||
assert.equal(result.versions.streamlink, 'Streamlink 8.4.0');
|
||||
assert.equal(result.versions.ffmpeg, 'ffmpeg version 8.1.2');
|
||||
assert.equal(result.versions.ffprobe, 'ffprobe version 8.1.2');
|
||||
assert.equal(result.paths.ffmpeg, fs.realpathSync.native(path.join(initialized.ffmpegDirectory, 'bin', 'ffmpeg.exe')));
|
||||
assert.equal(result.paths.ffprobe, fs.realpathSync.native(path.join(initialized.ffmpegDirectory, 'bin', 'ffprobe.exe')));
|
||||
assert.equal(result.paths.streamlink, fs.realpathSync.native(path.join(initialized.streamlinkDirectory, 'bin', 'streamlink.exe')));
|
||||
});
|
||||
|
||||
test('rejects a product tool path that escapes the owned installation directory', async (t) => {
|
||||
const environment = createEnvironment();
|
||||
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||
const fixture = createProvisioningFixture(environment);
|
||||
const outsidePath = path.join(environment.rootDir, 'outside-ffmpeg.exe');
|
||||
fs.writeFileSync(outsidePath, 'outside');
|
||||
fixture.tools.getFFmpegPath = () => outsidePath;
|
||||
|
||||
await assert.rejects(() => provisionManagedCutterTools(environment, {
|
||||
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
|
||||
runVersionCheck: () => '8.1.2'
|
||||
}), /outside the owned managed-tool directory/);
|
||||
});
|
||||
|
||||
test('rejects a managed tool that is not verified at the manifest version', async (t) => {
|
||||
const environment = createEnvironment();
|
||||
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||
const fixture = createProvisioningFixture();
|
||||
const repairManagedTools = fixture.tools.repairManagedTools;
|
||||
fixture.tools.repairManagedTools = async () => {
|
||||
const result = await repairManagedTools();
|
||||
result.statuses.ffmpeg.verified = false;
|
||||
result.statuses.ffmpeg.state = 'corrupt';
|
||||
return result;
|
||||
};
|
||||
|
||||
await assert.rejects(() => provisionManagedCutterTools(environment, {
|
||||
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
|
||||
runVersionCheck: () => '8.1.2'
|
||||
}), /FFmpeg is not verified at pinned 8\.1\.2/);
|
||||
});
|
||||
|
||||
test('rejects executable version output that differs from the pinned manifest', async (t) => {
|
||||
const environment = createEnvironment();
|
||||
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||
const fixture = createProvisioningFixture();
|
||||
|
||||
await assert.rejects(() => provisionManagedCutterTools(environment, {
|
||||
loadBuiltArtifacts: () => ({ tools: fixture.tools, manifest: fixture.manifest }),
|
||||
runVersionCheck: (_executablePath, _args, label) => label === 'FFmpeg' ? 'ffmpeg version 7.0.0' : label === 'FFprobe' ? 'ffprobe version 8.1.2' : 'Streamlink 8.4.0'
|
||||
}), /FFmpeg version output does not match pinned 8\.1\.2/);
|
||||
});
|
||||
|
||||
test('media runtime always executes the verified absolute product paths', () => {
|
||||
const calls = [];
|
||||
const runtime = createManagedMediaRuntime({
|
||||
ffmpeg: 'C:\\owned\\tools\\ffmpeg.exe',
|
||||
ffprobe: 'C:\\owned\\tools\\ffprobe.exe'
|
||||
}, (binary, args) => {
|
||||
calls.push({ binary, args });
|
||||
return { status: 0, stdout: `${path.win32.basename(binary)}:${args.join(',')}`, stderr: '' };
|
||||
});
|
||||
|
||||
assert.equal(runtime.ffmpeg(['-i', 'fixture.mkv']), 'ffmpeg.exe:-i,fixture.mkv');
|
||||
assert.equal(runtime.ffprobe(['-show_streams', 'fixture.mkv']), 'ffprobe.exe:-show_streams,fixture.mkv');
|
||||
assert.deepEqual(calls, [
|
||||
{ binary: 'C:\\owned\\tools\\ffmpeg.exe', args: ['-i', 'fixture.mkv'] },
|
||||
{ binary: 'C:\\owned\\tools\\ffprobe.exe', args: ['-show_streams', 'fixture.mkv'] }
|
||||
]);
|
||||
});
|
||||
|
||||
test('offline runner environment removes PATH and network fallback until restored', (t) => {
|
||||
const environment = createEnvironment();
|
||||
t.after(() => fs.rmSync(environment.rootDir, { recursive: true, force: true }));
|
||||
const variables = {
|
||||
PATH: 'C:\\system-tools',
|
||||
HTTP_PROXY: 'http://proxy.example.test:8080',
|
||||
HTTPS_PROXY: 'http://proxy.example.test:8080',
|
||||
ALL_PROXY: 'http://proxy.example.test:8080',
|
||||
NO_PROXY: 'localhost',
|
||||
LOCALAPPDATA: 'C:\\Users\\regular\\AppData\\Local',
|
||||
APPDATA: 'C:\\Users\\regular\\AppData\\Roaming',
|
||||
TEMP: 'C:\\Windows\\Temp',
|
||||
TMP: 'C:\\Windows\\Temp'
|
||||
};
|
||||
|
||||
const restore = activateOfflineRunnerEnvironment(environment, variables);
|
||||
assert.equal(variables.PATH, path.join(environment.rootDir, 'offline-path'));
|
||||
assert.equal(variables.HTTP_PROXY, 'http://127.0.0.1:1');
|
||||
assert.equal(variables.HTTPS_PROXY, 'http://127.0.0.1:1');
|
||||
assert.equal(variables.ALL_PROXY, 'http://127.0.0.1:1');
|
||||
assert.equal(variables.NO_PROXY, '');
|
||||
assert.equal(variables.LOCALAPPDATA, path.join(environment.rootDir, 'localappdata'));
|
||||
assert.equal(variables.APPDATA, path.join(environment.rootDir, 'roamingappdata'));
|
||||
assert.equal(variables.TEMP, path.join(environment.rootDir, 'runtime-temp'));
|
||||
assert.equal(variables.TMP, path.join(environment.rootDir, 'runtime-temp'));
|
||||
assert.equal(fs.statSync(variables.PATH).isDirectory(), true);
|
||||
|
||||
restore();
|
||||
assert.deepEqual(variables, {
|
||||
PATH: 'C:\\system-tools',
|
||||
HTTP_PROXY: 'http://proxy.example.test:8080',
|
||||
HTTPS_PROXY: 'http://proxy.example.test:8080',
|
||||
ALL_PROXY: 'http://proxy.example.test:8080',
|
||||
NO_PROXY: 'localhost',
|
||||
LOCALAPPDATA: 'C:\\Users\\regular\\AppData\\Local',
|
||||
APPDATA: 'C:\\Users\\regular\\AppData\\Roaming',
|
||||
TEMP: 'C:\\Windows\\Temp',
|
||||
TMP: 'C:\\Windows\\Temp'
|
||||
});
|
||||
});
|
||||
|
||||
test('requires the exact pinned executable version instead of a substring match', () => {
|
||||
assert.doesNotThrow(() => assertPinnedVersion('ffmpeg version 8.1.2 Copyright FFmpeg developers', '8.1.2', 'FFmpeg'));
|
||||
assert.doesNotThrow(() => assertPinnedVersion('ffmpeg version 8.1.2-essentials_build-www.gyan.dev Copyright FFmpeg developers', '8.1.2', 'FFmpeg'));
|
||||
assert.doesNotThrow(() => assertPinnedVersion('ffprobe version 8.1.2 Copyright FFmpeg developers', '8.1.2', 'FFprobe'));
|
||||
assert.doesNotThrow(() => assertPinnedVersion('ffprobe version 8.1.2-essentials_build-www.gyan.dev Copyright FFmpeg developers', '8.1.2', 'FFprobe'));
|
||||
assert.doesNotThrow(() => assertPinnedVersion('Streamlink 8.4.0', '8.4.0', 'Streamlink'));
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.20-essentials_build-www.gyan.dev', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 18.1.2-essentials_build-www.gyan.dev', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2+evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2---', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2evil', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2-evil!', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version v8.1.2', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 08.1.2', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
|
||||
assert.throws(() => assertPinnedVersion('ffmpeg version 8.1.2.0', '8.1.2', 'FFmpeg'), /does not match pinned 8\.1\.2/);
|
||||
assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2evil', '8.1.2', 'FFprobe'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2-evil', '8.1.2', 'FFprobe'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffprobe version v8.1.2', '8.1.2', 'FFprobe'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('ffprobe version 08.1.2', '8.1.2', 'FFprobe'), /does not match pinned 8\.1\.2/);
|
||||
assert.throws(() => assertPinnedVersion('ffprobe version 8.1.2.0', '8.1.2', 'FFprobe'), /does not match pinned 8\.1\.2/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0-evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0+evil', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0-essentials_build-www.gyan.dev', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0+', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink v8.4.0', '8.4.0', 'Streamlink'), /does not expose a parseable version/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink 08.4.0', '8.4.0', 'Streamlink'), /does not match pinned 8\.4\.0/);
|
||||
assert.throws(() => assertPinnedVersion('Streamlink 8.4.0.1', '8.4.0', 'Streamlink'), /does not match pinned 8\.4\.0/);
|
||||
assert.throws(() => assertPinnedVersion('custom wrapper contains 8.1.2', '8.1.2', 'FFmpeg'), /does not expose a parseable version/);
|
||||
});
|
||||
|
||||
test('prepare source has its own bounded operation timeout', async () => {
|
||||
const win = { evaluate: () => new Promise(() => {}) };
|
||||
await assert.rejects(() => prepareSource(win, 'C:\\media\\source.mp4', {
|
||||
timeoutMs: 15,
|
||||
createCapability: async () => ({ token: 'a'.repeat(32), name: 'source.mp4' })
|
||||
}), /prepareSource timed out after 15ms/);
|
||||
});
|
||||
|
||||
test('export source has its own bounded operation timeout', async () => {
|
||||
const win = { evaluate: () => new Promise(() => {}) };
|
||||
await assert.rejects(() => exportSource(win, {
|
||||
outputName: 'result.mp4',
|
||||
profile: 'balanced',
|
||||
audioStreamIndex: 0
|
||||
}, {
|
||||
capability: { token: 'b'.repeat(32), name: 'source.mp4' }
|
||||
}, 15), /exportSource timed out after 15ms/);
|
||||
});
|
||||
|
||||
test('Electron app close has its own bounded operation timeout', async () => {
|
||||
const app = { close: () => new Promise(() => {}) };
|
||||
await assert.rejects(() => closeElectronApp(app, 15), /app.close timed out after 15ms/);
|
||||
});
|
||||
|
||||
test('runner exposes a bounded success shutdown that cannot hang and is not retried', async () => {
|
||||
const environment = createEnvironment();
|
||||
const child = new EventEmitter();
|
||||
child.exitCode = null;
|
||||
const events = [];
|
||||
let closeCalls = 0;
|
||||
let receivedClose = false;
|
||||
child.kill = () => {
|
||||
events.push('kill');
|
||||
setTimeout(() => {
|
||||
child.exitCode = 1;
|
||||
events.push('exit');
|
||||
child.emit('exit', 1, null);
|
||||
}, 5);
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(() => runCutterMatrixLifecycle({
|
||||
createEnvironment: () => environment,
|
||||
cleanupEnvironment: (value) => {
|
||||
events.push('cleanup');
|
||||
fs.rmSync(value.rootDir, { recursive: true, force: true });
|
||||
},
|
||||
closeApp: async (app) => {
|
||||
closeCalls += 1;
|
||||
await closeElectronApp(app, 15, 30);
|
||||
},
|
||||
execute: async ({ setApp, closeApp }) => {
|
||||
setApp({
|
||||
close: () => {
|
||||
events.push('close');
|
||||
return new Promise(() => {});
|
||||
},
|
||||
process: () => child
|
||||
});
|
||||
receivedClose = typeof closeApp === 'function';
|
||||
await closeApp();
|
||||
}
|
||||
}), /app\.close timed out after 15ms/);
|
||||
assert.equal(receivedClose, true);
|
||||
assert.equal(closeCalls, 1);
|
||||
assert.deepEqual(events, ['close', 'kill', 'exit', 'cleanup']);
|
||||
assert.equal(fs.existsSync(environment.rootDir), false);
|
||||
});
|
||||
|
||||
test('process exit fallback after app close timeout has its own bound', async () => {
|
||||
const child = new EventEmitter();
|
||||
child.exitCode = null;
|
||||
child.kill = () => true;
|
||||
const app = {
|
||||
close: () => new Promise(() => {}),
|
||||
process: () => child
|
||||
};
|
||||
await assert.rejects(() => closeElectronApp(app, 10, 15), /Electron process exit timed out after 15ms/);
|
||||
assert.equal(child.listenerCount('exit'), 0);
|
||||
});
|
||||
|
||||
test('runner releases a successfully closed app before lifecycle cleanup', async () => {
|
||||
const environment = createEnvironment();
|
||||
let closeCalls = 0;
|
||||
let closedBeforeExecuteReturned = false;
|
||||
await runCutterMatrixLifecycle({
|
||||
createEnvironment: () => environment,
|
||||
cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }),
|
||||
closeApp: async () => { closeCalls += 1; },
|
||||
execute: async ({ setApp, closeApp }) => {
|
||||
setApp({});
|
||||
await closeApp();
|
||||
closedBeforeExecuteReturned = true;
|
||||
}
|
||||
});
|
||||
assert.equal(closedBeforeExecuteReturned, true);
|
||||
assert.equal(closeCalls, 1);
|
||||
assert.equal(fs.existsSync(environment.rootDir), false);
|
||||
});
|
||||
|
||||
test('runner lifecycle restores the process environment and removes its tree after failure', async () => {
|
||||
const environment = createEnvironment();
|
||||
const variables = { PATH: 'C:\\original-tools' };
|
||||
const failure = new Error('matrix failed');
|
||||
await assert.rejects(() => runCutterMatrixLifecycle({
|
||||
createEnvironment: () => environment,
|
||||
cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }),
|
||||
closeApp: async () => {},
|
||||
execute: async ({ setApp, setRestoreEnvironment }) => {
|
||||
setApp({});
|
||||
setRestoreEnvironment(activateOfflineRunnerEnvironment(environment, variables));
|
||||
assert.equal(variables.PATH, path.join(environment.rootDir, 'offline-path'));
|
||||
throw failure;
|
||||
}
|
||||
}), (error) => error === failure);
|
||||
assert.deepEqual(variables, { PATH: 'C:\\original-tools' });
|
||||
assert.equal(fs.existsSync(environment.rootDir), false);
|
||||
});
|
||||
|
||||
test('runner lifecycle still restores and cleans up when bounded app close fails', async () => {
|
||||
const environment = createEnvironment();
|
||||
const variables = { PATH: 'C:\\original-tools' };
|
||||
await assert.rejects(() => runCutterMatrixLifecycle({
|
||||
createEnvironment: () => environment,
|
||||
cleanupEnvironment: (value) => fs.rmSync(value.rootDir, { recursive: true, force: true }),
|
||||
closeApp: async () => { throw new Error('app.close timed out after 15ms'); },
|
||||
execute: async ({ setApp, setRestoreEnvironment }) => {
|
||||
setApp({});
|
||||
setRestoreEnvironment(activateOfflineRunnerEnvironment(environment, variables));
|
||||
}
|
||||
}), /app\.close timed out after 15ms/);
|
||||
assert.deepEqual(variables, { PATH: 'C:\\original-tools' });
|
||||
assert.equal(fs.existsSync(environment.rootDir), false);
|
||||
});
|
||||
|
||||
test('locked target requires a resolved production publish failure with a Windows lock diagnostic', () => {
|
||||
const before = '[2026-08-13T00:00:00.000Z] startup';
|
||||
const outputFile = 'C:\\media\\result.mp4';
|
||||
const after = `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> '${outputFile}'`;
|
||||
assert.doesNotThrow(() => assertLockedTargetFailure({
|
||||
result: { success: false, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: after,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}));
|
||||
assert.doesNotThrow(() => assertLockedTargetFailure({
|
||||
result: { success: false, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EBUSY: resource busy or locked, rename '${outputFile}' -> '${outputFile}.42.123.tvm-backup'`,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}));
|
||||
assert.throws(() => assertLockedTargetFailure({
|
||||
result: { success: false, rejected: 'Error: IPC connection closed' },
|
||||
debugBefore: before,
|
||||
debugAfter: after,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}), /must resolve through the product IPC/);
|
||||
assert.throws(() => assertLockedTargetFailure({
|
||||
result: { success: false, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] unrelated-failure`,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}), /atomic publish lock diagnostic/);
|
||||
assert.throws(() => assertLockedTargetFailure({
|
||||
result: { success: false, cancelled: true, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: after,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}), /must not be reported as cancelled/);
|
||||
assert.throws(() => assertLockedTargetFailure({
|
||||
result: { success: false, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: after,
|
||||
outputFile,
|
||||
runtimeIssues: ['pageerror: renderer crashed']
|
||||
}), /runtime issues/);
|
||||
assert.throws(() => assertLockedTargetFailure({
|
||||
result: { success: false, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed\n[2026-08-13T00:00:02.000Z] unrelated | Error: EPERM: operation not permitted, rename 'C:\\media\\.other.tvm-edit.mp4' -> 'C:\\media\\other.mp4'`,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}), /atomic publish lock diagnostic/);
|
||||
assert.throws(() => assertLockedTargetFailure({
|
||||
result: { success: false, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> '${outputFile}.backup'`,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}), /atomic publish lock diagnostic/);
|
||||
assert.throws(() => assertLockedTargetFailure({
|
||||
result: { success: false, outputName: null },
|
||||
debugBefore: before,
|
||||
debugAfter: `${before}\n[2026-08-13T00:00:01.000Z] video-editor-export-failed | Error: EPERM: operation not permitted, rename 'C:\\media\\.result.tvm-edit.mp4' -> 'C:\\media\\prefix-result.mp4'`,
|
||||
outputFile,
|
||||
runtimeIssues: []
|
||||
}), /atomic publish lock diagnostic/);
|
||||
});
|
||||
|
||||
test('managed execution snapshots prove exact owned paths and operation-specific counter deltas', () => {
|
||||
const rootDir = path.join('C:\\runner', 'matrix');
|
||||
const streamlinkDirectory = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager', 'tools', 'streamlink');
|
||||
const ffmpegDirectory = path.join(rootDir, 'programdata', 'Twitch_VOD_Manager', 'tools', 'ffmpeg');
|
||||
const paths = {
|
||||
streamlink: path.join(streamlinkDirectory, 'bin', 'streamlink.exe'),
|
||||
ffmpeg: path.join(ffmpegDirectory, 'bin', 'ffmpeg.exe'),
|
||||
ffprobe: path.join(ffmpegDirectory, 'bin', 'ffprobe.exe')
|
||||
};
|
||||
const baseline = {
|
||||
ffmpeg: { path: null, count: 0 },
|
||||
ffprobe: { path: null, count: 0 },
|
||||
streamlink: { path: null, count: 0 }
|
||||
};
|
||||
const afterPrepare = {
|
||||
ffmpeg: { path: null, count: 0 },
|
||||
ffprobe: { path: paths.ffprobe, count: 1 },
|
||||
streamlink: { path: null, count: 0 }
|
||||
};
|
||||
const afterExport = {
|
||||
ffmpeg: { path: paths.ffmpeg, count: 1 },
|
||||
ffprobe: { path: paths.ffprobe, count: 2 },
|
||||
streamlink: { path: null, count: 0 }
|
||||
};
|
||||
assert.doesNotThrow(() => assertManagedExecutionDiagnostics({
|
||||
diagnostics: afterPrepare,
|
||||
expectedPaths: paths,
|
||||
streamlinkDirectory,
|
||||
ffmpegDirectory,
|
||||
electronPath: path.join(rootDir, 'offline-path'),
|
||||
expectedElectronPath: path.join(rootDir, 'offline-path'),
|
||||
previousDiagnostics: baseline,
|
||||
requiredTools: ['ffprobe'],
|
||||
label: 'after prepare'
|
||||
}));
|
||||
assert.doesNotThrow(() => assertManagedExecutionDiagnostics({
|
||||
diagnostics: afterExport,
|
||||
expectedPaths: paths,
|
||||
streamlinkDirectory,
|
||||
ffmpegDirectory,
|
||||
electronPath: path.join(rootDir, 'offline-path'),
|
||||
expectedElectronPath: path.join(rootDir, 'offline-path'),
|
||||
previousDiagnostics: afterPrepare,
|
||||
requiredTools: ['ffmpeg', 'ffprobe'],
|
||||
label: 'after export'
|
||||
}));
|
||||
assert.throws(() => assertManagedExecutionDiagnostics({
|
||||
diagnostics: {
|
||||
ffmpeg: { path: 'C:\\system\\ffmpeg.exe', count: 1 },
|
||||
ffprobe: { path: paths.ffprobe, count: 2 },
|
||||
streamlink: { path: null, count: 0 }
|
||||
},
|
||||
expectedPaths: paths,
|
||||
streamlinkDirectory,
|
||||
ffmpegDirectory,
|
||||
electronPath: path.join(rootDir, 'offline-path'),
|
||||
expectedElectronPath: path.join(rootDir, 'offline-path'),
|
||||
label: 'after export'
|
||||
}), /did not execute the provisioned FFmpeg path/);
|
||||
assert.throws(() => assertManagedExecutionDiagnostics({
|
||||
diagnostics: afterPrepare,
|
||||
expectedPaths: paths,
|
||||
streamlinkDirectory,
|
||||
ffmpegDirectory,
|
||||
electronPath: 'C:\\Windows\\System32',
|
||||
expectedElectronPath: path.join(rootDir, 'offline-path'),
|
||||
label: 'after prepare'
|
||||
}), /Electron PATH escaped isolation/);
|
||||
assert.throws(() => assertManagedExecutionDiagnostics({
|
||||
diagnostics: afterPrepare,
|
||||
expectedPaths: paths,
|
||||
streamlinkDirectory,
|
||||
ffmpegDirectory,
|
||||
electronPath: path.join(rootDir, 'offline-path'),
|
||||
expectedElectronPath: path.join(rootDir, 'offline-path'),
|
||||
previousDiagnostics: afterPrepare,
|
||||
requiredTools: ['ffprobe'],
|
||||
label: 'after prepare'
|
||||
}), /did not record a new FFprobe execution/);
|
||||
assert.throws(() => assertManagedExecutionDiagnostics({
|
||||
diagnostics: baseline,
|
||||
expectedPaths: paths,
|
||||
streamlinkDirectory,
|
||||
ffmpegDirectory,
|
||||
electronPath: path.join(rootDir, 'offline-path'),
|
||||
expectedElectronPath: path.join(rootDir, 'offline-path'),
|
||||
previousDiagnostics: afterExport,
|
||||
requiredTools: [],
|
||||
label: 'regressed snapshot'
|
||||
}), /FFmpeg execution count regressed/);
|
||||
});
|
||||
|
||||
function createPcmTone(frequency, durationSeconds = 0.1, sampleRate = 48000) {
|
||||
const samples = Math.round(durationSeconds * sampleRate);
|
||||
const buffer = Buffer.alloc(samples * 2);
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
buffer.writeInt16LE(Math.round(Math.sin(2 * Math.PI * frequency * index / sampleRate) * 24000), index * 2);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
test('decoded PCM frequency estimation distinguishes the trim boundary audio markers', () => {
|
||||
assert.ok(Math.abs(estimatePcmFrequency(createPcmTone(440), 48000) - 440) <= 5);
|
||||
assert.ok(Math.abs(estimatePcmFrequency(createPcmTone(1760), 48000) - 1760) <= 10);
|
||||
});
|
||||
|
||||
test('video marker sampling decodes before seeking for timestamp-offset containers', () => {
|
||||
let args = null;
|
||||
const rgb = sampleVideoRgb({
|
||||
ffmpegBuffer(nextArgs) {
|
||||
args = nextArgs;
|
||||
return Buffer.from([12, 34, 56]);
|
||||
}
|
||||
}, 'fixture.ts', 0.3);
|
||||
assert.deepEqual(rgb, [12, 34, 56]);
|
||||
assert.ok(args.indexOf('-i') < args.indexOf('-ss'));
|
||||
});
|
||||
|
||||
test('trim boundary validation rejects a same-duration export from the wrong source interval', () => {
|
||||
const correctMarkers = {
|
||||
startVideoRgb: [18, 150, 22],
|
||||
endVideoRgb: [20, 170, 175],
|
||||
startAudioFrequency: 440,
|
||||
endAudioFrequency: 1760
|
||||
};
|
||||
assert.doesNotThrow(() => assertTrimBoundaryMarkers(correctMarkers, 'fixture'));
|
||||
assert.throws(() => assertTrimBoundaryMarkers({
|
||||
...correctMarkers,
|
||||
startVideoRgb: [180, 20, 18]
|
||||
}, 'wrong interval'), /start video marker/);
|
||||
assert.throws(() => assertTrimBoundaryMarkers({
|
||||
...correctMarkers,
|
||||
endVideoRgb: [170, 20, 160]
|
||||
}, 'wrong interval'), /end video marker/);
|
||||
assert.throws(() => assertTrimBoundaryMarkers({
|
||||
...correctMarkers,
|
||||
startAudioFrequency: 220
|
||||
}, 'wrong interval'), /start audio marker/);
|
||||
assert.throws(() => assertTrimBoundaryMarkers({
|
||||
...correctMarkers,
|
||||
endAudioFrequency: 880
|
||||
}, 'wrong interval'), /end audio marker/);
|
||||
});
|
||||
+208
-18
@@ -75,6 +75,44 @@ async function loadCutterCapability(win, filePath) {
|
||||
return capability;
|
||||
}
|
||||
|
||||
async function verifyMultiAudioExport(win, environment, inputFile, outputFile) {
|
||||
await loadCutterCapability(win, inputFile);
|
||||
await win.waitForFunction(() => {
|
||||
const video = document.getElementById('cutterVideo');
|
||||
const select = document.getElementById('cutterAudioStream');
|
||||
return video.readyState >= HTMLMediaElement.HAVE_METADATA && select.options.length === 2 && !select.disabled;
|
||||
}, null, { timeout: 90000 });
|
||||
const selection = await win.evaluate(() => {
|
||||
const select = document.getElementById('cutterAudioStream');
|
||||
const selectedValue = select.options[1].value;
|
||||
window.setCutterAudioStream(selectedValue);
|
||||
select.value = selectedValue;
|
||||
return {
|
||||
choices: [...select.options].map((option) => ({ value: option.value, text: option.textContent })),
|
||||
selectedIndex: cutterAudioStreamIndex,
|
||||
duration: cutterEditorState.duration
|
||||
};
|
||||
});
|
||||
const exportResult = await win.evaluate(({ outputName, duration }) => window.api.exportVideoEdit({
|
||||
inputCapability: cutterFile.token,
|
||||
outputName,
|
||||
trimStart: 0,
|
||||
trimEnd: duration,
|
||||
cuts: [],
|
||||
audioStreamIndex: cutterAudioStreamIndex
|
||||
}), { outputName: path.basename(outputFile), duration: selection.duration });
|
||||
let probe = null;
|
||||
if (fs.existsSync(outputFile)) {
|
||||
const media = JSON.parse(runBinary(resolveBinary(environment, 'ffprobe'), ['-v', 'quiet', '-print_format', 'json', '-show_streams', outputFile]));
|
||||
const audioStreams = media.streams.filter((stream) => stream.codec_type === 'audio');
|
||||
probe = {
|
||||
audioStreams: audioStreams.length,
|
||||
channels: audioStreams[0]?.channels || 0
|
||||
};
|
||||
}
|
||||
return { selection, exportResult, probe };
|
||||
}
|
||||
|
||||
async function dropCutterFile(win, filePath) {
|
||||
const inputId = `cutter-drop-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
await win.evaluate((id) => {
|
||||
@@ -110,6 +148,22 @@ function createTestVideo(environment) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createMultiAudioTestVideo(environment) {
|
||||
const filePath = path.join(environment.mediaDir, 'Cutter Multi Audio.mp4');
|
||||
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=25',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=48000',
|
||||
'-map', '0:v:0', '-map', '1:a:0', '-map', '2:a:0', '-t', '4',
|
||||
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac',
|
||||
'-ac:a:0', '1', '-ac:a:1', '2',
|
||||
'-metadata:s:a:0', 'language=eng', '-metadata:s:a:1', 'language=deu',
|
||||
'-shortest', '-y', filePath
|
||||
]);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createScrubStressVideo(environment) {
|
||||
const filePath = path.join(environment.mediaDir, 'Scrub Stress 60fps.mp4');
|
||||
runBinary(resolveBinary(environment, 'ffmpeg'), [
|
||||
@@ -187,19 +241,23 @@ function createLongVideo(environment) {
|
||||
async function run() {
|
||||
const environment = createE2eEnvironment('cutter', { language: 'en', theme: 'twitch' });
|
||||
const remaindersOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_REMAINDERS_ONLY === '1';
|
||||
const inputFile = createTestVideo(environment);
|
||||
const scrubStressInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA)
|
||||
const audioOnly = process.env.TWITCH_VOD_MANAGER_CUTTER_AUDIO_ONLY === '1';
|
||||
const reducedFixtureMode = remaindersOnly || audioOnly;
|
||||
const inputFile = audioOnly ? null : createTestVideo(environment);
|
||||
const multiAudioInputFile = remaindersOnly ? null : createMultiAudioTestVideo(environment);
|
||||
const scrubStressInputFile = reducedFixtureMode ? null : process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA)
|
||||
? process.env.TWITCH_VOD_MANAGER_SCRUB_MEDIA
|
||||
: createScrubStressVideo(environment);
|
||||
const mediumInputFile = remaindersOnly ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA)
|
||||
const mediumInputFile = reducedFixtureMode ? null : process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA && fs.existsSync(process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA)
|
||||
? process.env.TWITCH_VOD_MANAGER_MEDIUM_MEDIA
|
||||
: createMediumVideo(environment);
|
||||
const additionalContainerFiles = remaindersOnly ? {} : createAdditionalContainerVideos(environment, inputFile);
|
||||
const unsupportedInputFile = remaindersOnly ? null : createUnsupportedVideo(environment, inputFile);
|
||||
const unsupportedImageFile = createUnsupportedImage(environment);
|
||||
const silentInputFile = remaindersOnly ? null : createSilentPortraitVideo(environment);
|
||||
const longInputFile = remaindersOnly ? null : createLongVideo(environment);
|
||||
const additionalContainerFiles = reducedFixtureMode ? {} : createAdditionalContainerVideos(environment, inputFile);
|
||||
const unsupportedInputFile = reducedFixtureMode ? null : createUnsupportedVideo(environment, inputFile);
|
||||
const unsupportedImageFile = audioOnly ? null : createUnsupportedImage(environment);
|
||||
const silentInputFile = reducedFixtureMode ? null : createSilentPortraitVideo(environment);
|
||||
const longInputFile = reducedFixtureMode ? null : createLongVideo(environment);
|
||||
const outputFile = path.join(environment.mediaDir, 'Cutter Test #ä 01 edited.mp4');
|
||||
const multiAudioOutputFile = path.join(environment.mediaDir, 'Cutter Multi Audio selected.mp4');
|
||||
const silentOutputFile = path.join(environment.mediaDir, 'Silent Portrait edited.mp4');
|
||||
const failures = [];
|
||||
const runtimeIssues = [];
|
||||
@@ -231,6 +289,21 @@ async function run() {
|
||||
await win.setViewportSize({ width: 1440, height: 900 });
|
||||
await win.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await win.evaluate(() => window.showTab('cutter'));
|
||||
if (audioOnly) {
|
||||
const multiAudio = await verifyMultiAudioExport(win, environment, multiAudioInputFile, multiAudioOutputFile);
|
||||
check(
|
||||
multiAudio.selection.choices.length === 2
|
||||
&& multiAudio.selection.selectedIndex === 1
|
||||
&& multiAudio.exportResult.success
|
||||
&& multiAudio.probe?.audioStreams === 1
|
||||
&& multiAudio.probe.channels === 2,
|
||||
`The selected second audio track was not preserved in the real export: ${JSON.stringify(multiAudio)}`
|
||||
);
|
||||
check(runtimeIssues.length === 0, `Cutter runtime errors occurred: ${runtimeIssues.join(' | ')}`);
|
||||
console.log(JSON.stringify({ failures, runtimeIssues, multiAudio }, null, 2));
|
||||
if (failures.length > 0) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const cutterSourceVisibility = [];
|
||||
for (const viewport of [{ width: 1060, height: 700 }, { width: 1180, height: 900 }, { width: 1184, height: 661 }, { width: 1440, height: 679 }, { width: 1440, height: 900 }, { width: 2048, height: 1152 }]) {
|
||||
await win.setViewportSize(viewport);
|
||||
@@ -511,7 +584,8 @@ async function run() {
|
||||
&& video.readyState >= HTMLMediaElement.HAVE_METADATA
|
||||
&& document.querySelectorAll('#cutterThumbnailStrip img').length > 0
|
||||
&& window.__cutterAssetAudit.waveformLoads.length > 0
|
||||
&& document.getElementById('cutterAudioStream').selectedOptions[0]?.textContent !== 'Keine Audiospur';
|
||||
&& !document.getElementById('cutterAudioStream').disabled
|
||||
&& document.getElementById('cutterAudioStream').options.length > 0;
|
||||
}, null, { timeout: 90000 });
|
||||
await win.waitForTimeout(480);
|
||||
const firstAssetQuality = await win.evaluate(async () => {
|
||||
@@ -629,6 +703,108 @@ async function run() {
|
||||
&& loadedMinimumSource.previewWidth > loadedMinimumSource.sidebarWidth,
|
||||
`The source selector remains visible after a real load at the native minimum viewport: ${JSON.stringify(loadedMinimumSource)}`
|
||||
);
|
||||
const cutterProjectActions = await win.evaluate(() => {
|
||||
const toolbar = document.querySelector('[data-toolbar-for="cutter"]');
|
||||
const toolbarRect = toolbar.getBoundingClientRect();
|
||||
const state = (id) => {
|
||||
const button = document.getElementById(id);
|
||||
const rect = button.getBoundingClientRect();
|
||||
return { disabled: button.disabled, visible: getComputedStyle(button).display !== 'none' && rect.width > 0 && rect.height > 0 };
|
||||
};
|
||||
return {
|
||||
newVideo: state('cutterNewVideoBtn'),
|
||||
open: state('cutterOpenProjectBtn'),
|
||||
save: state('cutterSaveProjectBtn'),
|
||||
toolbarOverflow: toolbar.scrollWidth - toolbar.clientWidth,
|
||||
contained: toolbarRect.right <= window.innerWidth + 1
|
||||
};
|
||||
});
|
||||
check(
|
||||
cutterProjectActions.newVideo.visible
|
||||
&& cutterProjectActions.open.visible
|
||||
&& !cutterProjectActions.open.disabled
|
||||
&& cutterProjectActions.save.visible
|
||||
&& !cutterProjectActions.save.disabled
|
||||
&& cutterProjectActions.toolbarOverflow <= 1
|
||||
&& cutterProjectActions.contained,
|
||||
`Loaded cutter project actions are hidden, disabled, or overflowing: ${JSON.stringify(cutterProjectActions)}`
|
||||
);
|
||||
await win.evaluate(() => window.setLanguage('en'));
|
||||
await win.locator('#cutterSaveProjectBtn').click();
|
||||
await win.waitForFunction(() => document.getElementById('appToast')?.textContent === UI_TEXT.cutter.projectSaved);
|
||||
const savedProjectFeedback = await win.locator('#appToast').textContent();
|
||||
await win.locator('#cutterOpenProjectBtn').click();
|
||||
await win.waitForFunction(() => document.getElementById('appToast')?.textContent === UI_TEXT.cutter.projectOpened);
|
||||
const openedProjectFeedback = await win.locator('#appToast').textContent();
|
||||
const cutterProjectFeedback = { savedProjectFeedback, openedProjectFeedback };
|
||||
check(
|
||||
cutterProjectFeedback.savedProjectFeedback === 'Project saved'
|
||||
&& cutterProjectFeedback.openedProjectFeedback === 'Project opened',
|
||||
`English cutter project feedback is not localized: ${JSON.stringify(cutterProjectFeedback)}`
|
||||
);
|
||||
await app.evaluate(({ dialog }) => {
|
||||
const originalShowOpenDialog = dialog.showOpenDialog;
|
||||
globalThis.__cutterContextPickerCalls = 0;
|
||||
dialog.showOpenDialog = async () => {
|
||||
globalThis.__cutterContextPickerCalls += 1;
|
||||
dialog.showOpenDialog = originalShowOpenDialog;
|
||||
return { canceled: true, filePaths: [] };
|
||||
};
|
||||
});
|
||||
await win.locator('[data-context-for="cutter"] .context-link').first().click();
|
||||
await win.waitForTimeout(50);
|
||||
const cutterContextPickerCalls = await app.evaluate(() => globalThis.__cutterContextPickerCalls || 0);
|
||||
check(cutterContextPickerCalls === 1, `The loaded cutter context action did not open the video picker: ${cutterContextPickerCalls}`);
|
||||
const englishCutterStrings = await win.evaluate(() => ({
|
||||
newVideo: document.getElementById('cutterNewVideoText').textContent,
|
||||
openProject: document.getElementById('cutterOpenProjectBtn').getAttribute('aria-label'),
|
||||
saveProject: document.getElementById('cutterSaveProjectBtn').getAttribute('aria-label'),
|
||||
recovery: document.getElementById('cutterRecoveryText').textContent,
|
||||
recover: document.getElementById('cutterRecoveryRestoreBtn').textContent,
|
||||
discard: document.getElementById('cutterRecoveryDiscardBtn').textContent,
|
||||
exportProfile: document.getElementById('cutterExportProfileLabel').textContent,
|
||||
exportEncoder: document.getElementById('cutterExportEncoderLabel').textContent,
|
||||
audioStream: document.getElementById('cutterAudioStreamLabel').textContent,
|
||||
profiles: [...document.getElementById('cutterExportProfile').options].map((option) => option.textContent),
|
||||
encoders: [...document.getElementById('cutterExportEncoder').options].map((option) => option.textContent),
|
||||
audioChoices: [...document.getElementById('cutterAudioStream').options].map((option) => option.textContent)
|
||||
}));
|
||||
check(
|
||||
englishCutterStrings.newVideo === 'New video'
|
||||
&& englishCutterStrings.openProject === 'Open project'
|
||||
&& englishCutterStrings.saveProject === 'Save project'
|
||||
&& englishCutterStrings.recovery === 'Saved edit found'
|
||||
&& englishCutterStrings.recover === 'Restore'
|
||||
&& englishCutterStrings.discard === 'Discard'
|
||||
&& englishCutterStrings.exportProfile === 'Export profile'
|
||||
&& englishCutterStrings.exportEncoder === 'Encoder'
|
||||
&& englishCutterStrings.audioStream === 'Audio track'
|
||||
&& JSON.stringify(englishCutterStrings.profiles) === JSON.stringify(['Quality', 'Balanced', 'Fast', 'Archive'])
|
||||
&& englishCutterStrings.encoders[0] === 'Software'
|
||||
&& englishCutterStrings.audioChoices.every((choice) => choice.startsWith('Audio track ')),
|
||||
`English cutter strings still contain fallback or backend copy: ${JSON.stringify(englishCutterStrings)}`
|
||||
);
|
||||
await win.evaluate(() => window.setLanguage('de'));
|
||||
const germanCutterStrings = await win.evaluate(() => ({
|
||||
newVideo: document.getElementById('cutterNewVideoText').textContent,
|
||||
openProject: document.getElementById('cutterOpenProjectBtn').getAttribute('aria-label'),
|
||||
saveProject: document.getElementById('cutterSaveProjectBtn').getAttribute('aria-label'),
|
||||
recovery: document.getElementById('cutterRecoveryText').textContent,
|
||||
exportProfile: document.getElementById('cutterExportProfileLabel').textContent,
|
||||
profiles: [...document.getElementById('cutterExportProfile').options].map((option) => option.textContent),
|
||||
audioChoices: [...document.getElementById('cutterAudioStream').options].map((option) => option.textContent)
|
||||
}));
|
||||
check(
|
||||
germanCutterStrings.newVideo === 'Neues Video'
|
||||
&& germanCutterStrings.openProject === 'Projekt öffnen'
|
||||
&& germanCutterStrings.saveProject === 'Projekt speichern'
|
||||
&& germanCutterStrings.recovery === 'Gespeicherte Bearbeitung gefunden'
|
||||
&& germanCutterStrings.exportProfile === 'Exportprofil'
|
||||
&& JSON.stringify(germanCutterStrings.profiles) === JSON.stringify(['Qualität', 'Ausgewogen', 'Schnell', 'Archiv'])
|
||||
&& germanCutterStrings.audioChoices.every((choice) => choice.startsWith('Audiospur ')),
|
||||
`German cutter strings regressed while localizing English: ${JSON.stringify(germanCutterStrings)}`
|
||||
);
|
||||
await win.evaluate(() => window.setLanguage('en'));
|
||||
const loadedCutterExportSelectPresentation = [];
|
||||
for (const viewport of [{ width: 1184, height: 661 }, { width: 1280, height: 800 }]) {
|
||||
await win.setViewportSize(viewport);
|
||||
@@ -840,7 +1016,7 @@ async function run() {
|
||||
);
|
||||
if (remaindersOnly) {
|
||||
check(runtimeIssues.length === 0, runtimeIssues.join('\n'));
|
||||
console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2));
|
||||
console.log(JSON.stringify({ failures, runtimeIssues, cutterSourceVisibility, cutterExportSelectPresentation, loadedCompactLayout, loadedMinimumSource, cutterProjectActions, cutterProjectFeedback, cutterContextPickerCalls, englishCutterStrings, germanCutterStrings, loadedCutterExportSelectPresentation, revealAnimation, recoveryGeometry, pngDropState, pngDialogState }, null, 2));
|
||||
if (failures.length > 0) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -1187,7 +1363,8 @@ async function run() {
|
||||
window.__cutterScrubSyncRecording = true;
|
||||
const parseTimecode = (value, fps) => {
|
||||
const fields = value.split(':').map(Number);
|
||||
return fields[0] * 60 + fields[1] + fields[2] / fps;
|
||||
const [hours, minutes, seconds, frames] = fields.length === 4 ? fields : [0, ...fields];
|
||||
return hours * 3600 + minutes * 60 + seconds + frames / fps;
|
||||
};
|
||||
const recordFrame = (_now, metadata) => {
|
||||
if (!window.__cutterScrubSyncRecording) return;
|
||||
@@ -1260,7 +1437,8 @@ async function run() {
|
||||
window.__cutterTrimSyncRecording = true;
|
||||
const parseTimecode = (value, fps) => {
|
||||
const fields = value.split(':').map(Number);
|
||||
return fields[0] * 60 + fields[1] + fields[2] / fps;
|
||||
const [hours, minutes, seconds, frames] = fields.length === 4 ? fields : [0, ...fields];
|
||||
return hours * 3600 + minutes * 60 + seconds + frames / fps;
|
||||
};
|
||||
const recordFrame = (_now, metadata) => {
|
||||
if (!window.__cutterTrimSyncRecording) return;
|
||||
@@ -1508,8 +1686,8 @@ async function run() {
|
||||
window.addCutterCut();
|
||||
});
|
||||
const firstInputs = win.locator('.cutter-cut-row').first().locator('input');
|
||||
await firstInputs.nth(0).fill('00:02:00');
|
||||
await firstInputs.nth(1).fill('00:04:00');
|
||||
await firstInputs.nth(0).fill('00:00:02:00');
|
||||
await firstInputs.nth(1).fill('00:00:04:00');
|
||||
await firstInputs.nth(1).press('Enter');
|
||||
await win.evaluate(() => {
|
||||
const video = document.getElementById('cutterVideo');
|
||||
@@ -1517,8 +1695,8 @@ async function run() {
|
||||
window.addCutterCut();
|
||||
});
|
||||
const secondInputs = win.locator('.cutter-cut-row').nth(1).locator('input');
|
||||
await secondInputs.nth(0).fill('00:06:00');
|
||||
await secondInputs.nth(1).fill('00:07:00');
|
||||
await secondInputs.nth(0).fill('00:00:06:00');
|
||||
await secondInputs.nth(1).fill('00:00:07:00');
|
||||
await secondInputs.nth(1).press('Enter');
|
||||
const edited = await win.evaluate(() => ({
|
||||
cuts: cutterEditorState.cuts.map((cut) => ({ start: cut.start, end: cut.end })),
|
||||
@@ -1732,7 +1910,7 @@ async function run() {
|
||||
const cutInput = win.locator('.cutter-cut-row').first().locator('input').first();
|
||||
const stateBeforeTextUndo = await win.evaluate(() => JSON.stringify(cutterEditorState));
|
||||
await cutInput.focus();
|
||||
await cutInput.fill('00:02:01');
|
||||
await cutInput.fill('00:00:02:01');
|
||||
await cutInput.press('Control+z');
|
||||
const textUndoState = await win.evaluate((before) => ({ stateUnchanged: JSON.stringify(cutterEditorState) === before, activeTag: document.activeElement?.tagName }), stateBeforeTextUndo);
|
||||
check(textUndoState.stateUnchanged && textUndoState.activeTag === 'INPUT', `Text-field undo changed the whole editor: ${JSON.stringify(textUndoState)}`);
|
||||
@@ -2281,6 +2459,18 @@ async function run() {
|
||||
});
|
||||
await win.waitForTimeout(250);
|
||||
await win.screenshot({ path: path.join(cutterArtifactDir, 'editor.png'), fullPage: true });
|
||||
const multiAudio = await verifyMultiAudioExport(win, environment, multiAudioInputFile, multiAudioOutputFile);
|
||||
const multiAudioSelection = multiAudio.selection;
|
||||
const multiAudioExportResult = multiAudio.exportResult;
|
||||
const multiAudioProbe = multiAudio.probe;
|
||||
check(
|
||||
multiAudioSelection.choices.length === 2
|
||||
&& multiAudioSelection.selectedIndex === 1
|
||||
&& multiAudioExportResult.success
|
||||
&& multiAudioProbe?.audioStreams === 1
|
||||
&& multiAudioProbe.channels === 2,
|
||||
`The selected second audio track was not preserved in the real export: ${JSON.stringify({ multiAudioSelection, multiAudioExportResult, multiAudioProbe })}`
|
||||
);
|
||||
await loadCutterCapability(win, silentInputFile);
|
||||
await win.waitForFunction(() => {
|
||||
const video = document.getElementById('cutterVideo');
|
||||
@@ -2327,7 +2517,7 @@ async function run() {
|
||||
const shutdownArtifacts = fs.readdirSync(environment.mediaDir)
|
||||
.filter((name) => name.includes('.tvm-edit.mp4') || name.includes('.tvm-backup') || name === path.basename(shutdownOutputFile));
|
||||
check(cutterTempDirectoriesAfterShutdown.length === 0 && shutdownArtifacts.length === 0, `Shutdown left cutter artifacts: ${JSON.stringify({ cutterTempDirectoriesAfterShutdown, shutdownArtifacts })}`);
|
||||
console.log(JSON.stringify({ failures, runtimeIssues, additionalContainerSupport, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter, revealAnimation, firstAssetQuality, firstAssetsReadyMs, initialAssetStability, verticalProfileQuality, loaded, edgeGeometry, timestampTypography, playerControlGeometry, cutterInfoAlignment, replacementPromptState, replacementPlaybackState, scrubMediaInfo, scrubFirstAssetsReadyMs, scrubFirstAssetQuality, realMaximumZoomState, scrubSyncProbe, trimScrubProbe, mediumWaveformReadyMs, mediumFirstAssetsReadyMs, mediumZoomReuseBefore, mediumZoomReuseAfter, longPlayerReadyMs, longAssetsReadyMs, longAssetTopology, longPreservedAfterAssetInterruptions, longScrubPresentation, rapidSwitch, memoryBeforeStressMb, memoryAfterStressMb, stressMemoryDeltaMb, preservedAfterUnsupported, edited, cutHandleVisualGeometry, cutterAria, germanCutLabels, draggedCutStart, collisionBoundedCut, reversibleTrimStart, reversibleTrimEnd, crossedCutTime, skippedTime, smoothTimecodeFrames, playbackPerformance, playbackAfterDrag, stoppedTime, settingsMenuLayout, escapedSettings, playbackRateState, tabLeaveState, layoutAudit, responsiveLayouts, expandedVolumeLayout, wheelZoom, zoomGeometryDelta, maximumZoomGeometryDelta, assetDensity, zoomWaveformReuse, zoom, sourceProtection, invalidRequest, cancelledExport, exportResult, manyCutsExport, changedSourceExport, silentState, silentExportResult, cutterTempDirectoriesAfterShutdown, shutdownArtifacts }, null, 2));
|
||||
console.log(JSON.stringify({ failures, runtimeIssues, additionalContainerSupport, emptyLayout, emptyFullscreenLayout, emptyVolumeBefore, emptyVolumeAfter, revealAnimation, firstAssetQuality, firstAssetsReadyMs, initialAssetStability, verticalProfileQuality, loaded, edgeGeometry, timestampTypography, playerControlGeometry, cutterInfoAlignment, replacementPromptState, replacementPlaybackState, scrubMediaInfo, scrubFirstAssetsReadyMs, scrubFirstAssetQuality, realMaximumZoomState, scrubSyncProbe, trimScrubProbe, mediumWaveformReadyMs, mediumFirstAssetsReadyMs, mediumZoomReuseBefore, mediumZoomReuseAfter, longPlayerReadyMs, longAssetsReadyMs, longAssetTopology, longPreservedAfterAssetInterruptions, longScrubPresentation, rapidSwitch, memoryBeforeStressMb, memoryAfterStressMb, stressMemoryDeltaMb, preservedAfterUnsupported, edited, cutHandleVisualGeometry, cutterAria, germanCutLabels, draggedCutStart, collisionBoundedCut, reversibleTrimStart, reversibleTrimEnd, crossedCutTime, skippedTime, smoothTimecodeFrames, playbackPerformance, playbackAfterDrag, stoppedTime, settingsMenuLayout, escapedSettings, playbackRateState, tabLeaveState, layoutAudit, responsiveLayouts, expandedVolumeLayout, wheelZoom, zoomGeometryDelta, maximumZoomGeometryDelta, assetDensity, zoomWaveformReuse, zoom, sourceProtection, invalidRequest, cancelledExport, exportResult, manyCutsExport, changedSourceExport, multiAudioSelection, multiAudioExportResult, multiAudioProbe, silentState, silentExportResult, cutterTempDirectoriesAfterShutdown, shutdownArtifacts }, null, 2));
|
||||
if (failures.length > 0) process.exitCode = 1;
|
||||
} finally {
|
||||
if (app) await app.close();
|
||||
|
||||
+258
-37
@@ -1,11 +1,11 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const appGuid = '08429788-303d-53b6-a4f9-894401712c7e';
|
||||
const shortcutName = packageJson.build.nsis.shortcutName;
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
@@ -22,20 +22,197 @@ function run(command, args, options = {}) {
|
||||
}
|
||||
|
||||
function findUninstaller(installationDirectory) {
|
||||
if (!fs.existsSync(installationDirectory)) return '';
|
||||
return fs.readdirSync(installationDirectory)
|
||||
.filter((name) => /^uninstall.*\.exe$/i.test(name))
|
||||
.map((name) => path.join(installationDirectory, name))[0] || '';
|
||||
}
|
||||
|
||||
function assertCleanInstallerSmokeSurface() {
|
||||
const userInstallKey = `HKCU\\Software\\${appGuid}`;
|
||||
const machineInstallKey = `HKLM\\SOFTWARE\\${appGuid}`;
|
||||
const query = (key) => spawnSync('reg', ['query', key], { encoding: 'utf8', windowsHide: true });
|
||||
const existingInstallations = [userInstallKey, machineInstallKey]
|
||||
.filter((key) => query(key).status === 0);
|
||||
if (existingInstallations.length > 0) {
|
||||
throw new Error(`Installer smoke requires a clean Windows registration surface: ${existingInstallations.join(', ')}`);
|
||||
function createInstallerPhases(smokeRoot, folders) {
|
||||
if (!path.win32.isAbsolute(smokeRoot)) throw new Error(`Installer smoke root is not absolute: ${smokeRoot}`);
|
||||
for (const name of ['commonDesktop', 'commonPrograms', 'currentDesktop', 'currentPrograms']) {
|
||||
if (!path.win32.isAbsolute(folders[name] || '')) throw new Error(`Windows shell folder ${name} is invalid: ${folders[name] || ''}`);
|
||||
}
|
||||
return [
|
||||
{
|
||||
desktopShortcut: path.win32.join(folders.currentDesktop, `${shortcutName}.lnk`),
|
||||
flag: '/currentuser',
|
||||
hive: 'HKCU',
|
||||
installationDirectory: path.win32.join(smokeRoot, 'currentuser', 'app'),
|
||||
oppositeHive: 'HKLM',
|
||||
startMenuShortcut: path.win32.join(folders.currentPrograms, `${shortcutName}.lnk`)
|
||||
},
|
||||
{
|
||||
desktopShortcut: path.win32.join(folders.commonDesktop, `${shortcutName}.lnk`),
|
||||
flag: '/allusers',
|
||||
hive: 'HKLM',
|
||||
installationDirectory: path.win32.join(smokeRoot, 'allusers', 'app'),
|
||||
oppositeHive: 'HKCU',
|
||||
startMenuShortcut: path.win32.join(folders.commonPrograms, `${shortcutName}.lnk`)
|
||||
}
|
||||
].map((phase) => ({
|
||||
...phase,
|
||||
executablePath: path.win32.join(phase.installationDirectory, `${packageJson.build.productName}.exe`),
|
||||
iconPath: path.win32.join(phase.installationDirectory, 'resources', 'app-icons', `icon-${packageJson.version}.ico`),
|
||||
installArguments: ['/S', phase.flag, `/D=${phase.installationDirectory}`]
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(candidate) {
|
||||
return path.win32.resolve(String(candidate)).replaceAll('/', '\\').toLowerCase();
|
||||
}
|
||||
|
||||
function assertPathInside(targetPath, parentPath) {
|
||||
const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath));
|
||||
if (!relative || relative.startsWith('..\\') || relative === '..' || path.win32.isAbsolute(relative)) {
|
||||
throw new Error(`Refusing recursive cleanup outside its parent: ${JSON.stringify({ targetPath, parentPath })}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFile(filePath, label) {
|
||||
let isFile = false;
|
||||
try {
|
||||
isFile = fs.statSync(filePath).isFile();
|
||||
} catch {}
|
||||
if (!isFile) throw new Error(`${label} is missing: ${filePath}`);
|
||||
}
|
||||
|
||||
function assertShortcutDetails(details, { expectedIcon, expectedTarget, pathExists = fs.existsSync }) {
|
||||
const targetPath = String(details.targetPath || '');
|
||||
const iconPath = String(details.iconLocation || '').replace(/,\s*-?\d+$/, '');
|
||||
if (normalizeWindowsPath(targetPath) !== normalizeWindowsPath(expectedTarget)) {
|
||||
throw new Error(`Shortcut target mismatch: ${JSON.stringify({ actual: targetPath, expected: expectedTarget })}`);
|
||||
}
|
||||
if (normalizeWindowsPath(iconPath) !== normalizeWindowsPath(expectedIcon)) {
|
||||
throw new Error(`Shortcut icon mismatch: ${JSON.stringify({ actual: iconPath, expected: expectedIcon })}`);
|
||||
}
|
||||
if (!pathExists(targetPath)) throw new Error(`Shortcut target is missing: ${targetPath}`);
|
||||
if (!pathExists(iconPath)) throw new Error(`Shortcut icon is missing: ${iconPath}`);
|
||||
}
|
||||
|
||||
function registryKeys(hive) {
|
||||
return {
|
||||
install: `${hive}\\Software\\${appGuid}`,
|
||||
uninstall: `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${appGuid}`
|
||||
};
|
||||
}
|
||||
|
||||
function assertInstalledRegistration(phase, { expectedUninstallerPath, keyExists, readRegistryValue }) {
|
||||
const selectedKeys = registryKeys(phase.hive);
|
||||
const oppositeKeys = registryKeys(phase.oppositeHive);
|
||||
if (!keyExists(selectedKeys.install) || !keyExists(selectedKeys.uninstall)) {
|
||||
throw new Error(`Installer did not register the ${phase.flag} installation in ${phase.hive}`);
|
||||
}
|
||||
if (keyExists(oppositeKeys.install) || keyExists(oppositeKeys.uninstall)) {
|
||||
throw new Error(`Installer left registration in the opposite installation scope ${phase.oppositeHive}`);
|
||||
}
|
||||
|
||||
const registeredLocation = readRegistryValue(selectedKeys.install, 'InstallLocation');
|
||||
if (normalizeWindowsPath(registeredLocation) !== normalizeWindowsPath(phase.installationDirectory)) {
|
||||
throw new Error(`Registered install location mismatch: ${JSON.stringify({ actual: registeredLocation, expected: phase.installationDirectory })}`);
|
||||
}
|
||||
|
||||
const uninstallString = String(readRegistryValue(selectedKeys.uninstall, 'UninstallString') || '');
|
||||
const quietUninstallString = String(readRegistryValue(selectedKeys.uninstall, 'QuietUninstallString') || '');
|
||||
const expectedUninstallString = `"${expectedUninstallerPath}" ${phase.flag}`;
|
||||
const expectedQuietUninstallString = `${expectedUninstallString} /S`;
|
||||
if (uninstallString.toLowerCase() !== expectedUninstallString.toLowerCase() || quietUninstallString.toLowerCase() !== expectedQuietUninstallString.toLowerCase()) {
|
||||
throw new Error(`Registered uninstall command mismatch: ${JSON.stringify({ actual: { quietUninstallString, uninstallString }, expected: { quietUninstallString: expectedQuietUninstallString, uninstallString: expectedUninstallString } })}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertInstallerSurfaceClean(phases, { keyExists, pathExists = fs.existsSync }) {
|
||||
const registrySurface = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))];
|
||||
const shortcutSurface = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))];
|
||||
const existing = [
|
||||
...registrySurface.filter(keyExists),
|
||||
...shortcutSurface.filter(pathExists)
|
||||
];
|
||||
if (existing.length > 0) {
|
||||
throw new Error(`Installer smoke requires a clean registry and shortcut surface: ${existing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function registryKeyExists(key) {
|
||||
const result = spawnSync('reg.exe', ['query', key], { encoding: 'utf8', timeout: 30000, windowsHide: true });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status === 0) return true;
|
||||
if (result.status === 1) return false;
|
||||
throw new Error(`Registry query failed: ${JSON.stringify({ key, status: result.status, stdout: result.stdout, stderr: result.stderr })}`);
|
||||
}
|
||||
|
||||
function readRegistryValue(key, name) {
|
||||
const result = spawnSync('reg.exe', ['query', key, '/v', name], { encoding: 'utf8', timeout: 30000, windowsHide: true });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status === 1) return null;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Registry value query failed: ${JSON.stringify({ key, name, status: result.status, stdout: result.stdout, stderr: result.stderr })}`);
|
||||
}
|
||||
const valueMatch = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/^\s*(\S+)\s+REG_\w+\s+(.*)$/i))
|
||||
.find((match) => match?.[1]?.toLowerCase() === name.toLowerCase());
|
||||
return valueMatch?.[2]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function runPowerShell(script, environment = {}) {
|
||||
const result = run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
|
||||
env: { ...process.env, ...environment }
|
||||
});
|
||||
return result.stdout.trim().replace(/^\uFEFF/, '');
|
||||
}
|
||||
|
||||
function readShellFolders() {
|
||||
return JSON.parse(runPowerShell("[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); [ordered]@{ currentPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::Programs); commonPrograms = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonPrograms); currentDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::DesktopDirectory); commonDesktop = [Environment]::GetFolderPath([Environment+SpecialFolder]::CommonDesktopDirectory) } | ConvertTo-Json -Compress"));
|
||||
}
|
||||
|
||||
function readShortcutDetails(shortcutPath) {
|
||||
const script = "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_INSTALLER_SMOKE_SHORTCUT); [ordered]@{ targetPath = $shortcut.TargetPath; iconLocation = $shortcut.IconLocation } | ConvertTo-Json -Compress";
|
||||
return JSON.parse(runPowerShell(script, { TVM_INSTALLER_SMOKE_SHORTCUT: shortcutPath }));
|
||||
}
|
||||
|
||||
function assertAdministrator() {
|
||||
runPowerShell("$identity = [Security.Principal.WindowsIdentity]::GetCurrent(); $principal = [Security.Principal.WindowsPrincipal]::new($identity); if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { exit 1 }");
|
||||
}
|
||||
|
||||
function assertCleanInstallerSmokeSurface(phases) {
|
||||
assertInstallerSurfaceClean(phases, { keyExists: registryKeyExists });
|
||||
}
|
||||
|
||||
function seedOrphanedRegistrations(phase, smokeRoot) {
|
||||
for (const hive of ['HKCU', 'HKLM']) {
|
||||
const keys = registryKeys(hive);
|
||||
const orphanedLocation = path.win32.join(smokeRoot, 'orphaned', phase.flag.slice(1), hive, 'app');
|
||||
const orphanedUninstaller = path.win32.join(orphanedLocation, `Uninstall ${packageJson.build.productName}.exe`);
|
||||
run('reg.exe', ['add', keys.install, '/v', 'InstallLocation', '/t', 'REG_SZ', '/d', orphanedLocation, '/f']);
|
||||
run('reg.exe', ['add', keys.uninstall, '/v', 'UninstallString', '/t', 'REG_SZ', '/d', `"${orphanedUninstaller}" ${phase.flag}`, '/f']);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupInstallerSurface(phases) {
|
||||
const keys = [...new Set(phases.flatMap((phase) => Object.values(registryKeys(phase.hive))))];
|
||||
for (const key of keys) {
|
||||
spawnSync('reg.exe', ['delete', key, '/f'], { encoding: 'utf8', timeout: 30000, windowsHide: true });
|
||||
}
|
||||
const shortcuts = [...new Set(phases.flatMap((phase) => [phase.startMenuShortcut, phase.desktopShortcut]))];
|
||||
for (const shortcut of shortcuts) fs.rmSync(shortcut, { force: true });
|
||||
}
|
||||
|
||||
function verifyInstalledPhase(phase) {
|
||||
assertFile(phase.executablePath, 'Installed executable');
|
||||
assertFile(phase.iconPath, 'Installed shortcut icon');
|
||||
assertFile(phase.startMenuShortcut, 'Start Menu shortcut');
|
||||
assertFile(phase.desktopShortcut, 'Desktop shortcut');
|
||||
const uninstallerPath = findUninstaller(phase.installationDirectory);
|
||||
if (!uninstallerPath) throw new Error(`Installed uninstaller is missing: ${phase.installationDirectory}`);
|
||||
assertInstalledRegistration(phase, { expectedUninstallerPath: uninstallerPath, keyExists: registryKeyExists, readRegistryValue });
|
||||
for (const shortcutPath of [phase.startMenuShortcut, phase.desktopShortcut]) {
|
||||
assertShortcutDetails(readShortcutDetails(shortcutPath), {
|
||||
expectedIcon: phase.iconPath,
|
||||
expectedTarget: phase.executablePath
|
||||
});
|
||||
}
|
||||
return uninstallerPath;
|
||||
}
|
||||
|
||||
async function waitForPathRemoval(targetPath, timeoutMs = 10000) {
|
||||
@@ -47,42 +224,86 @@ async function waitForPathRemoval(targetPath, timeoutMs = 10000) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') throw new Error('Installer smoke requires Windows');
|
||||
if (process.env.CI !== 'true' && process.env.TWITCH_VOD_MANAGER_INSTALLER_SMOKE !== '1') {
|
||||
throw new Error('Installer smoke is restricted to CI or explicit TWITCH_VOD_MANAGER_INSTALLER_SMOKE=1 opt-in');
|
||||
function assertHostedWindowsCi(environment = process.env, platform = process.platform) {
|
||||
const serverUrl = String(environment.GITHUB_SERVER_URL || '').replace(/\/+$/, '').toLowerCase();
|
||||
const isGitHubActions = environment.GITHUB_ACTIONS === 'true' && environment.GITEA_ACTIONS !== 'true' && environment.RUNNER_ENVIRONMENT === 'github-hosted' && serverUrl === 'https://github.com';
|
||||
const isGiteaActions = environment.GITEA_ACTIONS === 'true' && serverUrl === 'https://git.24-music.de';
|
||||
if (platform !== 'win32' || environment.CI !== 'true' || environment.RUNNER_OS !== 'Windows' || !environment.RUNNER_TEMP || !environment.GITHUB_RUN_ID || (!isGitHubActions && !isGiteaActions)) {
|
||||
throw new Error('Real installer smoke is restricted to an approved Windows Actions runner');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertHostedWindowsCi();
|
||||
const installerPath = path.join(root, 'release', `Twitch-VOD-Manager-Setup-${packageJson.version}.exe`);
|
||||
if (!fs.statSync(installerPath).isFile()) throw new Error(`Installer is missing: ${installerPath}`);
|
||||
assertCleanInstallerSmokeSurface();
|
||||
assertFile(installerPath, 'Installer');
|
||||
assertAdministrator();
|
||||
|
||||
const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-installer-'));
|
||||
const installationDirectory = path.join(smokeRoot, 'app');
|
||||
const executablePath = path.join(installationDirectory, `${packageJson.build.productName}.exe`);
|
||||
let uninstallerPath = '';
|
||||
const runnerTemp = process.env.RUNNER_TEMP;
|
||||
if (!runnerTemp || !path.win32.isAbsolute(runnerTemp) || !fs.statSync(runnerTemp).isDirectory()) {
|
||||
throw new Error(`Hosted runner temp directory is invalid: ${runnerTemp || ''}`);
|
||||
}
|
||||
const smokeRoot = fs.mkdtempSync(path.join(runnerTemp, 'tvm-installer-'));
|
||||
let phases = [];
|
||||
const results = [];
|
||||
let ownsSurface = false;
|
||||
|
||||
try {
|
||||
run(installerPath, ['/S', '/currentuser', `/D=${installationDirectory}`], { cwd: smokeRoot });
|
||||
if (!fs.statSync(executablePath).isFile()) throw new Error(`Installed executable is missing: ${executablePath}`);
|
||||
uninstallerPath = findUninstaller(installationDirectory);
|
||||
if (!uninstallerPath) throw new Error('Installed uninstaller is missing');
|
||||
run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], {
|
||||
cwd: root,
|
||||
env: { ...process.env, PACKAGED_APP_PATH: executablePath }
|
||||
});
|
||||
run(uninstallerPath, ['/S'], { cwd: smokeRoot });
|
||||
if (!await waitForPathRemoval(executablePath)) throw new Error('Silent uninstall left the packaged executable installed');
|
||||
console.log(JSON.stringify({ failures: [], installerPath }, null, 2));
|
||||
} finally {
|
||||
if (uninstallerPath && fs.existsSync(uninstallerPath)) {
|
||||
spawnSync(uninstallerPath, ['/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' });
|
||||
assertPathInside(smokeRoot, runnerTemp);
|
||||
phases = createInstallerPhases(smokeRoot, readShellFolders());
|
||||
assertCleanInstallerSmokeSurface(phases);
|
||||
ownsSurface = true;
|
||||
for (const phase of phases) {
|
||||
seedOrphanedRegistrations(phase, smokeRoot);
|
||||
run(installerPath, phase.installArguments, { cwd: smokeRoot });
|
||||
const uninstallerPath = verifyInstalledPhase(phase);
|
||||
run(process.execPath, [path.join(__dirname, 'smoke-test-packaged-launch.js')], {
|
||||
cwd: root,
|
||||
env: { ...process.env, PACKAGED_APP_PATH: phase.executablePath }
|
||||
});
|
||||
run(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot });
|
||||
if (!await waitForPathRemoval(phase.installationDirectory, 30000)) {
|
||||
throw new Error(`Silent uninstall left the installation directory behind: ${phase.installationDirectory}`);
|
||||
}
|
||||
assertCleanInstallerSmokeSurface(phases);
|
||||
results.push({
|
||||
flag: phase.flag,
|
||||
hive: phase.hive,
|
||||
iconPath: phase.iconPath,
|
||||
installationDirectory: phase.installationDirectory,
|
||||
startMenuShortcut: phase.startMenuShortcut
|
||||
});
|
||||
}
|
||||
console.log(JSON.stringify({ failures: [], installerPath, results }, null, 2));
|
||||
} finally {
|
||||
if (ownsSurface) {
|
||||
for (const phase of [...phases].reverse()) {
|
||||
const uninstallerPath = findUninstaller(phase.installationDirectory);
|
||||
if (uninstallerPath) {
|
||||
spawnSync(uninstallerPath, [phase.flag, '/S'], { cwd: smokeRoot, timeout: 240000, windowsHide: true, stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
cleanupInstallerSurface(phases);
|
||||
}
|
||||
assertPathInside(smokeRoot, runnerTemp);
|
||||
await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertHostedWindowsCi,
|
||||
assertInstalledRegistration,
|
||||
assertInstallerSurfaceClean,
|
||||
assertPathInside,
|
||||
assertShortcutDetails,
|
||||
createInstallerPhases,
|
||||
readShortcutDetails,
|
||||
registryKeys
|
||||
};
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const test = require('node:test');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const {
|
||||
assertHostedWindowsCi,
|
||||
assertInstalledRegistration,
|
||||
assertInstallerSurfaceClean,
|
||||
assertPathInside,
|
||||
assertShortcutDetails,
|
||||
createInstallerPhases,
|
||||
readShortcutDetails,
|
||||
registryKeys
|
||||
} = require('./smoke-test-installer');
|
||||
|
||||
const builderInstallerSource = fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'include', 'installer.nsh'), 'utf8');
|
||||
const builderMultiUserSource = fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'app-builder-lib', 'templates', 'nsis', 'multiUser.nsh'), 'utf8');
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
||||
|
||||
test('real installer smoke cannot be enabled on a local workstation', () => {
|
||||
assert.throws(
|
||||
() => assertHostedWindowsCi({ TWITCH_VOD_MANAGER_INSTALLER_SMOKE: '1' }, 'win32'),
|
||||
/approved Windows Actions runner/
|
||||
);
|
||||
});
|
||||
|
||||
test('real installer smoke rejects generic local CI identities', () => {
|
||||
assert.throws(() => assertHostedWindowsCi({
|
||||
CI: 'true',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GITHUB_RUN_ID: '123',
|
||||
GITHUB_SERVER_URL: 'https://ci.example.test',
|
||||
RUNNER_OS: 'Windows',
|
||||
RUNNER_TEMP: 'C:\\runner-temp'
|
||||
}, 'win32'), /approved Windows Actions runner/);
|
||||
});
|
||||
|
||||
test('real installer smoke accepts the GitHub Windows Actions identity', () => {
|
||||
assert.doesNotThrow(() => assertHostedWindowsCi({
|
||||
CI: 'true',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
GITHUB_RUN_ID: '123',
|
||||
GITHUB_SERVER_URL: 'https://github.com',
|
||||
RUNNER_OS: 'Windows',
|
||||
RUNNER_ENVIRONMENT: 'github-hosted',
|
||||
RUNNER_TEMP: 'C:\\runner-temp'
|
||||
}, 'win32'));
|
||||
});
|
||||
|
||||
test('real installer smoke accepts the git.24-music.de Gitea Windows Actions identity', () => {
|
||||
assert.doesNotThrow(() => assertHostedWindowsCi({
|
||||
CI: 'true',
|
||||
GITEA_ACTIONS: 'true',
|
||||
GITHUB_RUN_ID: '456',
|
||||
GITHUB_SERVER_URL: 'https://git.24-music.de',
|
||||
RUNNER_OS: 'Windows',
|
||||
RUNNER_TEMP: 'C:\\runner-temp'
|
||||
}, 'win32'));
|
||||
});
|
||||
|
||||
test('installer phases cover current user then all users with scope-correct paths', () => {
|
||||
const phases = createInstallerPhases('C:\\smoke', {
|
||||
commonDesktop: 'C:\\shared-desktop',
|
||||
commonPrograms: 'C:\\shared-programs',
|
||||
currentDesktop: 'C:\\user-desktop',
|
||||
currentPrograms: 'C:\\user-programs'
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(phases.map((phase) => ({
|
||||
flag: phase.flag,
|
||||
hive: phase.hive,
|
||||
installationDirectory: phase.installationDirectory,
|
||||
startMenuShortcut: phase.startMenuShortcut
|
||||
})), [
|
||||
{
|
||||
flag: '/currentuser',
|
||||
hive: 'HKCU',
|
||||
installationDirectory: 'C:\\smoke\\currentuser\\app',
|
||||
startMenuShortcut: 'C:\\user-programs\\Twitch VOD Manager.lnk'
|
||||
},
|
||||
{
|
||||
flag: '/allusers',
|
||||
hive: 'HKLM',
|
||||
installationDirectory: 'C:\\smoke\\allusers\\app',
|
||||
startMenuShortcut: 'C:\\shared-programs\\Twitch VOD Manager.lnk'
|
||||
}
|
||||
]);
|
||||
for (const phase of phases) {
|
||||
assert.deepStrictEqual(phase.installArguments, ['/S', phase.flag, `/D=${phase.installationDirectory}`]);
|
||||
}
|
||||
});
|
||||
|
||||
test('installer phases reject unresolved Windows shell folders', () => {
|
||||
assert.throws(() => createInstallerPhases('C:\\smoke', {
|
||||
commonDesktop: '',
|
||||
commonPrograms: 'C:\\shared-programs',
|
||||
currentDesktop: 'C:\\user-desktop',
|
||||
currentPrograms: 'C:\\user-programs'
|
||||
}), /commonDesktop/);
|
||||
});
|
||||
|
||||
test('shortcut contract verifies the real target and versioned installed icon', () => {
|
||||
const expectedIcon = `C:\\smoke\\currentuser\\app\\resources\\app-icons\\icon-${packageJson.version}.ico`;
|
||||
const existingPaths = new Set([
|
||||
'c:\\smoke\\currentuser\\app\\twitch vod manager.exe',
|
||||
expectedIcon.toLowerCase()
|
||||
]);
|
||||
assert.doesNotThrow(() => assertShortcutDetails({
|
||||
iconLocation: `${expectedIcon},0`,
|
||||
targetPath: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe'
|
||||
}, {
|
||||
expectedIcon,
|
||||
expectedTarget: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe',
|
||||
pathExists: (candidate) => existingPaths.has(candidate.toLowerCase())
|
||||
}));
|
||||
assert.throws(() => assertShortcutDetails({
|
||||
iconLocation: 'C:\\Users\\runner\\AppData\\Local\\Twitch VOD Manager\\Shortcut Icons\\icon-1.0.17.ico,0',
|
||||
targetPath: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe'
|
||||
}, {
|
||||
expectedIcon,
|
||||
expectedTarget: 'C:\\smoke\\currentuser\\app\\Twitch VOD Manager.exe',
|
||||
pathExists: () => true
|
||||
}), /icon/i);
|
||||
});
|
||||
|
||||
test('shortcut inspection reads a real temporary Windows link', { skip: process.platform !== 'win32' }, () => {
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-link-contract-'));
|
||||
const targetPath = path.join(temporaryRoot, 'Twitch VOD Manager.exe');
|
||||
const iconPath = path.join(temporaryRoot, 'icon.ico');
|
||||
const shortcutPath = path.join(temporaryRoot, 'Twitch VOD Manager.lnk');
|
||||
try {
|
||||
fs.writeFileSync(targetPath, 'target');
|
||||
fs.writeFileSync(iconPath, 'icon');
|
||||
const result = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', "$shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($env:TVM_TEST_SHORTCUT); $shortcut.TargetPath = $env:TVM_TEST_TARGET; $shortcut.IconLocation = $env:TVM_TEST_ICON; $shortcut.Save()"], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, TVM_TEST_ICON: iconPath, TVM_TEST_SHORTCUT: shortcutPath, TVM_TEST_TARGET: targetPath },
|
||||
windowsHide: true
|
||||
});
|
||||
assert.strictEqual(result.status, 0, result.stderr);
|
||||
const details = readShortcutDetails(shortcutPath);
|
||||
assert.strictEqual(details.targetPath.toLowerCase(), targetPath.toLowerCase());
|
||||
assert.strictEqual(details.iconLocation.replace(/,\s*0$/, '').toLowerCase(), iconPath.toLowerCase());
|
||||
} finally {
|
||||
fs.rmSync(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('registration contract proves the selected hive, install path and uninstall mode', () => {
|
||||
const [phase] = createInstallerPhases('C:\\smoke', {
|
||||
commonDesktop: 'C:\\shared-desktop',
|
||||
commonPrograms: 'C:\\shared-programs',
|
||||
currentDesktop: 'C:\\user-desktop',
|
||||
currentPrograms: 'C:\\user-programs'
|
||||
});
|
||||
const selected = registryKeys('HKCU');
|
||||
const values = new Map([
|
||||
[`${selected.install}|InstallLocation`, phase.installationDirectory],
|
||||
[`${selected.uninstall}|UninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag}`],
|
||||
[`${selected.uninstall}|QuietUninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag} /S`]
|
||||
]);
|
||||
assert.doesNotThrow(() => assertInstalledRegistration(phase, {
|
||||
expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe',
|
||||
keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)),
|
||||
readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null
|
||||
}));
|
||||
values.set(`${registryKeys('HKLM').install}|InstallLocation`, 'C:\\orphan');
|
||||
assert.throws(() => assertInstalledRegistration(phase, {
|
||||
expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe',
|
||||
keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)),
|
||||
readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null
|
||||
}), /opposite installation scope/i);
|
||||
values.delete(`${registryKeys('HKLM').install}|InstallLocation`);
|
||||
values.set(`${selected.uninstall}|UninstallString`, `"C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe" ${phase.flag} /unexpected`);
|
||||
assert.throws(() => assertInstalledRegistration(phase, {
|
||||
expectedUninstallerPath: 'C:\\smoke\\currentuser\\app\\Uninstall Twitch VOD Manager.exe',
|
||||
keyExists: (key) => [...values.keys()].some((entry) => entry.startsWith(`${key}|`)),
|
||||
readRegistryValue: (key, name) => values.get(`${key}|${name}`) ?? null
|
||||
}), /uninstall command mismatch/i);
|
||||
});
|
||||
|
||||
test('bundled electron-builder writes the selected install mode into both uninstall commands', () => {
|
||||
assert.match(builderMultiUserSource, /!macro setInstallModePerUser[\s\S]*?SetShellVarContext current/);
|
||||
assert.match(builderMultiUserSource, /!macro setInstallModePerAllUsers[\s\S]*?SetShellVarContext all/);
|
||||
assert.match(builderInstallerSource, /\$installMode == "all"[\s\S]*?StrCpy \$0 "\/allusers"[\s\S]*?StrCpy \$0 "\/currentuser"/);
|
||||
assert.match(builderInstallerSource, /WriteRegStr SHELL_CONTEXT "\$\{UNINSTALL_REGISTRY_KEY\}" UninstallString '"\$2" \$0'/);
|
||||
assert.match(builderInstallerSource, /WriteRegStr SHELL_CONTEXT "\$\{UNINSTALL_REGISTRY_KEY\}" QuietUninstallString '"\$2" \$0 \/S'/);
|
||||
});
|
||||
|
||||
test('clean surface contract includes both registry hives and both shortcut scopes', () => {
|
||||
const phases = createInstallerPhases('C:\\smoke', {
|
||||
commonDesktop: 'C:\\shared-desktop',
|
||||
commonPrograms: 'C:\\shared-programs',
|
||||
currentDesktop: 'C:\\user-desktop',
|
||||
currentPrograms: 'C:\\user-programs'
|
||||
});
|
||||
assert.doesNotThrow(() => assertInstallerSurfaceClean(phases, {
|
||||
keyExists: () => false,
|
||||
pathExists: () => false
|
||||
}));
|
||||
assert.throws(() => assertInstallerSurfaceClean(phases, {
|
||||
keyExists: (key) => key === registryKeys('HKLM').uninstall,
|
||||
pathExists: (candidate) => candidate === phases[0].startMenuShortcut
|
||||
}), /HKLM.*Twitch VOD Manager\.lnk/i);
|
||||
});
|
||||
|
||||
test('recursive cleanup is limited to the dedicated runner temp directory', () => {
|
||||
assert.doesNotThrow(() => assertPathInside('C:\\runner-temp\\tvm-installer-123', 'C:\\runner-temp'));
|
||||
assert.throws(() => assertPathInside('C:\\runner-temp', 'C:\\runner-temp'), /outside its parent/i);
|
||||
assert.throws(() => assertPathInside('C:\\other', 'C:\\runner-temp'), /outside its parent/i);
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
const nodeCrypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const LIVE_OPT_IN = 'TWITCH_VOD_MANAGER_LIVE_INTEGRATION';
|
||||
const PRODUCTION_RELEASE_DOWNLOAD_BASE = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download';
|
||||
const PACKAGE_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version;
|
||||
|
||||
function parseGateMode(argumentsList) {
|
||||
if (argumentsList.length === 0) return 'all';
|
||||
if (argumentsList.length === 1 && ['all', 'twitch', 'updater'].includes(argumentsList[0])) return argumentsList[0];
|
||||
throw new Error('Live integration mode must be one of: all, twitch, updater');
|
||||
}
|
||||
|
||||
function requiredEnvironment(environment, name) {
|
||||
const value = environment[name];
|
||||
if (typeof value !== 'string' || value.trim() === '') throw new Error(`Missing required environment variable: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalEnvironment(environment, name) {
|
||||
const value = environment[name];
|
||||
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
function readLiveConfiguration(mode, environment = process.env) {
|
||||
if (environment[LIVE_OPT_IN] !== '1') throw new Error(`Refusing live network execution without ${LIVE_OPT_IN}=1`);
|
||||
const configuration = { mode };
|
||||
|
||||
if (mode === 'all' || mode === 'twitch') {
|
||||
const login = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN').trim().toLowerCase();
|
||||
const vodId = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID').trim();
|
||||
if (!/^[a-z0-9_]{2,25}$/.test(login)) throw new Error('TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN must be a Twitch login, not a URL');
|
||||
if (!/^\d{6,20}$/.test(vodId)) throw new Error('TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID must contain only the numeric VOD id');
|
||||
configuration.twitch = {
|
||||
clientId: requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID').trim(),
|
||||
clientSecret: requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET'),
|
||||
ffprobePath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_FFPROBE_PATH'),
|
||||
login,
|
||||
streamlinkPath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_STREAMLINK_PATH'),
|
||||
vodId
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'all' || mode === 'updater') {
|
||||
const sourceVersion = normalizeVersion(requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION'));
|
||||
const sourceSha256 = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256').trim().toLowerCase();
|
||||
const expectedCommitSha = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA').trim().toLowerCase();
|
||||
const expectedVersion = normalizeVersion(requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION'));
|
||||
const expectedSha512 = requiredEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512').trim();
|
||||
const workflowCommitSha = requiredEnvironment(environment, 'GITHUB_SHA').trim().toLowerCase();
|
||||
if (!sourceVersion) throw new Error('TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION must be a numeric release version');
|
||||
if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new Error('TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256 must be a hexadecimal SHA-256 digest');
|
||||
if (!/^[a-f0-9]{40}$/.test(expectedCommitSha)) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA must be a 40-character hexadecimal commit SHA');
|
||||
if (!expectedVersion) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION must be a numeric release version');
|
||||
if (!isSha512Base64(expectedSha512)) throw new Error('TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512 must be a base64 SHA-512 digest');
|
||||
if (!/^[a-f0-9]{40}$/.test(workflowCommitSha) || workflowCommitSha !== expectedCommitSha) {
|
||||
throw new Error('Pinned update commit must match the current workflow commit');
|
||||
}
|
||||
if (compareVersions(sourceVersion, expectedVersion) >= 0) throw new Error('Packaged source version must be older than the pinned update version');
|
||||
if (expectedVersion !== PACKAGE_VERSION) throw new Error(`Pinned update version must match package version ${PACKAGE_VERSION}`);
|
||||
const expectedRef = `refs/tags/v${expectedVersion}`;
|
||||
if (requiredEnvironment(environment, 'GITHUB_REF').trim() !== expectedRef) {
|
||||
throw new Error(`Post-publish updater gate must run from release tag ${expectedRef}`);
|
||||
}
|
||||
configuration.updater = {
|
||||
expectedCommitSha,
|
||||
expectedSha512,
|
||||
expectedVersion,
|
||||
packagedAppPath: optionalEnvironment(environment, 'TWITCH_VOD_MANAGER_LIVE_PACKAGED_APP_PATH'),
|
||||
sourceSha256,
|
||||
sourceVersion
|
||||
};
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
function redactDiagnostic(error, sensitiveValues = []) {
|
||||
let diagnostic = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
const values = sensitiveValues
|
||||
.filter((value) => typeof value === 'string' && value.length >= 4)
|
||||
.sort((left, right) => right.length - left.length);
|
||||
for (const value of values) diagnostic = diagnostic.split(value).join('[REDACTED]');
|
||||
return diagnostic
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
||||
.replace(/(client_secret=)[^&\s]+/gi, '$1[REDACTED]')
|
||||
.replace(/(access_token=)[^&\s]+/gi, '$1[REDACTED]');
|
||||
}
|
||||
|
||||
function validateTwitchToken(tokenPayload, validationPayload, expectedClientId) {
|
||||
if (!tokenPayload || typeof tokenPayload !== 'object' || typeof tokenPayload.access_token !== 'string' || tokenPayload.access_token.length < 8) {
|
||||
throw new Error('Twitch OAuth token response did not contain an access token');
|
||||
}
|
||||
const tokenType = String(tokenPayload.token_type || '').toLowerCase();
|
||||
if (tokenType !== 'bearer') throw new Error('Twitch OAuth token response did not declare bearer token type');
|
||||
if (!validationPayload || typeof validationPayload !== 'object' || validationPayload.client_id !== expectedClientId) {
|
||||
throw new Error('Twitch OAuth validation returned a different client id');
|
||||
}
|
||||
const expiresInSeconds = Number(validationPayload.expires_in);
|
||||
if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) throw new Error('Twitch OAuth validation returned an expired token');
|
||||
return { expiresInSeconds, tokenType };
|
||||
}
|
||||
|
||||
function buildStreamlinkArguments(vodId, outputPath, durationSeconds = 8) {
|
||||
if (!/^\d{6,20}$/.test(String(vodId))) throw new Error('Streamlink VOD id must be numeric');
|
||||
if (!Number.isInteger(durationSeconds) || durationSeconds < 3 || durationSeconds > 60) throw new Error('Streamlink sample duration must be between 3 and 60 seconds');
|
||||
if (typeof outputPath !== 'string' || !path.isAbsolute(outputPath)) throw new Error('Streamlink output path must be absolute');
|
||||
return [
|
||||
'--no-config',
|
||||
'--no-plugin-cache',
|
||||
'--no-plugin-sideloading',
|
||||
'--http-timeout',
|
||||
'20',
|
||||
'--stream-timeout',
|
||||
'30',
|
||||
'--stream-segment-attempts',
|
||||
'2',
|
||||
'--stream-segment-timeout',
|
||||
'20',
|
||||
'--stream-segmented-duration',
|
||||
String(durationSeconds),
|
||||
'--output',
|
||||
outputPath,
|
||||
`https://www.twitch.tv/videos/${vodId}`,
|
||||
'worst'
|
||||
];
|
||||
}
|
||||
|
||||
function validateMediaProbe(probe, actualBytes) {
|
||||
const streams = Array.isArray(probe?.streams) ? probe.streams : [];
|
||||
const video = streams.find((stream) => stream?.codec_type === 'video' && typeof stream.codec_name === 'string' && stream.codec_name !== '');
|
||||
if (!video) throw new Error('Downloaded sample did not contain a video stream');
|
||||
const durationSeconds = Number(probe?.format?.duration);
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds < 1 || durationSeconds > 60) throw new Error('Downloaded sample duration was outside the bounded smoke range');
|
||||
if (!Number.isInteger(actualBytes) || actualBytes < 16 * 1024) throw new Error('Downloaded sample was too small to be valid media');
|
||||
if (actualBytes > 32 * 1024 * 1024) throw new Error('Downloaded sample exceeded the 32 MiB safety limit');
|
||||
const reportedBytes = Number(probe?.format?.size);
|
||||
if (Number.isFinite(reportedBytes) && reportedBytes !== actualBytes) throw new Error('ffprobe size did not match the downloaded file');
|
||||
return { bytes: actualBytes, codec: video.codec_name, durationSeconds };
|
||||
}
|
||||
|
||||
function parseYamlScalar(value) {
|
||||
const trimmed = value.trim();
|
||||
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) return trimmed.slice(1, -1);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseLatestYaml(source) {
|
||||
if (typeof source !== 'string' || source.length === 0 || source.length > 128 * 1024) throw new Error('latest.yml payload was empty or too large');
|
||||
const metadata = { files: [] };
|
||||
let currentFile;
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
let match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
||||
if (match) {
|
||||
const [, key, rawValue] = match;
|
||||
if (key === 'version' || key === 'path' || key === 'sha512' || key === 'releaseDate') metadata[key] = parseYamlScalar(rawValue);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s{2}-\s+url:\s*(.+)$/);
|
||||
if (match) {
|
||||
currentFile = { url: parseYamlScalar(match[1]) };
|
||||
metadata.files.push(currentFile);
|
||||
continue;
|
||||
}
|
||||
match = line.match(/^\s{4}(sha512|size):\s*(.+)$/);
|
||||
if (match && currentFile) currentFile[match[1]] = match[1] === 'size' ? Number(parseYamlScalar(match[2])) : parseYamlScalar(match[2]);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function normalizeVersion(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const normalizedLeft = normalizeVersion(left);
|
||||
const normalizedRight = normalizeVersion(right);
|
||||
if (!normalizedLeft || !normalizedRight) throw new Error('Cannot compare invalid update versions');
|
||||
const leftParts = normalizedLeft.split('.').map(Number);
|
||||
const rightParts = normalizedRight.split('.').map(Number);
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const delta = (leftParts[index] || 0) - (rightParts[index] || 0);
|
||||
if (delta !== 0) return Math.sign(delta);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isSha512Base64(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(value)) return false;
|
||||
try {
|
||||
return Buffer.from(value, 'base64').length === 64;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateProductionRelease(metadata, expected) {
|
||||
const version = normalizeVersion(metadata?.version);
|
||||
if (!version || version !== normalizeVersion(expected.expectedVersion)) throw new Error('Production release version did not match the pinned version');
|
||||
if (expected.latestTag !== `v${version}`) throw new Error('Production release tag did not match the pinned version');
|
||||
const artifactName = String(metadata?.path || '');
|
||||
if (!artifactName || artifactName !== path.posix.basename(artifactName) || artifactName !== path.win32.basename(artifactName) || !artifactName.toLowerCase().endsWith('.exe')) {
|
||||
throw new Error('Production release artifact path was unsafe');
|
||||
}
|
||||
const file = Array.isArray(metadata?.files) ? metadata.files.find((entry) => entry?.url === artifactName) : undefined;
|
||||
if (!file || !Number.isSafeInteger(file.size) || file.size < 1024 * 1024) throw new Error('Production release artifact metadata was incomplete');
|
||||
if (metadata.sha512 !== expected.expectedSha512 || file.sha512 !== expected.expectedSha512 || !isSha512Base64(expected.expectedSha512)) {
|
||||
throw new Error('Production release SHA-512 did not match the pinned digest');
|
||||
}
|
||||
const feedUrl = `${PRODUCTION_RELEASE_DOWNLOAD_BASE}/${encodeURIComponent(expected.latestTag)}/`;
|
||||
return { artifactName, artifactSize: file.size, feedUrl, version };
|
||||
}
|
||||
|
||||
async function sha512File(filePath) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const hash = nodeCrypto.createHash('sha512');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.once('error', reject);
|
||||
stream.once('end', () => resolve(hash.digest('base64')));
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDownloadedReleaseArtifact(artifactPath, expected) {
|
||||
const resolvedPath = path.resolve(artifactPath);
|
||||
const stat = fs.lstatSync(resolvedPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || path.basename(resolvedPath) !== expected.artifactName) {
|
||||
throw new Error('Downloaded updater artifact was not the expected regular file');
|
||||
}
|
||||
if (stat.size !== expected.artifactSize) throw new Error('Downloaded updater artifact size did not match latest.yml');
|
||||
const header = Buffer.alloc(2);
|
||||
const handle = fs.openSync(resolvedPath, 'r');
|
||||
try {
|
||||
fs.readSync(handle, header, 0, header.length, 0);
|
||||
} finally {
|
||||
fs.closeSync(handle);
|
||||
}
|
||||
if (header[0] !== 0x4d || header[1] !== 0x5a) throw new Error('Downloaded updater artifact was not a Windows executable');
|
||||
const digest = await sha512File(resolvedPath);
|
||||
if (digest !== expected.expectedSha512) throw new Error('Downloaded updater artifact SHA-512 did not match latest.yml');
|
||||
return { bytes: stat.size, sha512Verified: true };
|
||||
}
|
||||
|
||||
function validateUpdateCacheRecord(record, expected) {
|
||||
const fileName = typeof record?.fileName === 'string' ? record.fileName : '';
|
||||
if (!fileName || fileName !== path.basename(fileName) || fileName !== path.win32.basename(fileName) || fileName !== expected.artifactName) {
|
||||
throw new Error('Updater cache file name did not match the pinned release artifact');
|
||||
}
|
||||
if (record.sha512 !== expected.expectedSha512 || !isSha512Base64(record.sha512)) {
|
||||
throw new Error('Updater cache SHA-512 did not match the pinned release artifact');
|
||||
}
|
||||
return { fileName, sha512Verified: true };
|
||||
}
|
||||
|
||||
function assertOwnedPath(targetPath, ownerPath) {
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
const resolvedOwner = path.resolve(ownerPath);
|
||||
const relative = path.relative(resolvedOwner, resolvedTarget);
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Refusing access outside the owned temporary root: ${resolvedTarget}`);
|
||||
}
|
||||
return resolvedTarget;
|
||||
}
|
||||
|
||||
function sanitizeChildEnvironment(environment, overrides = {}) {
|
||||
const result = {};
|
||||
const allowedNames = new Set([
|
||||
'ALLUSERSPROFILE',
|
||||
'APPDATA',
|
||||
'COMMONPROGRAMFILES',
|
||||
'COMMONPROGRAMFILES(X86)',
|
||||
'COMMONPROGRAMW6432',
|
||||
'COMSPEC',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LOCALAPPDATA',
|
||||
'NUMBER_OF_PROCESSORS',
|
||||
'OS',
|
||||
'PATH',
|
||||
'PATHEXT',
|
||||
'PROCESSOR_ARCHITECTURE',
|
||||
'PROCESSOR_IDENTIFIER',
|
||||
'PROCESSOR_LEVEL',
|
||||
'PROCESSOR_REVISION',
|
||||
'PROGRAMDATA',
|
||||
'PROGRAMFILES',
|
||||
'PROGRAMFILES(X86)',
|
||||
'PROGRAMW6432',
|
||||
'SYSTEMDRIVE',
|
||||
'SYSTEMROOT',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TZ',
|
||||
'USERDOMAIN',
|
||||
'USERDOMAIN_ROAMINGPROFILE',
|
||||
'USERNAME',
|
||||
'USERPROFILE',
|
||||
'WINDIR'
|
||||
]);
|
||||
for (const [name, value] of Object.entries(environment)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const normalizedName = name.toUpperCase();
|
||||
if (!allowedNames.has(normalizedName)) continue;
|
||||
result[normalizedName] = value;
|
||||
}
|
||||
for (const [name, value] of Object.entries(overrides)) {
|
||||
if (typeof value === 'string') result[name.toUpperCase()] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LIVE_OPT_IN,
|
||||
PRODUCTION_RELEASE_DOWNLOAD_BASE,
|
||||
assertOwnedPath,
|
||||
buildStreamlinkArguments,
|
||||
compareVersions,
|
||||
isSha512Base64,
|
||||
normalizeVersion,
|
||||
parseGateMode,
|
||||
parseLatestYaml,
|
||||
readLiveConfiguration,
|
||||
redactDiagnostic,
|
||||
sanitizeChildEnvironment,
|
||||
validateMediaProbe,
|
||||
validateDownloadedReleaseArtifact,
|
||||
validateUpdateCacheRecord,
|
||||
validateProductionRelease,
|
||||
validateTwitchToken
|
||||
};
|
||||
@@ -0,0 +1,743 @@
|
||||
const nodeCrypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { Readable, Transform } = require('node:stream');
|
||||
const { pipeline } = require('node:stream/promises');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const { _electron: electron } = require('playwright');
|
||||
|
||||
const {
|
||||
PRODUCTION_RELEASE_DOWNLOAD_BASE,
|
||||
assertOwnedPath,
|
||||
buildStreamlinkArguments,
|
||||
compareVersions,
|
||||
parseGateMode,
|
||||
parseLatestYaml,
|
||||
readLiveConfiguration,
|
||||
redactDiagnostic,
|
||||
sanitizeChildEnvironment,
|
||||
validateDownloadedReleaseArtifact,
|
||||
validateMediaProbe,
|
||||
validateProductionRelease,
|
||||
validateTwitchToken,
|
||||
validateUpdateCacheRecord
|
||||
} = require('./smoke-test-live-integration-contract');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const GITHUB_LATEST_API = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest';
|
||||
const GITHUB_COMMIT_API_BASE = 'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/commits';
|
||||
const GITHUB_RELEASE_BASE = 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download';
|
||||
const TWITCH_VALIDATE_URL = 'https://id.twitch.tv/oauth2/validate';
|
||||
const MAX_SOURCE_INSTALLER_BYTES = 256 * 1024 * 1024;
|
||||
const ELECTRON_CLOSE_TIMEOUT_MS = 15000;
|
||||
const PACKAGED_VERSION_TIMEOUT_MS = 30000;
|
||||
const UPDATE_CHECK_TIMEOUT_MS = 120000;
|
||||
const UPDATE_DOWNLOAD_TIMEOUT_MS = 8 * 60 * 1000;
|
||||
|
||||
function createOwnedRoot(prefix) {
|
||||
const base = process.env.RUNNER_TEMP && path.isAbsolute(process.env.RUNNER_TEMP)
|
||||
? process.env.RUNNER_TEMP
|
||||
: os.tmpdir();
|
||||
if (!fs.statSync(base).isDirectory()) throw new Error(`Temporary base directory is unavailable: ${base}`);
|
||||
const ownedRoot = fs.mkdtempSync(path.join(base, prefix));
|
||||
assertOwnedPath(ownedRoot, base);
|
||||
return { base, ownedRoot };
|
||||
}
|
||||
|
||||
async function removeOwnedRoot(ownedRoot, base) {
|
||||
assertOwnedPath(ownedRoot, base);
|
||||
await fs.promises.rm(ownedRoot, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 });
|
||||
}
|
||||
|
||||
async function runWithOwnedRoot(prefix, operation) {
|
||||
const context = createOwnedRoot(prefix);
|
||||
try {
|
||||
return await operation(context);
|
||||
} finally {
|
||||
await removeOwnedRoot(context.ownedRoot, context.base);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBoundedOperation(label, timeoutMs, operation) {
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new Error(`${label} timeout must be a positive integer`);
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(operation),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
|
||||
return await fetch(url, { ...options, redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
|
||||
}
|
||||
|
||||
async function readBoundedText(response, maximumBytes = 256 * 1024) {
|
||||
const length = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(length) && length > maximumBytes) throw new Error(`HTTP response exceeded ${maximumBytes} bytes`);
|
||||
if (!response.body) return '';
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
for await (const chunk of Readable.fromWeb(response.body)) {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maximumBytes) throw new Error(`HTTP response exceeded ${maximumBytes} bytes`);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, totalBytes).toString('utf8');
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}, timeoutMs = 30000) {
|
||||
const response = await fetchWithTimeout(url, options, timeoutMs);
|
||||
const text = await readBoundedText(response);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} from ${new URL(url).hostname}`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON from ${new URL(url).hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
function loadBuiltTwitchProduct() {
|
||||
const product = require(path.join(root, 'dist', 'main', 'twitch'));
|
||||
const requiredExports = [
|
||||
'TwitchAppTokenService',
|
||||
'createTwitchProviderRefreshService',
|
||||
'requestPublicTwitchVodsByLogin',
|
||||
'requestTwitchAppAccessToken',
|
||||
'requestTwitchHelixUsers',
|
||||
'requestTwitchHelixVideos'
|
||||
];
|
||||
if (requiredExports.some((name) => typeof product[name] !== 'function')) {
|
||||
throw new Error('Built Twitch product paths are unavailable; run npm run build first');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
async function requestProductTwitchToken(credentials, requestJson = fetchJson) {
|
||||
const product = loadBuiltTwitchProduct();
|
||||
let tokenPayload;
|
||||
const client = {
|
||||
async post(url, data, config) {
|
||||
if (data !== null || !config || typeof config.timeout !== 'number') throw new Error('Built Twitch token request contract was invalid');
|
||||
const requestUrl = new URL(url);
|
||||
for (const [name, value] of Object.entries(config.params || {})) requestUrl.searchParams.set(name, String(value));
|
||||
tokenPayload = await requestJson(requestUrl, { method: 'POST' }, config.timeout);
|
||||
return { data: tokenPayload };
|
||||
}
|
||||
};
|
||||
const service = new product.TwitchAppTokenService(
|
||||
(requestCredentials) => product.requestTwitchAppAccessToken(client, requestCredentials, 30000)
|
||||
);
|
||||
const accessToken = await service.ensure(credentials);
|
||||
if (!accessToken) throw new Error('Built Twitch token product path rejected the provider response');
|
||||
return { accessToken, tokenPayload };
|
||||
}
|
||||
|
||||
function createProductTwitchHttpClient(requestJson = fetchJson) {
|
||||
return {
|
||||
async get(url, config) {
|
||||
const requestUrl = new URL(url);
|
||||
for (const [name, value] of Object.entries(config.params || {})) requestUrl.searchParams.set(name, String(value));
|
||||
return { data: await requestJson(requestUrl, { headers: config.headers }, config.timeout) };
|
||||
},
|
||||
async post(url, body, config) {
|
||||
return {
|
||||
data: await requestJson(url, {
|
||||
body: JSON.stringify(body),
|
||||
headers: config.headers,
|
||||
method: 'POST'
|
||||
}, config.timeout)
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyProductTwitchProviderFallbacks(configuration, accessToken, client = createProductTwitchHttpClient()) {
|
||||
const product = loadBuiltTwitchProduct();
|
||||
const auth = { accessToken, clientId: configuration.clientId };
|
||||
const usersOutcome = await product.requestTwitchHelixUsers(client, configuration.login, auth, 30000);
|
||||
if (usersOutcome.status !== 'success') throw new Error(`Built Twitch Helix user path returned ${usersOutcome.status}`);
|
||||
const user = usersOutcome.value.find((entry) => entry.login.toLowerCase() === configuration.login);
|
||||
if (!user) throw new Error('Built Twitch Helix user path did not bind the requested login');
|
||||
|
||||
let phase = 'public';
|
||||
const service = product.createTwitchProviderRefreshService({
|
||||
maxLastGoodEntries: 1,
|
||||
refreshToken: async () => false,
|
||||
requestHelix: async () => phase === 'helix'
|
||||
? await product.requestTwitchHelixVideos(client, user.id, auth, 30000, 50)
|
||||
: { status: 'unavailable' },
|
||||
requestPublic: async () => phase === 'public'
|
||||
? await product.requestPublicTwitchVodsByLogin(client, configuration.login, 100, 30000, 3)
|
||||
: { status: 'unavailable' }
|
||||
});
|
||||
const key = `vod:${configuration.vodId}`;
|
||||
const publicRefresh = await service.refresh(key);
|
||||
if (publicRefresh.source !== 'public') throw new Error(`Built Twitch public GQL path returned ${publicRefresh.source}`);
|
||||
const publicVod = publicRefresh.value?.find((entry) => entry.id === configuration.vodId);
|
||||
if (!publicVod || String(publicVod.user_login || '').toLowerCase() !== configuration.login) {
|
||||
throw new Error('Built Twitch public GQL path did not bind the requested VOD to the broadcaster');
|
||||
}
|
||||
phase = 'helix';
|
||||
const helixRefresh = await service.refresh(key);
|
||||
if (helixRefresh.source !== 'helix') throw new Error(`Built Twitch Helix video path returned ${helixRefresh.source}`);
|
||||
const helixVod = helixRefresh.value?.find((entry) => entry.id === configuration.vodId);
|
||||
if (!helixVod || String(helixVod.user_login || '').toLowerCase() !== configuration.login) {
|
||||
throw new Error('Built Twitch Helix video path did not bind the requested VOD to the broadcaster');
|
||||
}
|
||||
phase = 'offline';
|
||||
const lastGoodRefresh = await service.refresh(key);
|
||||
if (lastGoodRefresh.source !== 'last-good' || !lastGoodRefresh.stale || lastGoodRefresh.value !== helixRefresh.value) {
|
||||
throw new Error('Built Twitch provider service did not restore last-good data while both providers were unavailable');
|
||||
}
|
||||
return {
|
||||
helix: {
|
||||
duration: helixVod.duration,
|
||||
source: helixRefresh.source,
|
||||
userId: user.id,
|
||||
vodId: helixVod.id
|
||||
},
|
||||
lastGood: {
|
||||
restoredFrom: 'helix',
|
||||
restoredVodId: helixVod.id,
|
||||
source: lastGoodRefresh.source,
|
||||
stale: lastGoodRefresh.stale
|
||||
},
|
||||
public: {
|
||||
duration: publicVod.duration,
|
||||
login: publicVod.user_login,
|
||||
source: publicRefresh.source,
|
||||
vodId: publicVod.id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function assertTrustedDownloadResponse(response) {
|
||||
const finalUrl = new URL(response.url);
|
||||
const allowedHost = finalUrl.hostname === 'github.com'
|
||||
|| finalUrl.hostname.endsWith('.githubusercontent.com');
|
||||
if (finalUrl.protocol !== 'https:' || !allowedHost) throw new Error(`Release download redirected to an untrusted host: ${finalUrl.hostname}`);
|
||||
}
|
||||
|
||||
async function downloadFile(url, destinationPath, maximumBytes) {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
headers: {
|
||||
Accept: 'application/octet-stream',
|
||||
'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate'
|
||||
}
|
||||
}, 240000);
|
||||
if (!response.ok || !response.body) throw new Error(`Release download failed with HTTP ${response.status}`);
|
||||
assertTrustedDownloadResponse(response);
|
||||
const contentLength = Number(response.headers.get('content-length'));
|
||||
if (Number.isFinite(contentLength) && contentLength > maximumBytes) throw new Error(`Release download exceeded ${maximumBytes} bytes`);
|
||||
const partialPath = `${destinationPath}.partial`;
|
||||
let downloadedBytes = 0;
|
||||
const digest = nodeCrypto.createHash('sha256');
|
||||
const limiter = new Transform({
|
||||
transform(chunk, encoding, callback) {
|
||||
downloadedBytes += chunk.length;
|
||||
if (downloadedBytes > maximumBytes) {
|
||||
callback(new Error(`Release download exceeded ${maximumBytes} bytes`));
|
||||
return;
|
||||
}
|
||||
digest.update(chunk);
|
||||
callback(null, chunk);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(partialPath, { flags: 'wx' }));
|
||||
fs.renameSync(partialPath, destinationPath);
|
||||
return { bytes: downloadedBytes, sha256: digest.digest('hex') };
|
||||
} finally {
|
||||
fs.rmSync(partialPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runProcess(command, argumentsList, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, argumentsList, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || sanitizeChildEnvironment(process.env),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let finished = false;
|
||||
const capture = (current, chunk) => `${current}${chunk}`.slice(-65536);
|
||||
child.stdout?.on('data', (chunk) => { stdout = capture(stdout, chunk); });
|
||||
child.stderr?.on('data', (chunk) => { stderr = capture(stderr, chunk); });
|
||||
const timer = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
terminateProcessTree(child);
|
||||
reject(new Error(`${options.label || path.basename(command)} timed out`));
|
||||
}, options.timeoutMs || 120000);
|
||||
child.once('error', (error) => {
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(new Error(`${options.label || path.basename(command)} failed: ${JSON.stringify({ code, signal, stderr: stderr.trim(), stdout: stdout.trim() })}`));
|
||||
return;
|
||||
}
|
||||
resolve({ code, stderr: stderr.trim(), stdout: stdout.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function terminateProcessTree(child) {
|
||||
if (!child || child.exitCode !== null || !child.pid) return;
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
env: sanitizeChildEnvironment(process.env),
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
|
||||
function findExecutable(explicitPath, commandName) {
|
||||
if (explicitPath) {
|
||||
const resolved = path.resolve(explicitPath);
|
||||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new Error(`${commandName} executable is not a file: ${resolved}`);
|
||||
return resolved;
|
||||
}
|
||||
const locator = process.platform === 'win32' ? 'where.exe' : 'which';
|
||||
const located = spawnSync(locator, [commandName], {
|
||||
encoding: 'utf8',
|
||||
env: sanitizeChildEnvironment(process.env),
|
||||
windowsHide: true
|
||||
});
|
||||
if (located.status !== 0) throw new Error(`${commandName} is unavailable; set its TWITCH_VOD_MANAGER_LIVE_*_PATH variable`);
|
||||
const candidate = String(located.stdout || '').split(/\r?\n/).map((entry) => entry.trim()).find(Boolean);
|
||||
if (!candidate || !fs.statSync(candidate).isFile()) throw new Error(`${commandName} could not be resolved to a regular file`);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function runTwitchGate(configuration) {
|
||||
return await runWithOwnedRoot('tvm-live-twitch-', async ({ ownedRoot }) => {
|
||||
let accessToken = '';
|
||||
try {
|
||||
const toolProfile = path.join(ownedRoot, 'profile');
|
||||
const toolEnvironment = sanitizeChildEnvironment(process.env, {
|
||||
APPDATA: path.join(toolProfile, 'appdata'),
|
||||
LOCALAPPDATA: path.join(toolProfile, 'localappdata'),
|
||||
TEMP: path.join(toolProfile, 'temp'),
|
||||
TMP: path.join(toolProfile, 'temp'),
|
||||
USERPROFILE: toolProfile
|
||||
});
|
||||
for (const directory of [toolEnvironment.APPDATA, toolEnvironment.LOCALAPPDATA, toolEnvironment.TEMP, toolEnvironment.USERPROFILE]) {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
const streamlinkPath = findExecutable(configuration.streamlinkPath, process.platform === 'win32' ? 'streamlink.exe' : 'streamlink');
|
||||
const ffprobePath = findExecutable(configuration.ffprobePath, process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
|
||||
const streamlinkVersion = await runProcess(streamlinkPath, ['--version'], { env: toolEnvironment, label: 'Streamlink version check', timeoutMs: 30000 });
|
||||
const ffprobeVersion = await runProcess(ffprobePath, ['-version'], { env: toolEnvironment, label: 'ffprobe version check', timeoutMs: 30000 });
|
||||
|
||||
const productToken = await requestProductTwitchToken({
|
||||
clientId: configuration.clientId,
|
||||
clientSecret: configuration.clientSecret
|
||||
});
|
||||
accessToken = productToken.accessToken;
|
||||
const validationPayload = await fetchJson(TWITCH_VALIDATE_URL, {
|
||||
headers: { Authorization: `OAuth ${accessToken}` }
|
||||
});
|
||||
const token = validateTwitchToken(productToken.tokenPayload, validationPayload, configuration.clientId);
|
||||
const providers = await verifyProductTwitchProviderFallbacks(configuration, accessToken);
|
||||
|
||||
const samplePath = path.join(ownedRoot, `vod-${configuration.vodId}-sample.ts`);
|
||||
const streamlinkArguments = buildStreamlinkArguments(configuration.vodId, samplePath, 8);
|
||||
await runProcess(streamlinkPath, streamlinkArguments, { env: toolEnvironment, label: 'Bounded Streamlink VOD download', timeoutMs: 120000 });
|
||||
const stat = fs.statSync(samplePath);
|
||||
const probe = await runProcess(ffprobePath, [
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration,size',
|
||||
'-show_entries',
|
||||
'stream=codec_type,codec_name',
|
||||
'-of',
|
||||
'json',
|
||||
samplePath
|
||||
], { env: toolEnvironment, label: 'ffprobe sample validation', timeoutMs: 30000 });
|
||||
const media = validateMediaProbe(JSON.parse(probe.stdout), stat.size);
|
||||
|
||||
return {
|
||||
helix: providers.helix,
|
||||
lastGood: providers.lastGood,
|
||||
oauth: { expiresInSeconds: token.expiresInSeconds, validated: true },
|
||||
public: providers.public,
|
||||
streamlink: {
|
||||
bytes: media.bytes,
|
||||
codec: media.codec,
|
||||
durationSeconds: media.durationSeconds,
|
||||
ffprobeVersion: ffprobeVersion.stdout.split(/\r?\n/, 1)[0],
|
||||
streamlinkVersion: streamlinkVersion.stdout.split(/\r?\n/, 1)[0]
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(redactDiagnostic(error, [configuration.clientId, configuration.clientSecret, accessToken]), { cause: error });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findFileRecursive(directory, fileName) {
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) continue;
|
||||
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) return entryPath;
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFileRecursive(entryPath, fileName);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function prepareSourcePackagedApp(configuration, ownedRoot) {
|
||||
if (configuration.packagedAppPath) {
|
||||
const executablePath = path.resolve(configuration.packagedAppPath);
|
||||
if (!fs.statSync(executablePath).isFile()) throw new Error(`Packaged source executable is not a file: ${executablePath}`);
|
||||
return { executablePath, sourceInstallerVerified: false };
|
||||
}
|
||||
|
||||
const sourceTag = `v${configuration.sourceVersion}`;
|
||||
const installerName = `Twitch-VOD-Manager-Setup-${configuration.sourceVersion}.exe`;
|
||||
const installerUrl = `${GITHUB_RELEASE_BASE}/${sourceTag}/${installerName}`;
|
||||
const installerPath = path.join(ownedRoot, installerName);
|
||||
const downloaded = await downloadFile(installerUrl, installerPath, MAX_SOURCE_INSTALLER_BYTES);
|
||||
if (downloaded.sha256 !== configuration.sourceSha256) throw new Error('Public source installer SHA-256 did not match the pinned digest');
|
||||
|
||||
const sevenZipPath = path.join(root, 'node_modules', 'electron-winstaller', 'vendor', '7z.exe');
|
||||
if (!fs.existsSync(sevenZipPath)) throw new Error('Bundled 7z extractor is unavailable; run npm ci first');
|
||||
const installerExtraction = path.join(ownedRoot, 'source-installer');
|
||||
const appExtraction = path.join(ownedRoot, 'source-app');
|
||||
fs.mkdirSync(installerExtraction);
|
||||
fs.mkdirSync(appExtraction);
|
||||
await runProcess(sevenZipPath, ['x', '-y', `-o${installerExtraction}`, installerPath, '$PLUGINSDIR\\app-64.7z'], {
|
||||
label: 'Source installer extraction',
|
||||
timeoutMs: 120000
|
||||
});
|
||||
const appArchivePath = findFileRecursive(installerExtraction, 'app-64.7z');
|
||||
if (!appArchivePath) throw new Error('Public source installer did not contain app-64.7z');
|
||||
assertOwnedPath(appArchivePath, ownedRoot);
|
||||
await runProcess(sevenZipPath, ['x', '-y', `-o${appExtraction}`, appArchivePath], {
|
||||
label: 'Packaged source app extraction',
|
||||
timeoutMs: 120000
|
||||
});
|
||||
const executablePath = findFileRecursive(appExtraction, 'Twitch VOD Manager.exe');
|
||||
if (!executablePath) throw new Error('Extracted public source app did not contain Twitch VOD Manager.exe');
|
||||
assertOwnedPath(executablePath, ownedRoot);
|
||||
return { executablePath, sourceInstallerVerified: true };
|
||||
}
|
||||
|
||||
async function requestProductionLatestYaml(url) {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
headers: { 'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`Production latest.yml failed with HTTP ${response.status}`);
|
||||
assertTrustedDownloadResponse(response);
|
||||
return await readBoundedText(response, 128 * 1024);
|
||||
}
|
||||
|
||||
async function inspectProductionRelease(configuration, dependencies = {}) {
|
||||
const requestJson = dependencies.requestJson || fetchJson;
|
||||
const requestLatestYaml = dependencies.requestLatestYaml || requestProductionLatestYaml;
|
||||
const latest = await requestJson(GITHUB_LATEST_API, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate'
|
||||
}
|
||||
});
|
||||
const expectedTag = `v${configuration.expectedVersion}`;
|
||||
if (latest?.tag_name !== expectedTag || latest?.draft === true || latest?.prerelease === true) {
|
||||
throw new Error(`GitHub latest release was not the pinned public ${expectedTag}`);
|
||||
}
|
||||
const commit = await requestJson(`${GITHUB_COMMIT_API_BASE}/${encodeURIComponent(expectedTag)}`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'User-Agent': 'Twitch-VOD-Manager-Live-Integration-Gate'
|
||||
}
|
||||
});
|
||||
const commitSha = typeof commit?.sha === 'string' ? commit.sha.trim().toLowerCase() : '';
|
||||
if (!/^[a-f0-9]{40}$/.test(commitSha) || commitSha !== configuration.expectedCommitSha) {
|
||||
throw new Error('Public release tag commit did not match the pinned workflow commit');
|
||||
}
|
||||
const feedUrl = `${PRODUCTION_RELEASE_DOWNLOAD_BASE}/${expectedTag}/latest.yml`;
|
||||
const metadata = parseLatestYaml(await requestLatestYaml(feedUrl));
|
||||
const release = validateProductionRelease(metadata, {
|
||||
expectedSha512: configuration.expectedSha512,
|
||||
expectedVersion: configuration.expectedVersion,
|
||||
latestTag: expectedTag
|
||||
});
|
||||
const assets = Array.isArray(latest.assets) ? latest.assets : [];
|
||||
const installerAsset = assets.find((asset) => asset?.name === release.artifactName);
|
||||
const feedAsset = assets.find((asset) => asset?.name === 'latest.yml');
|
||||
if (!installerAsset || installerAsset.size !== release.artifactSize || !feedAsset) {
|
||||
throw new Error('GitHub latest release assets did not match latest.yml');
|
||||
}
|
||||
return { ...release, commitSha };
|
||||
}
|
||||
|
||||
function createUpdaterEnvironment(ownedRoot) {
|
||||
const directories = {
|
||||
appData: path.join(ownedRoot, 'appdata'),
|
||||
localAppData: path.join(ownedRoot, 'localappdata'),
|
||||
programData: path.join(ownedRoot, 'programdata'),
|
||||
temp: path.join(ownedRoot, 'temp'),
|
||||
userData: path.join(ownedRoot, 'userdata'),
|
||||
userProfile: path.join(ownedRoot, 'profile')
|
||||
};
|
||||
for (const directory of [...Object.values(directories), path.join(directories.userProfile, 'Desktop')]) {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
const environment = sanitizeChildEnvironment(process.env, {
|
||||
APPDATA: directories.appData,
|
||||
LOCALAPPDATA: directories.localAppData,
|
||||
PROGRAMDATA: directories.programData,
|
||||
TEMP: directories.temp,
|
||||
TMP: directories.temp,
|
||||
USERPROFILE: directories.userProfile
|
||||
});
|
||||
return { directories, environment };
|
||||
}
|
||||
|
||||
async function waitForHardExit(child, timeoutMs = 15000) {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error('Packaged app did not exit after forced termination'));
|
||||
}, timeoutMs);
|
||||
child.once('exit', finish);
|
||||
if (child.exitCode !== null) finish();
|
||||
});
|
||||
}
|
||||
|
||||
async function closeElectronApp(electronApp, electronProcess, options = {}) {
|
||||
if (!electronProcess || electronProcess.exitCode !== null) return;
|
||||
const timeoutMs = options.timeoutMs || ELECTRON_CLOSE_TIMEOUT_MS;
|
||||
const terminate = options.terminate || terminateProcessTree;
|
||||
const waitForExit = options.waitForExit || waitForHardExit;
|
||||
if (electronApp) {
|
||||
try {
|
||||
await runBoundedOperation('Packaged app close', timeoutMs, async () => await electronApp.close());
|
||||
} catch {}
|
||||
}
|
||||
if (electronProcess.exitCode === null) {
|
||||
terminate(electronProcess);
|
||||
await waitForExit(electronProcess, timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithElectronAppCleanup(lifecycle, operation, closeOptions = {}) {
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await closeElectronApp(lifecycle.electronApp, lifecycle.electronProcess, closeOptions);
|
||||
}
|
||||
}
|
||||
|
||||
async function startUpdaterDownload(window) {
|
||||
await window.locator('#workspaceUpdateButton').hover();
|
||||
const downloadButton = window.locator('#updateButton');
|
||||
await downloadButton.waitFor({ state: 'visible' });
|
||||
await downloadButton.click();
|
||||
}
|
||||
|
||||
async function getPackagedVersion(window, timeoutMs = PACKAGED_VERSION_TIMEOUT_MS) {
|
||||
return await runBoundedOperation(
|
||||
'Packaged app version query',
|
||||
timeoutMs,
|
||||
async () => await window.evaluate(() => window.api.getVersion())
|
||||
);
|
||||
}
|
||||
|
||||
async function checkPackagedUpdate(window, timeoutMs = UPDATE_CHECK_TIMEOUT_MS) {
|
||||
return await runBoundedOperation(
|
||||
'Packaged app update check',
|
||||
timeoutMs,
|
||||
async () => await window.evaluate(() => window.api.checkUpdate())
|
||||
);
|
||||
}
|
||||
|
||||
function createUpdaterDownloadReadySummary({ artifact, downloadedVersion, progressEvents, release, source, sourceVersion }) {
|
||||
return {
|
||||
artifact: {
|
||||
bytes: artifact.bytes,
|
||||
name: release.artifactName,
|
||||
sha512Verified: artifact.sha512Verified
|
||||
},
|
||||
feed: release.feedUrl,
|
||||
scope: 'download-ready',
|
||||
source: {
|
||||
installerDigestVerified: source.sourceInstallerVerified,
|
||||
version: sourceVersion
|
||||
},
|
||||
target: {
|
||||
commitSha: release.commitSha,
|
||||
downloadReady: true,
|
||||
progressEvents,
|
||||
version: downloadedVersion
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function runUpdaterGate(configuration) {
|
||||
if (process.platform !== 'win32') throw new Error('The packaged updater live gate requires Windows');
|
||||
return await runWithOwnedRoot('tvm-live-updater-', async ({ ownedRoot }) => {
|
||||
const lifecycle = { electronApp: null, electronProcess: null };
|
||||
return await runWithElectronAppCleanup(lifecycle, async () => {
|
||||
const release = await inspectProductionRelease(configuration);
|
||||
const source = await prepareSourcePackagedApp(configuration, ownedRoot);
|
||||
const { directories, environment } = createUpdaterEnvironment(ownedRoot);
|
||||
lifecycle.electronApp = await electron.launch({
|
||||
executablePath: source.executablePath,
|
||||
args: [`--user-data-dir=${directories.userData}`],
|
||||
env: environment,
|
||||
timeout: 60000
|
||||
});
|
||||
lifecycle.electronProcess = lifecycle.electronApp.process();
|
||||
const window = await lifecycle.electronApp.firstWindow({ timeout: 60000 });
|
||||
await window.waitForFunction(() => Boolean(window.api && document.getElementById('updateBanner')), null, { timeout: 60000 });
|
||||
await window.evaluate(() => {
|
||||
window.__tvmLiveUpdaterGate = {
|
||||
available: null,
|
||||
downloaded: null,
|
||||
errors: [],
|
||||
maximumProgress: 0,
|
||||
progressEvents: 0
|
||||
};
|
||||
window.api.onUpdateAvailable((info) => { window.__tvmLiveUpdaterGate.available = info; });
|
||||
window.api.onUpdateDownloaded((info) => { window.__tvmLiveUpdaterGate.downloaded = info; });
|
||||
window.api.onUpdateDownloadProgress((progress) => {
|
||||
window.__tvmLiveUpdaterGate.progressEvents += 1;
|
||||
window.__tvmLiveUpdaterGate.maximumProgress = Math.max(window.__tvmLiveUpdaterGate.maximumProgress, Number(progress.percent) || 0);
|
||||
});
|
||||
window.api.onUpdateError((error) => { window.__tvmLiveUpdaterGate.errors.push(String(error?.message || 'update-error')); });
|
||||
});
|
||||
const sourceVersion = await getPackagedVersion(window);
|
||||
if (sourceVersion !== configuration.sourceVersion || compareVersions(sourceVersion, configuration.expectedVersion) >= 0) {
|
||||
throw new Error(`Packaged source app version ${sourceVersion} was not the pinned older ${configuration.sourceVersion}`);
|
||||
}
|
||||
|
||||
const checkResult = await checkPackagedUpdate(window);
|
||||
if (!checkResult || checkResult.error) throw new Error('Packaged app rejected the production update check');
|
||||
await window.waitForFunction((version) => {
|
||||
const state = window.__tvmLiveUpdaterGate;
|
||||
return state.errors.length > 0 || state.available?.version === version;
|
||||
}, configuration.expectedVersion, { timeout: 120000 });
|
||||
let state = await window.evaluate(() => ({
|
||||
events: window.__tvmLiveUpdaterGate,
|
||||
ui: document.getElementById('updateBanner')?.dataset.updateState
|
||||
}));
|
||||
if (state.events.errors.length > 0) throw new Error(`Packaged updater emitted an error before download: ${state.events.errors.join('; ')}`);
|
||||
if (state.ui !== 'available') throw new Error(`Packaged updater UI did not reach available state: ${state.ui || 'missing'}`);
|
||||
|
||||
await startUpdaterDownload(window);
|
||||
await window.waitForFunction((version) => {
|
||||
const state = window.__tvmLiveUpdaterGate;
|
||||
const uiState = document.getElementById('updateBanner')?.dataset.updateState;
|
||||
return state.errors.length > 0 || (state.downloaded?.version === version && uiState === 'ready');
|
||||
}, configuration.expectedVersion, { timeout: UPDATE_DOWNLOAD_TIMEOUT_MS });
|
||||
state = await window.evaluate(() => ({
|
||||
events: window.__tvmLiveUpdaterGate,
|
||||
installButtonDisabled: document.getElementById('updateButton')?.disabled,
|
||||
progressValue: document.getElementById('updateProgressGauge')?.getAttribute('aria-valuenow'),
|
||||
ui: document.getElementById('updateBanner')?.dataset.updateState
|
||||
}));
|
||||
if (state.events.errors.length > 0) throw new Error(`Packaged updater emitted an error during download: ${state.events.errors.join('; ')}`);
|
||||
if (state.ui !== 'ready' || state.progressValue !== '100' || state.installButtonDisabled !== false) {
|
||||
throw new Error(`Packaged updater UI did not reach install-ready state: ${JSON.stringify({ disabled: state.installButtonDisabled, progress: state.progressValue, ui: state.ui })}`);
|
||||
}
|
||||
if (state.events.progressEvents < 1 || state.events.maximumProgress <= 0) {
|
||||
throw new Error('Packaged updater did not emit real download progress from the isolated cache');
|
||||
}
|
||||
|
||||
const pendingDirectory = path.join(directories.localAppData, 'twitch-vod-manager-updater', 'pending');
|
||||
assertOwnedPath(pendingDirectory, ownedRoot);
|
||||
const updateInfoPath = path.join(pendingDirectory, 'update-info.json');
|
||||
const updateInfo = JSON.parse(fs.readFileSync(updateInfoPath, 'utf8'));
|
||||
const cacheRecord = validateUpdateCacheRecord(updateInfo, {
|
||||
artifactName: release.artifactName,
|
||||
expectedSha512: configuration.expectedSha512
|
||||
});
|
||||
const artifactPath = path.join(pendingDirectory, cacheRecord.fileName);
|
||||
assertOwnedPath(artifactPath, ownedRoot);
|
||||
const artifact = await validateDownloadedReleaseArtifact(artifactPath, {
|
||||
artifactName: release.artifactName,
|
||||
artifactSize: release.artifactSize,
|
||||
expectedSha512: configuration.expectedSha512
|
||||
});
|
||||
|
||||
return createUpdaterDownloadReadySummary({
|
||||
artifact,
|
||||
downloadedVersion: state.events.downloaded.version,
|
||||
progressEvents: state.events.progressEvents,
|
||||
release,
|
||||
source,
|
||||
sourceVersion
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = parseGateMode(process.argv.slice(2));
|
||||
const configuration = readLiveConfiguration(mode);
|
||||
const summary = { mode, updater: null, twitch: null };
|
||||
if (mode === 'all' || mode === 'twitch') summary.twitch = await runTwitchGate(configuration.twitch);
|
||||
if (mode === 'all' || mode === 'updater') summary.updater = await runUpdaterGate(configuration.updater);
|
||||
console.log(JSON.stringify({ failures: [], summary }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
const secrets = Object.entries(process.env)
|
||||
.filter(([name]) => name.startsWith('TWITCH_VOD_MANAGER_LIVE_') && /CLIENT_ID|SECRET|TOKEN/i.test(name))
|
||||
.map(([, value]) => value);
|
||||
console.error(redactDiagnostic(error, secrets));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkPackagedUpdate,
|
||||
closeElectronApp,
|
||||
createUpdaterEnvironment,
|
||||
createUpdaterDownloadReadySummary,
|
||||
downloadFile,
|
||||
findExecutable,
|
||||
getPackagedVersion,
|
||||
inspectProductionRelease,
|
||||
prepareSourcePackagedApp,
|
||||
requestProductTwitchToken,
|
||||
runWithElectronAppCleanup,
|
||||
runWithOwnedRoot,
|
||||
runTwitchGate,
|
||||
runUpdaterGate,
|
||||
startUpdaterDownload,
|
||||
verifyProductTwitchProviderFallbacks
|
||||
};
|
||||
@@ -0,0 +1,704 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const nodeCrypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
assertOwnedPath,
|
||||
buildStreamlinkArguments,
|
||||
compareVersions,
|
||||
parseGateMode,
|
||||
parseLatestYaml,
|
||||
readLiveConfiguration,
|
||||
redactDiagnostic,
|
||||
sanitizeChildEnvironment,
|
||||
validateMediaProbe,
|
||||
validateDownloadedReleaseArtifact,
|
||||
validateProductionRelease,
|
||||
validateUpdateCacheRecord,
|
||||
validateTwitchToken
|
||||
} = require('./smoke-test-live-integration-contract');
|
||||
const {
|
||||
checkPackagedUpdate,
|
||||
closeElectronApp,
|
||||
createUpdaterDownloadReadySummary,
|
||||
getPackagedVersion,
|
||||
inspectProductionRelease,
|
||||
requestProductTwitchToken,
|
||||
runWithElectronAppCleanup,
|
||||
runWithOwnedRoot,
|
||||
startUpdaterDownload,
|
||||
verifyProductTwitchProviderFallbacks
|
||||
} = require('./smoke-test-live-integration');
|
||||
const PACKAGE_VERSION = require('../package.json').version;
|
||||
|
||||
const TWITCH_ENVIRONMENT = {
|
||||
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1',
|
||||
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_ID: 'client-id-value',
|
||||
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: 'client-secret-value',
|
||||
TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: 'example_channel',
|
||||
TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: '1234567890'
|
||||
};
|
||||
|
||||
const UPDATE_SHA512 = Buffer.alloc(64, 7).toString('base64');
|
||||
const UPDATE_COMMIT_SHA = 'b'.repeat(40);
|
||||
|
||||
function releaseYaml(version = PACKAGE_VERSION) {
|
||||
return [
|
||||
`version: ${version}`,
|
||||
'files:',
|
||||
` - url: Twitch-VOD-Manager-Setup-${version}.exe`,
|
||||
` sha512: ${UPDATE_SHA512}`,
|
||||
' size: 120000000',
|
||||
`path: Twitch-VOD-Manager-Setup-${version}.exe`,
|
||||
`sha512: ${UPDATE_SHA512}`,
|
||||
"releaseDate: '2026-08-13T10:00:00.000Z'"
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function releaseInspectionDependencies(commitSha = UPDATE_COMMIT_SHA) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
requestJson: async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url).endsWith('/releases/latest')) {
|
||||
return {
|
||||
assets: [
|
||||
{ name: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`, size: 120000000 },
|
||||
{ name: 'latest.yml', size: 1024 }
|
||||
],
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
tag_name: `v${PACKAGE_VERSION}`
|
||||
};
|
||||
}
|
||||
if (String(url).endsWith(`/commits/v${PACKAGE_VERSION}`)) return { sha: commitSha };
|
||||
throw new Error(`Unexpected JSON request: ${url}`);
|
||||
},
|
||||
requestLatestYaml: async (url) => {
|
||||
calls.push(String(url));
|
||||
return releaseYaml();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updaterEnvironment(overrides = {}) {
|
||||
return {
|
||||
GITHUB_REF: `refs/tags/v${PACKAGE_VERSION}`,
|
||||
GITHUB_SHA: UPDATE_COMMIT_SHA,
|
||||
TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1',
|
||||
TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION: '0.0.1',
|
||||
TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256: 'a'.repeat(64),
|
||||
TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: UPDATE_COMMIT_SHA,
|
||||
TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: PACKAGE_VERSION,
|
||||
TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512: UPDATE_SHA512,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('refuses every live mode until the explicit opt-in is set', () => {
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_INTEGRATION: undefined }),
|
||||
/TWITCH_VOD_MANAGER_LIVE_INTEGRATION=1/
|
||||
);
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', {}),
|
||||
/TWITCH_VOD_MANAGER_LIVE_INTEGRATION=1/
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps Twitch credentials scoped to the Twitch gate while updater requires explicit release pins', () => {
|
||||
const twitch = readLiveConfiguration('twitch', TWITCH_ENVIRONMENT);
|
||||
assert.equal(twitch.twitch.clientId, 'client-id-value');
|
||||
assert.equal(twitch.twitch.clientSecret, 'client-secret-value');
|
||||
assert.equal(twitch.twitch.login, 'example_channel');
|
||||
assert.equal(twitch.twitch.vodId, '1234567890');
|
||||
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', { TWITCH_VOD_MANAGER_LIVE_INTEGRATION: '1' }),
|
||||
/TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION/
|
||||
);
|
||||
|
||||
const updater = readLiveConfiguration('updater', updaterEnvironment());
|
||||
assert.equal(updater.twitch, undefined);
|
||||
assert.equal(updater.updater.sourceVersion, '0.0.1');
|
||||
assert.equal(updater.updater.sourceSha256, 'a'.repeat(64));
|
||||
assert.equal(updater.updater.expectedVersion, PACKAGE_VERSION);
|
||||
assert.equal(updater.updater.expectedSha512, UPDATE_SHA512);
|
||||
assert.equal(updater.updater.expectedCommitSha, UPDATE_COMMIT_SHA);
|
||||
assert.equal(updater.updater.packagedAppPath, undefined);
|
||||
|
||||
const override = readLiveConfiguration('updater', updaterEnvironment({
|
||||
TWITCH_VOD_MANAGER_LIVE_PACKAGED_APP_PATH: 'C:\\fixtures\\Twitch VOD Manager.exe',
|
||||
}));
|
||||
assert.equal(override.updater.packagedAppPath, 'C:\\fixtures\\Twitch VOD Manager.exe');
|
||||
assert.equal(override.updater.sourceVersion, '0.0.1');
|
||||
assert.equal(override.updater.expectedVersion, PACKAGE_VERSION);
|
||||
assert.equal(override.updater.expectedSha512, UPDATE_SHA512);
|
||||
});
|
||||
|
||||
test('rejects every missing updater pin and binds the target to package version and release tag', () => {
|
||||
for (const name of [
|
||||
'TWITCH_VOD_MANAGER_LIVE_SOURCE_VERSION',
|
||||
'TWITCH_VOD_MANAGER_LIVE_SOURCE_SHA256',
|
||||
'TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA',
|
||||
'TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION',
|
||||
'TWITCH_VOD_MANAGER_LIVE_UPDATE_SHA512'
|
||||
]) {
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', updaterEnvironment({ [name]: undefined })),
|
||||
(error) => error instanceof Error && error.message.includes(name)
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', updaterEnvironment({ TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: '99.0.0', GITHUB_REF: 'refs/tags/v99.0.0' })),
|
||||
/package version/
|
||||
);
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', updaterEnvironment({ GITHUB_REF: 'refs/heads/main' })),
|
||||
/release tag/
|
||||
);
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', updaterEnvironment({ TWITCH_VOD_MANAGER_LIVE_UPDATE_COMMIT_SHA: 'abc' })),
|
||||
/UPDATE_COMMIT_SHA/
|
||||
);
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', updaterEnvironment({ GITHUB_SHA: 'c'.repeat(40) })),
|
||||
/current workflow commit/
|
||||
);
|
||||
for (const version of ['1.0.18-alpha', '1.0.18+build', '01.0.18', '1.00.18', '1.0.18.0', '1234567890.0.0']) {
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('updater', updaterEnvironment({
|
||||
GITHUB_REF: `refs/tags/v${version}`,
|
||||
TWITCH_VOD_MANAGER_LIVE_UPDATE_VERSION: version
|
||||
})),
|
||||
/numeric release version/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('obtains the live credential through the built Twitch token product path', async () => {
|
||||
const product = require('../dist/main/twitch');
|
||||
assert.equal(requestProductTwitchToken.length, 1);
|
||||
assert.equal(typeof product.TwitchAppTokenService, 'function');
|
||||
assert.equal(typeof product.requestTwitchAppAccessToken, 'function');
|
||||
let requests = 0;
|
||||
const request = async (url, options, timeoutMs) => {
|
||||
requests += 1;
|
||||
assert.equal(url.origin + url.pathname, 'https://id.twitch.tv/oauth2/token');
|
||||
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||
client_id: 'client-id-value',
|
||||
client_secret: 'client-secret-value',
|
||||
grant_type: 'client_credentials'
|
||||
});
|
||||
assert.deepEqual(options, { method: 'POST' });
|
||||
assert.equal(timeoutMs, 30000);
|
||||
return { access_token: 'live-product-token', expires_in: 3600, token_type: 'bearer' };
|
||||
};
|
||||
const token = await requestProductTwitchToken(
|
||||
{ clientId: 'client-id-value', clientSecret: 'client-secret-value' },
|
||||
request
|
||||
);
|
||||
assert.deepEqual(token, {
|
||||
accessToken: 'live-product-token',
|
||||
tokenPayload: { access_token: 'live-product-token', expires_in: 3600, token_type: 'bearer' }
|
||||
});
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test('runs Helix, public GQL, and offline last-good through the built Twitch provider product paths', async () => {
|
||||
const calls = [];
|
||||
const client = {
|
||||
async get(url, config) {
|
||||
calls.push({ config, method: 'GET', url });
|
||||
assert.deepEqual(config.headers, {
|
||||
'Client-ID': 'client-id-value',
|
||||
Authorization: 'Bearer live-product-token'
|
||||
});
|
||||
assert.equal(config.timeout, 30000);
|
||||
if (url === 'https://api.twitch.tv/helix/users') {
|
||||
assert.deepEqual(config.params, { login: 'example_channel' });
|
||||
return {
|
||||
data: {
|
||||
data: [{
|
||||
broadcaster_type: 'partner',
|
||||
description: 'Example broadcaster',
|
||||
display_name: 'Example Channel',
|
||||
id: '42',
|
||||
login: 'example_channel',
|
||||
profile_image_url: 'https://static-cdn.example.test/profile.png'
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
assert.equal(url, 'https://api.twitch.tv/helix/videos');
|
||||
assert.deepEqual(config.params, { first: 100, type: 'archive', user_id: '42' });
|
||||
return {
|
||||
data: {
|
||||
data: [{
|
||||
created_at: '2026-08-13T10:00:00Z',
|
||||
duration: '2h3m4s',
|
||||
id: '1234567890',
|
||||
stream_id: 'stream-1',
|
||||
thumbnail_url: 'https://static-cdn.example.test/vod.jpg',
|
||||
title: 'A VOD',
|
||||
url: 'https://www.twitch.tv/videos/1234567890',
|
||||
user_login: 'example_channel',
|
||||
view_count: 123
|
||||
}],
|
||||
pagination: {}
|
||||
}
|
||||
};
|
||||
},
|
||||
async post(url, body, config) {
|
||||
calls.push({ body, config, method: 'POST', url });
|
||||
if (config.timeout !== 30000) {
|
||||
const error = new Error('Public product wrapper did not own its request signature');
|
||||
error.response = { status: 400 };
|
||||
throw error;
|
||||
}
|
||||
assert.equal(url, 'https://gql.twitch.tv/gql');
|
||||
assert.equal(typeof body.query, 'string');
|
||||
assert.deepEqual(body.variables, { first: 100, login: 'example_channel' });
|
||||
assert.equal(config.headers['Content-Type'], 'application/json');
|
||||
assert.equal(typeof config.headers['Client-ID'], 'string');
|
||||
assert.notEqual(config.headers['Client-ID'], 'client-id-value');
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
user: {
|
||||
videos: {
|
||||
edges: [{
|
||||
node: {
|
||||
id: '1234567890',
|
||||
lengthSeconds: 7384,
|
||||
previewThumbnailURL: 'https://static-cdn.example.test/vod.jpg',
|
||||
publishedAt: '2026-08-13T10:00:00Z',
|
||||
title: 'A VOD',
|
||||
viewCount: 123
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => { throw new Error('provider contract bypassed its injected transport'); };
|
||||
try {
|
||||
const result = await verifyProductTwitchProviderFallbacks({
|
||||
clientId: 'client-id-value',
|
||||
login: 'example_channel',
|
||||
vodId: '1234567890'
|
||||
}, 'live-product-token', client);
|
||||
assert.deepEqual(result, {
|
||||
helix: { duration: '2h3m4s', source: 'helix', userId: '42', vodId: '1234567890' },
|
||||
lastGood: { restoredFrom: 'helix', restoredVodId: '1234567890', source: 'last-good', stale: true },
|
||||
public: { duration: '2h3m4s', login: 'example_channel', source: 'public', vodId: '1234567890' }
|
||||
});
|
||||
assert.deepEqual(calls.map((call) => `${call.method} ${call.url}`), [
|
||||
'GET https://api.twitch.tv/helix/users',
|
||||
'POST https://gql.twitch.tv/gql',
|
||||
'GET https://api.twitch.tv/helix/videos'
|
||||
]);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('bounds the packaged version product call', async () => {
|
||||
const window = { evaluate: async () => await new Promise(() => {}) };
|
||||
await assert.rejects(getPackagedVersion(window, 10), /version query timed out/i);
|
||||
});
|
||||
|
||||
test('bounds the packaged update-check product call independently', async () => {
|
||||
const window = { evaluate: async () => await new Promise(() => {}) };
|
||||
await assert.rejects(checkPackagedUpdate(window, 10), /update check timed out/i);
|
||||
});
|
||||
|
||||
test('closes the packaged app gracefully without a hard kill', async () => {
|
||||
const calls = [];
|
||||
const process = { exitCode: null };
|
||||
const app = {
|
||||
close: async () => {
|
||||
calls.push('close');
|
||||
process.exitCode = 0;
|
||||
}
|
||||
};
|
||||
await closeElectronApp(app, process, {
|
||||
terminate: () => calls.push('terminate'),
|
||||
timeoutMs: 10,
|
||||
waitForExit: async () => calls.push('wait')
|
||||
});
|
||||
assert.deepEqual(calls, ['close']);
|
||||
});
|
||||
|
||||
test('hard-kills the packaged app after graceful close times out', async () => {
|
||||
const calls = [];
|
||||
const process = { exitCode: null };
|
||||
const app = { close: async () => { calls.push('close'); return await new Promise(() => {}); } };
|
||||
await closeElectronApp(app, process, {
|
||||
terminate: () => { calls.push('terminate'); process.exitCode = 1; },
|
||||
timeoutMs: 10,
|
||||
waitForExit: async () => calls.push('wait')
|
||||
});
|
||||
assert.deepEqual(calls, ['close', 'terminate', 'wait']);
|
||||
});
|
||||
|
||||
test('hard-kills the packaged app after graceful close rejects', async () => {
|
||||
const calls = [];
|
||||
const process = { exitCode: null };
|
||||
const app = { close: async () => { calls.push('close'); throw new Error('close rejected'); } };
|
||||
await closeElectronApp(app, process, {
|
||||
terminate: () => { calls.push('terminate'); process.exitCode = 1; },
|
||||
timeoutMs: 10,
|
||||
waitForExit: async () => calls.push('wait')
|
||||
});
|
||||
assert.deepEqual(calls, ['close', 'terminate', 'wait']);
|
||||
});
|
||||
|
||||
test('closes the packaged app when the updater body fails', async () => {
|
||||
const calls = [];
|
||||
const process = { exitCode: null };
|
||||
const lifecycle = {
|
||||
electronApp: { close: async () => { calls.push('close'); process.exitCode = 0; } },
|
||||
electronProcess: process
|
||||
};
|
||||
await assert.rejects(
|
||||
runWithElectronAppCleanup(lifecycle, async () => { throw new Error('updater body failed'); }, { timeoutMs: 10 }),
|
||||
/updater body failed/
|
||||
);
|
||||
assert.deepEqual(calls, ['close']);
|
||||
});
|
||||
|
||||
test('opens the update popover before clicking its download action', async () => {
|
||||
const calls = [];
|
||||
const locators = {
|
||||
'#workspaceUpdateButton': { hover: async () => { calls.push('hover'); } },
|
||||
'#updateButton': {
|
||||
waitFor: async (options) => { calls.push(`wait:${options.state}`); },
|
||||
click: async () => { calls.push('click'); }
|
||||
}
|
||||
};
|
||||
await startUpdaterDownload({ locator: (selector) => locators[selector] });
|
||||
assert.deepEqual(calls, ['hover', 'wait:visible', 'click']);
|
||||
});
|
||||
|
||||
test('rejects malformed public fixture identities before making network requests', () => {
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_TWITCH_LOGIN: 'https://twitch.tv/name' }),
|
||||
/LIVE_TWITCH_LOGIN/
|
||||
);
|
||||
assert.throws(
|
||||
() => readLiveConfiguration('twitch', { ...TWITCH_ENVIRONMENT, TWITCH_VOD_MANAGER_LIVE_TWITCH_VOD_ID: '../123' }),
|
||||
/LIVE_TWITCH_VOD_ID/
|
||||
);
|
||||
});
|
||||
|
||||
test('redacts credentials and access tokens from nested external errors', () => {
|
||||
const diagnostic = redactDiagnostic(
|
||||
new Error('request failed for client-id-value client-secret-value bearer-token-value'),
|
||||
['client-id-value', 'client-secret-value', 'bearer-token-value']
|
||||
);
|
||||
assert.equal(diagnostic.includes('client-id-value'), false);
|
||||
assert.equal(diagnostic.includes('client-secret-value'), false);
|
||||
assert.equal(diagnostic.includes('bearer-token-value'), false);
|
||||
assert.match(diagnostic, /\[REDACTED\]/);
|
||||
});
|
||||
|
||||
test('validates a real client-credentials token contract without exposing the token', () => {
|
||||
const result = validateTwitchToken(
|
||||
{ access_token: 'bearer-token-value', expires_in: 3600, token_type: 'bearer' },
|
||||
{ client_id: 'client-id-value', expires_in: 3590 },
|
||||
'client-id-value'
|
||||
);
|
||||
assert.deepEqual(result, { expiresInSeconds: 3590, tokenType: 'bearer' });
|
||||
assert.throws(
|
||||
() => validateTwitchToken(
|
||||
{ access_token: 'bearer-token-value', expires_in: 3600, token_type: 'bearer' },
|
||||
{ client_id: 'other-client', expires_in: 3590 },
|
||||
'client-id-value'
|
||||
),
|
||||
/client id/
|
||||
);
|
||||
});
|
||||
|
||||
test('builds a bounded lowest-quality Streamlink download command', () => {
|
||||
const output = path.join('C:\\runner\\temp', 'sample.ts');
|
||||
assert.deepEqual(buildStreamlinkArguments('1234567890', output, 8), [
|
||||
'--no-config',
|
||||
'--no-plugin-cache',
|
||||
'--no-plugin-sideloading',
|
||||
'--http-timeout',
|
||||
'20',
|
||||
'--stream-timeout',
|
||||
'30',
|
||||
'--stream-segment-attempts',
|
||||
'2',
|
||||
'--stream-segment-timeout',
|
||||
'20',
|
||||
'--stream-segmented-duration',
|
||||
'8',
|
||||
'--output',
|
||||
output,
|
||||
'https://www.twitch.tv/videos/1234567890',
|
||||
'worst'
|
||||
]);
|
||||
assert.throws(() => buildStreamlinkArguments('abc', output, 8), /VOD id/);
|
||||
assert.throws(() => buildStreamlinkArguments('1234567890', output, 61), /duration/);
|
||||
});
|
||||
|
||||
test('accepts only a bounded ffprobe-confirmed video artifact', () => {
|
||||
const result = validateMediaProbe({
|
||||
format: { duration: '8.25', size: '1048576' },
|
||||
streams: [{ codec_type: 'video', codec_name: 'h264' }, { codec_type: 'audio', codec_name: 'aac' }]
|
||||
}, 1048576);
|
||||
assert.deepEqual(result, { bytes: 1048576, codec: 'h264', durationSeconds: 8.25 });
|
||||
assert.throws(
|
||||
() => validateMediaProbe({ format: { duration: '0.4', size: '40' }, streams: [{ codec_type: 'audio', codec_name: 'aac' }] }, 40),
|
||||
/video stream/
|
||||
);
|
||||
assert.throws(
|
||||
() => validateMediaProbe({ format: { duration: '8', size: String(40 * 1024 * 1024) }, streams: [{ codec_type: 'video', codec_name: 'h264' }] }, 40 * 1024 * 1024),
|
||||
/32 MiB/
|
||||
);
|
||||
});
|
||||
|
||||
test('parses and pins the production GitHub release feed metadata', () => {
|
||||
const yaml = [
|
||||
'version: 1.0.18',
|
||||
'files:',
|
||||
' - url: Twitch-VOD-Manager-Setup-1.0.18.exe',
|
||||
` sha512: ${UPDATE_SHA512}`,
|
||||
' size: 120000000',
|
||||
'path: Twitch-VOD-Manager-Setup-1.0.18.exe',
|
||||
`sha512: ${UPDATE_SHA512}`,
|
||||
"releaseDate: '2026-08-13T10:00:00.000Z'"
|
||||
].join('\n');
|
||||
const metadata = parseLatestYaml(yaml);
|
||||
const result = validateProductionRelease(metadata, {
|
||||
expectedVersion: '1.0.18',
|
||||
expectedSha512: UPDATE_SHA512,
|
||||
latestTag: 'v1.0.18'
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
artifactName: 'Twitch-VOD-Manager-Setup-1.0.18.exe',
|
||||
artifactSize: 120000000,
|
||||
feedUrl: 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v1.0.18/',
|
||||
version: '1.0.18'
|
||||
});
|
||||
assert.throws(
|
||||
() => validateProductionRelease({ ...metadata, path: '../outside.exe' }, {
|
||||
expectedVersion: '1.0.18',
|
||||
expectedSha512: UPDATE_SHA512,
|
||||
latestTag: 'v1.0.18'
|
||||
}),
|
||||
/artifact path/
|
||||
);
|
||||
assert.throws(
|
||||
() => validateProductionRelease(metadata, {
|
||||
expectedVersion: '1.0.19',
|
||||
expectedSha512: UPDATE_SHA512,
|
||||
latestTag: 'v1.0.18'
|
||||
}),
|
||||
/version/
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves the public release tag to the pinned workflow commit', async () => {
|
||||
const dependencies = releaseInspectionDependencies();
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => { throw new Error('inspectProductionRelease bypassed its injected transport'); };
|
||||
try {
|
||||
const result = await inspectProductionRelease({
|
||||
expectedCommitSha: UPDATE_COMMIT_SHA,
|
||||
expectedSha512: UPDATE_SHA512,
|
||||
expectedVersion: PACKAGE_VERSION
|
||||
}, dependencies);
|
||||
assert.equal(result.commitSha, UPDATE_COMMIT_SHA);
|
||||
assert.deepEqual(dependencies.calls, [
|
||||
'https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest',
|
||||
`https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/commits/v${PACKAGE_VERSION}`,
|
||||
`https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/latest.yml`
|
||||
]);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a public release tag that resolves to another commit', async () => {
|
||||
const dependencies = releaseInspectionDependencies('c'.repeat(40));
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => { throw new Error('inspectProductionRelease bypassed its injected transport'); };
|
||||
try {
|
||||
await assert.rejects(
|
||||
inspectProductionRelease({
|
||||
expectedCommitSha: UPDATE_COMMIT_SHA,
|
||||
expectedSha512: UPDATE_SHA512,
|
||||
expectedVersion: PACKAGE_VERSION
|
||||
}, dependencies),
|
||||
/release tag commit/i
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('describes updater success as download-ready without claiming installation', () => {
|
||||
const result = createUpdaterDownloadReadySummary({
|
||||
artifact: { bytes: 120000000, sha512Verified: true },
|
||||
downloadedVersion: PACKAGE_VERSION,
|
||||
progressEvents: 4,
|
||||
release: {
|
||||
artifactName: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`,
|
||||
commitSha: UPDATE_COMMIT_SHA,
|
||||
feedUrl: `https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/`
|
||||
},
|
||||
source: { sourceInstallerVerified: true },
|
||||
sourceVersion: '0.0.1'
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
artifact: {
|
||||
bytes: 120000000,
|
||||
name: `Twitch-VOD-Manager-Setup-${PACKAGE_VERSION}.exe`,
|
||||
sha512Verified: true
|
||||
},
|
||||
feed: `https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download/v${PACKAGE_VERSION}/`,
|
||||
scope: 'download-ready',
|
||||
source: { installerDigestVerified: true, version: '0.0.1' },
|
||||
target: {
|
||||
commitSha: UPDATE_COMMIT_SHA,
|
||||
downloadReady: true,
|
||||
progressEvents: 4,
|
||||
version: PACKAGE_VERSION
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('independently hashes the downloaded PE artifact from the isolated updater cache', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-live-update-artifact-'));
|
||||
try {
|
||||
const artifact = path.join(root, 'Twitch-VOD-Manager-Setup-1.0.18.exe');
|
||||
const contents = Buffer.alloc(1024 * 1024, 9);
|
||||
contents[0] = 0x4d;
|
||||
contents[1] = 0x5a;
|
||||
fs.writeFileSync(artifact, contents);
|
||||
const expectedSha512 = nodeCrypto.createHash('sha512').update(contents).digest('base64');
|
||||
const result = await validateDownloadedReleaseArtifact(artifact, {
|
||||
artifactName: path.basename(artifact),
|
||||
artifactSize: contents.length,
|
||||
expectedSha512
|
||||
});
|
||||
assert.deepEqual(result, { bytes: contents.length, sha512Verified: true });
|
||||
|
||||
contents[0] = 0;
|
||||
fs.writeFileSync(artifact, contents);
|
||||
await assert.rejects(
|
||||
validateDownloadedReleaseArtifact(artifact, {
|
||||
artifactName: path.basename(artifact),
|
||||
artifactSize: contents.length,
|
||||
expectedSha512: nodeCrypto.createHash('sha512').update(contents).digest('base64')
|
||||
}),
|
||||
/Windows executable/
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires the packaged source to be older than the pinned release', () => {
|
||||
assert.equal(compareVersions('1.0.17', '1.0.18'), -1);
|
||||
assert.equal(compareVersions('1.0.18', '1.0.18'), 0);
|
||||
assert.equal(compareVersions('1.0.19', '1.0.18'), 1);
|
||||
for (const invalid of ['nightly', 'v1.0.18', '1.0.18-alpha', '1.0.18+build', '01.0.18', '1.00.18', '1.0.18.0', '1234567890.0.0']) {
|
||||
assert.throws(() => compareVersions(invalid, '1.0.18'), /invalid/);
|
||||
}
|
||||
});
|
||||
|
||||
test('binds the updater cache record to the pinned artifact and digest', () => {
|
||||
assert.deepEqual(validateUpdateCacheRecord({
|
||||
fileName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
|
||||
sha512: 'MkTghoBxIOnhP77tHV8szr8S1dbhItJId0atllZjWVrPwNLcvwnCyYjoUVEWV1czTqb5I+CUvqLiIjaamwglgw==',
|
||||
isAdminRightsRequired: false
|
||||
}, {
|
||||
artifactName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
|
||||
expectedSha512: 'MkTghoBxIOnhP77tHV8szr8S1dbhItJId0atllZjWVrPwNLcvwnCyYjoUVEWV1czTqb5I+CUvqLiIjaamwglgw=='
|
||||
}), {
|
||||
fileName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
|
||||
sha512Verified: true
|
||||
});
|
||||
assert.throws(() => validateUpdateCacheRecord({
|
||||
fileName: '..\\outside.exe',
|
||||
sha512: UPDATE_SHA512
|
||||
}, {
|
||||
artifactName: 'Twitch-VOD-Manager-Setup-1.0.17.exe',
|
||||
expectedSha512: UPDATE_SHA512
|
||||
}), /file name/);
|
||||
});
|
||||
|
||||
test('refuses cleanup at or outside the owned temporary root', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-live-contract-'));
|
||||
try {
|
||||
const child = path.join(root, 'owned');
|
||||
fs.mkdirSync(child);
|
||||
assert.equal(assertOwnedPath(child, root), path.resolve(child));
|
||||
assert.throws(() => assertOwnedPath(root, root), /outside/);
|
||||
assert.throws(() => assertOwnedPath(path.dirname(root), root), /outside/);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes the owned runner root after a successful operation', async () => {
|
||||
let ownedRoot = '';
|
||||
const result = await runWithOwnedRoot('tvm-live-success-', async (context) => {
|
||||
ownedRoot = context.ownedRoot;
|
||||
assert.equal(fs.existsSync(ownedRoot), true);
|
||||
fs.writeFileSync(path.join(ownedRoot, 'artifact.tmp'), 'owned');
|
||||
return 'completed';
|
||||
});
|
||||
assert.equal(result, 'completed');
|
||||
assert.equal(fs.existsSync(ownedRoot), false);
|
||||
});
|
||||
|
||||
test('removes the owned runner root when the operation fails', async () => {
|
||||
let ownedRoot = '';
|
||||
await assert.rejects(
|
||||
runWithOwnedRoot('tvm-live-failure-', async (context) => {
|
||||
ownedRoot = context.ownedRoot;
|
||||
fs.writeFileSync(path.join(ownedRoot, 'artifact.tmp'), 'owned');
|
||||
throw new Error('runner failed');
|
||||
}),
|
||||
/runner failed/
|
||||
);
|
||||
assert.equal(fs.existsSync(ownedRoot), false);
|
||||
});
|
||||
|
||||
test('accepts only the three explicit execution modes', () => {
|
||||
assert.equal(parseGateMode([]), 'all');
|
||||
assert.equal(parseGateMode(['twitch']), 'twitch');
|
||||
assert.equal(parseGateMode(['updater']), 'updater');
|
||||
assert.throws(() => parseGateMode(['local-feed']), /mode/);
|
||||
});
|
||||
|
||||
test('removes live credentials and injection variables from the packaged app environment', () => {
|
||||
const result = sanitizeChildEnvironment({
|
||||
PATH: 'C:\\Windows',
|
||||
SystemRoot: 'C:\\Windows',
|
||||
GITHUB_TOKEN: 'github-secret',
|
||||
HTTP_PROXY: ['http://user', ':', 'password', '@', 'proxy.example.test'].join(''),
|
||||
NODE_OPTIONS: '--require malicious.js',
|
||||
TWITCH_VOD_MANAGER_LIVE_TWITCH_CLIENT_SECRET: 'twitch-secret',
|
||||
SAFE_SETTING: 'kept'
|
||||
}, { LOCALAPPDATA: 'C:\\isolated' });
|
||||
assert.deepEqual(result, {
|
||||
LOCALAPPDATA: 'C:\\isolated',
|
||||
PATH: 'C:\\Windows',
|
||||
SYSTEMROOT: 'C:\\Windows'
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
function assertActionsWindowsCi(environment = process.env, platform = process.platform) {
|
||||
const serverUrl = String(environment.GITHUB_SERVER_URL || '').replace(/\/+$/, '').toLowerCase();
|
||||
const isGitHubActions = environment.GITHUB_ACTIONS === 'true' && environment.GITEA_ACTIONS !== 'true' && environment.RUNNER_ENVIRONMENT === 'github-hosted' && serverUrl === 'https://github.com';
|
||||
const isGiteaActions = environment.GITEA_ACTIONS === 'true' && serverUrl === 'https://git.24-music.de';
|
||||
if (platform !== 'win32' || environment.CI !== 'true' || environment.RUNNER_OS !== 'Windows' || !environment.RUNNER_TEMP || !environment.GITHUB_RUN_ID || (!isGitHubActions && !isGiteaActions)) {
|
||||
throw new Error('Live managed-tool smoke is restricted to an approved Windows Actions runner');
|
||||
}
|
||||
}
|
||||
|
||||
function assertPathInside(targetPath, parentPath) {
|
||||
const relative = path.win32.relative(path.win32.resolve(parentPath), path.win32.resolve(targetPath));
|
||||
if (!relative || relative === '..' || relative.startsWith('..\\') || path.win32.isAbsolute(relative)) {
|
||||
throw new Error(`Refusing cleanup outside the owned runner directory: ${targetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function findFileRecursive(directory, fileName) {
|
||||
if (!fs.existsSync(directory)) return '';
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) return entryPath;
|
||||
if (entry.isDirectory()) {
|
||||
const nested = findFileRecursive(entryPath, fileName);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function corruptInstalledTools(streamlinkDirectory, ffmpegDirectory) {
|
||||
const streamlinkPath = findFileRecursive(streamlinkDirectory, 'streamlink.exe');
|
||||
const ffmpegPath = findFileRecursive(ffmpegDirectory, 'ffmpeg.exe');
|
||||
if (!streamlinkPath || !ffmpegPath) throw new Error('Managed executables are missing before corruption check');
|
||||
fs.rmSync(streamlinkPath);
|
||||
fs.appendFileSync(ffmpegPath, 'corrupt');
|
||||
return { ffmpegPath, streamlinkPath };
|
||||
}
|
||||
|
||||
function runVersionCheck(executablePath, args, label) {
|
||||
const result = spawnSync(executablePath, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
windowsHide: true,
|
||||
maxBuffer: 4 * 1024 * 1024
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${label} version check failed: ${JSON.stringify({ status: result.status, stderr: result.stderr })}`);
|
||||
}
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim();
|
||||
if (!output) throw new Error(`${label} version check produced no output`);
|
||||
return output.split(/\r?\n/, 1)[0];
|
||||
}
|
||||
|
||||
function assertPinnedVersion(output, version, label) {
|
||||
if (!output.toLowerCase().includes(version.toLowerCase())) {
|
||||
throw new Error(`${label} version output does not match the pinned ${version}: ${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVerified(statuses, manifest, phase) {
|
||||
for (const id of ['streamlink', 'ffmpeg']) {
|
||||
if (!statuses[id]?.verified || statuses[id]?.state !== 'verified' || statuses[id]?.version !== manifest[id].version) {
|
||||
throw new Error(`${id} is not verified after ${phase}: ${JSON.stringify(statuses[id])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertActionsWindowsCi();
|
||||
const runnerTemp = process.env.RUNNER_TEMP;
|
||||
if (!runnerTemp || !path.win32.isAbsolute(runnerTemp) || !fs.statSync(runnerTemp).isDirectory()) {
|
||||
throw new Error(`Actions runner temp directory is invalid: ${runnerTemp || ''}`);
|
||||
}
|
||||
const smokeRoot = fs.mkdtempSync(path.join(runnerTemp, 'tvm-managed-tools-'));
|
||||
assertPathInside(smokeRoot, runnerTemp);
|
||||
|
||||
try {
|
||||
const streamlinkDirectory = path.join(smokeRoot, 'tools', 'streamlink');
|
||||
const ffmpegDirectory = path.join(smokeRoot, 'tools', 'ffmpeg');
|
||||
const temporaryDirectory = path.join(smokeRoot, 'temporary');
|
||||
fs.mkdirSync(temporaryDirectory, { recursive: true });
|
||||
const toolsPath = path.join(root, 'dist', 'tools.js');
|
||||
const manifestPath = path.join(root, 'dist', 'main', 'domain', 'tool-manifest.js');
|
||||
if (!fs.existsSync(toolsPath)) throw new Error('Build output is missing; run npm run build first');
|
||||
if (!fs.existsSync(manifestPath)) throw new Error('Built tool manifest is missing; run npm run build first');
|
||||
const tools = require(toolsPath);
|
||||
const { APPLICATION_TOOL_MANIFEST: manifest } = require(manifestPath);
|
||||
tools.initToolDirs(streamlinkDirectory, ffmpegDirectory, () => temporaryDirectory);
|
||||
|
||||
const initial = await tools.getManagedToolStatuses();
|
||||
if (initial.streamlink.state !== 'missing' || initial.ffmpeg.state !== 'missing') {
|
||||
throw new Error(`Clean managed-tool surface is not empty: ${JSON.stringify(initial)}`);
|
||||
}
|
||||
|
||||
const firstRepair = await tools.repairManagedTools();
|
||||
if (!firstRepair.success) throw new Error(`Initial managed-tool provisioning failed: ${JSON.stringify(firstRepair.statuses)}`);
|
||||
assertVerified(firstRepair.statuses, manifest, 'initial provisioning');
|
||||
const initialPaths = {
|
||||
streamlink: fs.realpathSync.native(tools.getStreamlinkPath()),
|
||||
ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()),
|
||||
ffprobe: fs.realpathSync.native(tools.getFFprobePath())
|
||||
};
|
||||
assertPathInside(initialPaths.streamlink, streamlinkDirectory);
|
||||
assertPathInside(initialPaths.ffmpeg, ffmpegDirectory);
|
||||
assertPathInside(initialPaths.ffprobe, ffmpegDirectory);
|
||||
const initialVersions = {
|
||||
streamlink: runVersionCheck(initialPaths.streamlink, ['--version'], 'Streamlink'),
|
||||
ffmpeg: runVersionCheck(initialPaths.ffmpeg, ['-version'], 'FFmpeg'),
|
||||
ffprobe: runVersionCheck(initialPaths.ffprobe, ['-version'], 'FFprobe')
|
||||
};
|
||||
assertPinnedVersion(initialVersions.streamlink, manifest.streamlink.version, 'Streamlink');
|
||||
assertPinnedVersion(initialVersions.ffmpeg, manifest.ffmpeg.version, 'FFmpeg');
|
||||
assertPinnedVersion(initialVersions.ffprobe, manifest.ffmpeg.version, 'FFprobe');
|
||||
|
||||
corruptInstalledTools(streamlinkDirectory, ffmpegDirectory);
|
||||
tools.invalidateVerifiedToolCaches();
|
||||
const damaged = await tools.getManagedToolStatuses();
|
||||
if (damaged.streamlink.state !== 'corrupt' || damaged.ffmpeg.state !== 'corrupt') {
|
||||
throw new Error(`Damaged managed tools were not detected: ${JSON.stringify(damaged)}`);
|
||||
}
|
||||
|
||||
const secondRepair = await tools.repairManagedTools();
|
||||
if (!secondRepair.success) throw new Error(`Managed-tool repair failed: ${JSON.stringify(secondRepair.statuses)}`);
|
||||
assertVerified(secondRepair.statuses, manifest, 'corruption repair');
|
||||
const repairedPaths = {
|
||||
streamlink: fs.realpathSync.native(tools.getStreamlinkPath()),
|
||||
ffmpeg: fs.realpathSync.native(tools.getFFmpegPath()),
|
||||
ffprobe: fs.realpathSync.native(tools.getFFprobePath())
|
||||
};
|
||||
assertPathInside(repairedPaths.streamlink, streamlinkDirectory);
|
||||
assertPathInside(repairedPaths.ffmpeg, ffmpegDirectory);
|
||||
assertPathInside(repairedPaths.ffprobe, ffmpegDirectory);
|
||||
const repairedVersions = {
|
||||
streamlink: runVersionCheck(repairedPaths.streamlink, ['--version'], 'Repaired Streamlink'),
|
||||
ffmpeg: runVersionCheck(repairedPaths.ffmpeg, ['-version'], 'Repaired FFmpeg'),
|
||||
ffprobe: runVersionCheck(repairedPaths.ffprobe, ['-version'], 'Repaired FFprobe')
|
||||
};
|
||||
assertPinnedVersion(repairedVersions.streamlink, manifest.streamlink.version, 'Repaired Streamlink');
|
||||
assertPinnedVersion(repairedVersions.ffmpeg, manifest.ffmpeg.version, 'Repaired FFmpeg');
|
||||
assertPinnedVersion(repairedVersions.ffprobe, manifest.ffmpeg.version, 'Repaired FFprobe');
|
||||
|
||||
console.log(JSON.stringify({ failures: [], initialVersions, repairedVersions }, null, 2));
|
||||
} finally {
|
||||
assertPathInside(smokeRoot, runnerTemp);
|
||||
await fs.promises.rm(smokeRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 250 });
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertActionsWindowsCi,
|
||||
assertPathInside,
|
||||
corruptInstalledTools,
|
||||
findFileRecursive
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
assertActionsWindowsCi,
|
||||
assertPathInside,
|
||||
corruptInstalledTools,
|
||||
findFileRecursive
|
||||
} = require('./smoke-test-managed-tools-live');
|
||||
|
||||
test('accepts GitHub and Gitea Windows Actions while rejecting local opt-in', () => {
|
||||
assert.doesNotThrow(() => assertActionsWindowsCi({ CI: 'true', GITHUB_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://github.com', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '123' }, 'win32'));
|
||||
assert.doesNotThrow(() => assertActionsWindowsCi({ CI: 'true', GITEA_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://git.24-music.de', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'));
|
||||
assert.throws(() => assertActionsWindowsCi({ CI: 'true', RUNNER_OS: 'Windows' }, 'win32'), /Windows Actions runner/);
|
||||
assert.throws(() => assertActionsWindowsCi({ TWITCH_VOD_MANAGER_MANAGED_TOOLS_LIVE: '1' }, 'win32'), /Windows Actions runner/);
|
||||
assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITHUB_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://ci.example.test', RUNNER_ENVIRONMENT: 'github-hosted', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '123' }, 'win32'), /Windows Actions runner/);
|
||||
assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITEA_ACTIONS: 'true', GITHUB_SERVER_URL: 'https://other.example.test', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'), /Windows Actions runner/);
|
||||
assert.throws(() => assertActionsWindowsCi({ CI: 'true', GITHUB_SERVER_URL: 'https://git.24-music.de', RUNNER_OS: 'Windows', RUNNER_TEMP: 'C:\\runner-temp', GITHUB_RUN_ID: '456' }, 'win32'), /Windows Actions runner/);
|
||||
});
|
||||
|
||||
test('rejects cleanup targets outside the owned runner directory', () => {
|
||||
assert.doesNotThrow(() => assertPathInside('C:\\runner\\temp\\managed-tools-1', 'C:\\runner\\temp'));
|
||||
assert.throws(() => assertPathInside('C:\\runner\\other', 'C:\\runner\\temp'), /outside/);
|
||||
assert.throws(() => assertPathInside('C:\\runner\\temp', 'C:\\runner\\temp'), /outside/);
|
||||
});
|
||||
|
||||
test('damages both managed installations without touching unrelated files', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-managed-tools-contract-'));
|
||||
try {
|
||||
const streamlinkDirectory = path.join(root, 'streamlink', 'bin');
|
||||
const ffmpegDirectory = path.join(root, 'ffmpeg', 'bin');
|
||||
fs.mkdirSync(streamlinkDirectory, { recursive: true });
|
||||
fs.mkdirSync(ffmpegDirectory, { recursive: true });
|
||||
const streamlinkPath = path.join(streamlinkDirectory, 'streamlink.exe');
|
||||
const ffmpegPath = path.join(ffmpegDirectory, 'ffmpeg.exe');
|
||||
const ffprobePath = path.join(ffmpegDirectory, 'ffprobe.exe');
|
||||
fs.writeFileSync(streamlinkPath, 'streamlink');
|
||||
fs.writeFileSync(ffmpegPath, 'ffmpeg');
|
||||
fs.writeFileSync(ffprobePath, 'ffprobe');
|
||||
|
||||
const damaged = corruptInstalledTools(path.join(root, 'streamlink'), path.join(root, 'ffmpeg'));
|
||||
|
||||
assert.equal(fs.existsSync(streamlinkPath), false);
|
||||
assert.equal(fs.readFileSync(ffmpegPath, 'utf8'), 'ffmpegcorrupt');
|
||||
assert.equal(fs.readFileSync(ffprobePath, 'utf8'), 'ffprobe');
|
||||
assert.deepEqual(damaged, { ffmpegPath, streamlinkPath });
|
||||
assert.equal(findFileRecursive(path.join(root, 'ffmpeg'), 'ffprobe.exe'), ffprobePath);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createRequire } = require('module');
|
||||
|
||||
const { Minimatch } = createRequire(require.resolve('app-builder-lib/package.json'))('minimatch');
|
||||
|
||||
const root = process.cwd();
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
@@ -10,43 +13,77 @@ const installerSource = fs.readFileSync(path.join(root, 'build', 'installer.nsh'
|
||||
const installerSmokeSource = fs.readFileSync(path.join(root, 'scripts', 'smoke-test-installer.js'), 'utf8');
|
||||
const manifestPath = path.join(root, 'scripts', 'public-release-files.json');
|
||||
const failures = [];
|
||||
const expectedVersion = '1.0.18';
|
||||
|
||||
function check(condition, message) {
|
||||
if (!condition) failures.push(message);
|
||||
}
|
||||
|
||||
check(packageJson.version === '1.0.17', `package version is ${packageJson.version}`);
|
||||
check(packageLock.version === '1.0.17', `lockfile version is ${packageLock.version}`);
|
||||
check(packageLock.packages?.['']?.version === '1.0.17', `lockfile root package version is ${packageLock.packages?.['']?.version}`);
|
||||
check(packageJson.version === expectedVersion, `package version is ${packageJson.version}`);
|
||||
check(packageLock.version === expectedVersion, `lockfile version is ${packageLock.version}`);
|
||||
check(packageLock.packages?.['']?.version === expectedVersion, `lockfile root package version is ${packageLock.packages?.['']?.version}`);
|
||||
check(packageJson.build?.appId === 'io.github.sucukdeluxe.twitch-vod-manager', `appId is ${packageJson.build?.appId}`);
|
||||
check(packageJson.build?.publish?.provider === 'generic', `publish provider is ${packageJson.build?.publish?.provider}`);
|
||||
check(packageJson.build?.publish?.url === 'https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/latest/download/', `publish URL is ${packageJson.build?.publish?.url}`);
|
||||
for (const pattern of ['!dist/**/*.test.js', '!node_modules/better-sqlite3/build/**', '!node_modules/better-sqlite3/deps/**', '!node_modules/better-sqlite3/src/**']) {
|
||||
const packagedDistExclusions = [
|
||||
'!dist/**/*.test.js',
|
||||
'!dist/main/dev-executable.js',
|
||||
'!dist/main/index.js',
|
||||
'!dist/types.js'
|
||||
];
|
||||
const packagedDependencyExclusions = [
|
||||
'!node_modules/better-sqlite3/build/**',
|
||||
'!node_modules/better-sqlite3/deps/**',
|
||||
'!node_modules/better-sqlite3/src/**',
|
||||
'!node_modules/{agent-base,axios,builder-util-runtime,electron-updater,https-proxy-agent,js-yaml,lazy-val}/**/*.map',
|
||||
'!node_modules/agent-base/src/{index,promisify}.ts',
|
||||
'!node_modules/{call-bind-apply-helpers,dunder-proto,es-define-property,es-set-tostringtag,function-bind,get-intrinsic,get-proto,has-symbols,has-tostringtag,hasown}/.nycrc',
|
||||
'!node_modules/delayed-stream/Makefile',
|
||||
'!node_modules/node-addon-api/{common,except,noexcept}.gypi',
|
||||
'!node_modules/node-addon-api/{node_addon_api,node_api}.gyp',
|
||||
'!node_modules/node-addon-api/nothing.c',
|
||||
'!node_modules/node-addon-api/{napi-inl.deprecated,napi-inl,napi}.h',
|
||||
'!node_modules/better-sqlite3/prebuilds/{darwin-arm64,darwin-x64,linux-arm64,linux-x64,linuxmusl-arm64,linuxmusl-x64,win32-arm64}.node'
|
||||
];
|
||||
const packagedFileExclusions = [...packagedDistExclusions, ...packagedDependencyExclusions];
|
||||
for (const pattern of packagedFileExclusions) {
|
||||
check(packageJson.build?.files?.includes(pattern), `missing packaged file exclusion: ${pattern}`);
|
||||
}
|
||||
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', '!dist/**/*.test.js', 'src/index.html', 'src/styles.css', 'src/workspace.css', 'build/icon.png', 'package.json', '!node_modules/better-sqlite3/build/**', '!node_modules/better-sqlite3/deps/**', '!node_modules/better-sqlite3/src/**']), 'packaged file list is not restricted');
|
||||
const productionStyles = ['styles.css', 'styles-workflows.css', 'styles-overlays.css', 'workspace.css', 'workspace-refinements.css'];
|
||||
check(JSON.stringify(packageJson.build?.files) === JSON.stringify(['dist/**/*', ...packagedDistExclusions, 'src/index.html', ...productionStyles.map((fileName) => `src/${fileName}`), 'build/icon.png', 'package.json', ...packagedDependencyExclusions]), 'packaged file list is not restricted');
|
||||
const requiredWindowsSqlitePrebuild = 'node_modules/better-sqlite3/prebuilds/win32-x64.node';
|
||||
const matchingWindowsSqliteExclusions = packageJson.build?.files?.filter((pattern) => typeof pattern === 'string' && pattern.startsWith('!node_modules/') && new Minimatch(pattern.slice(1), { dot: true }).match(requiredWindowsSqlitePrebuild)) || [];
|
||||
check(matchingWindowsSqliteExclusions.length === 0, `required win32-x64 better-sqlite3 prebuild is excluded by: ${matchingWindowsSqliteExclusions.join(', ')}`);
|
||||
const linkedStyles = Array.from(indexSource.matchAll(/<link rel="stylesheet" href="\.\/([^"?]+)"/g), (match) => match[1]);
|
||||
check(JSON.stringify(linkedStyles) === JSON.stringify(productionStyles), `production stylesheet order is ${linkedStyles.join(', ')}`);
|
||||
check(packageJson.build?.win?.icon === 'build/icon.ico', `Windows icon is ${packageJson.build?.win?.icon}`);
|
||||
check(packageJson.build?.nsis?.installerIcon === 'build/icon.ico', `installer icon is ${packageJson.build?.nsis?.installerIcon}`);
|
||||
check(packageJson.build?.nsis?.uninstallerIcon === 'build/icon.ico', `uninstaller icon is ${packageJson.build?.nsis?.uninstallerIcon}`);
|
||||
check(packageJson.build?.nsis?.shortcutName === 'Twitch VOD Manager', `Windows Start Menu shortcut is not stable: ${packageJson.build?.nsis?.shortcutName}`);
|
||||
check(installerSource.includes('!macro preInit'), 'installer does not recover from orphaned Windows registration before upgrade detection');
|
||||
check(installerSource.includes('ReadRegStr $0 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation'), 'installer does not read the existing per-user install location before upgrade detection');
|
||||
check(installerSource.includes('${ifNot} ${FileExists} "$0\\${APP_EXECUTABLE_FILENAME}"'), 'installer does not detect a missing executable in an existing per-user registration');
|
||||
check(installerSource.includes('DeleteRegKey HKCU "${INSTALL_REGISTRY_KEY}"') && installerSource.includes('DeleteRegKey HKCU "${UNINSTALL_REGISTRY_KEY}"'), 'installer does not clear orphaned per-user registration before upgrade detection');
|
||||
check(installerSource.includes('!macro removeOrphanedRegistration ROOT'), 'installer does not centralize orphaned registration cleanup');
|
||||
check(installerSource.includes('ReadRegStr $0 ${ROOT} "${INSTALL_REGISTRY_KEY}" InstallLocation'), 'installer does not read an existing install location before upgrade detection');
|
||||
check(installerSource.includes('${if} $0 == ""') && installerSource.includes('${orIfNot} ${FileExists} "$0\\${APP_EXECUTABLE_FILENAME}"'), 'installer does not detect incomplete or missing orphaned installations');
|
||||
check(installerSource.includes('DeleteRegKey ${ROOT} "${INSTALL_REGISTRY_KEY}"') && installerSource.includes('DeleteRegKey ${ROOT} "${UNINSTALL_REGISTRY_KEY}"'), 'installer does not clear orphaned install and uninstall registration together');
|
||||
check(installerSource.includes('!insertmacro removeOrphanedRegistration HKCU') && installerSource.includes('!insertmacro removeOrphanedRegistration HKLM'), 'installer does not clear orphaned registration in both Windows installation scopes');
|
||||
check(installerSource.includes('!ifndef BUILD_UNINSTALLER') && installerSource.includes('!insertmacro check64BitAndSetRegView'), 'orphan cleanup can run during uninstaller generation or against the wrong registry view');
|
||||
const shortcutIconResource = packageJson.build?.extraResources?.find((entry) => entry?.from === 'build/icon.ico');
|
||||
check(shortcutIconResource?.to === 'app-icons/icon-${version}.ico', `versioned shortcut icon resource is ${shortcutIconResource?.to}`);
|
||||
check(installerSource.includes('"$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons\\icon-${VERSION}.ico"'), 'installed shortcuts do not use the persistent versioned icon resource');
|
||||
check(installerSource.includes('CopyFiles /SILENT "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'versioned shortcut icon is not copied to persistent storage');
|
||||
check(installerSource.includes('StrCpy $0 "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'installed shortcuts do not use the versioned icon in the selected installation scope');
|
||||
check(!installerSource.includes('CopyFiles /SILENT "$INSTDIR\\resources\\app-icons\\icon-${VERSION}.ico"'), 'versioned shortcut icon is copied into a user-private location');
|
||||
check(!installerSource.includes('CreateDirectory "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'installer creates user-private shortcut resources that break all-users installations');
|
||||
check(installerSource.includes('CreateShortCut "$newDesktopLink"'), 'desktop shortcut is not refreshed with the versioned icon resource');
|
||||
check(installerSource.includes('CreateShortCut "$newStartMenuLink"'), 'start menu shortcut is not refreshed with the versioned icon resource');
|
||||
check(installerSource.includes('Delete "$SMPROGRAMS\\Twitch VOD Manager v*.lnk"'), 'legacy versioned Start Menu shortcuts are not removed during upgrade');
|
||||
const stableStartShortcutBlock = installerSource.match(/Delete "\$SMPROGRAMS\\Twitch VOD Manager v\*\.lnk"([\s\S]*?)System::Call 'shell32::SHChangeNotify\(i 0x00001000/);
|
||||
check(Boolean(stableStartShortcutBlock) && !stableStartShortcutBlock[1].includes('${if} ${FileExists} "$newStartMenuLink"'), 'stable Start Menu shortcut is not recreated when an older installer did not register it');
|
||||
check(installerSource.includes('SHChangeNotify(i 0x00001000, i 0x0005, w "$SMPROGRAMS"'), 'Windows Start Menu is not notified after shortcut refresh');
|
||||
check(installerSmokeSource.includes("'/currentuser'"), 'installer smoke does not force a per-user test installation');
|
||||
check(installerSmokeSource.includes("flag: '/currentuser'") && installerSmokeSource.includes("flag: '/allusers'"), 'installer smoke does not cover current-user and all-users installations sequentially');
|
||||
check(installerSmokeSource.includes('assertCleanInstallerSmokeSurface'), 'installer smoke can run against an existing workstation installation');
|
||||
check(installerSmokeSource.includes('GITHUB_ACTIONS') && installerSmokeSource.includes('GITEA_ACTIONS') && installerSmokeSource.includes('https://github.com') && installerSmokeSource.includes('https://git.24-music.de') && !installerSmokeSource.includes('TWITCH_VOD_MANAGER_INSTALLER_SMOKE'), 'real installer smoke is not restricted to approved GitHub and Gitea Windows runners');
|
||||
check(installerSmokeSource.includes('assertInstalledRegistration(phase') && installerSmokeSource.includes('assertShortcutDetails('), 'installer smoke does not verify registry mode and shortcut target/icon');
|
||||
check(installerSource.includes('SHChangeNotify(i 0x08000000, i 0x1000'), 'Windows shell icon cache is not flushed after shortcut refresh');
|
||||
check(installerSource.includes('${ifNot} ${isUpdated}') && installerSource.includes('RMDir /r "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'persistent shortcut icons are not cleaned up on a real uninstall');
|
||||
check(installerSource.includes('${ifNot} ${isUpdated}') && installerSource.includes('RMDir /r "$LOCALAPPDATA\\Twitch VOD Manager\\Shortcut Icons"'), 'legacy user-private shortcut icons are not cleaned up on a real uninstall');
|
||||
check(packageJson.build?.win?.signAndEditExecutable !== false, 'Windows executable resource editing is enabled');
|
||||
check(packageJson.build?.win?.signExecutable !== false, 'Windows executable signing is disabled');
|
||||
check(fs.existsSync(path.join(root, 'build', 'icon.png')), 'application PNG icon is missing');
|
||||
@@ -65,7 +102,7 @@ check(mainSource.includes('GITHUB_RELEASES_DOWNLOAD_BASE_URL'), 'GitHub releases
|
||||
check(mainSource.includes('https://api.github.com/repos/Sucukdeluxe/Twitch-VOD-Manager/releases/latest'), 'GitHub latest release API URL is missing');
|
||||
check(mainSource.includes('https://github.com/Sucukdeluxe/Twitch-VOD-Manager/releases/download'), 'GitHub release download URL is missing');
|
||||
check(!/storyboards\/\d{8,12}(?:-|\/)/.test(mainSource), 'numeric Twitch VOD example remains in the public source');
|
||||
check(indexSource.includes('Version: v1.0.17'), 'initial version label is not 1.0.17');
|
||||
check(indexSource.includes(`Version: v${expectedVersion}`), `initial version label is not ${expectedVersion}`);
|
||||
check(!indexSource.includes('Version: v4.1.13'), 'legacy version label is still present');
|
||||
check(fs.existsSync(manifestPath), 'public release manifest is missing');
|
||||
|
||||
@@ -73,7 +110,10 @@ if (fs.existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const entries = Array.isArray(manifest.files) ? manifest.files : [];
|
||||
const normalizedEntries = entries.map((entry) => entry.replace(/\\/g, '/').replace(/\/$/, ''));
|
||||
const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl)$/i;
|
||||
const forbiddenReleasePath = /(?:^|\/)(?:\.claude|\.codex|\.superpowers|superpowers|tasks?|memories?|prompts?|artifacts?|logs?|backups?)(?:\/|$)|(?:^|\/)(?:AGENTS|CLAUDE)\.md$|\.(?:db|sqlite|sqlite3|log|bak|backup|zip|7z|rar|exe|msi|jsonl|patch)$/i;
|
||||
for (const candidate of ['docs/superpowers/internal.md', 'task6-main-selective.patch']) {
|
||||
check(forbiddenReleasePath.test(candidate), `forbidden path guard misses ${candidate}`);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(root, entry);
|
||||
check(fs.existsSync(absolutePath), `public release entry does not exist: ${entry}`);
|
||||
|
||||
@@ -477,6 +477,11 @@ async function run() {
|
||||
check(cutterDropUi.filePath === path.basename(cutterDropFixturePath), `Cutter drop displayed "${cutterDropUi.filePath}" instead of the safe file name`);
|
||||
check(typeof cutterDropPaths.mediaCapability === 'string' && cutterDropPaths.mediaCapability.length >= 32 && cutterDropPaths.mediaCapability !== cutterDropFixturePath, `Cutter drop did not send an opaque capability to media preparation: ${JSON.stringify(cutterDropPaths)}`);
|
||||
check(cutterDropUi.infoVisible && cutterDropUi.cutEnabled, 'Cutter drop did not populate the cutter controls');
|
||||
checks.cutterDrop.fixtureInputRemoved = await win.evaluate(() => {
|
||||
document.getElementById('workspaceCutterDropInput')?.remove();
|
||||
return document.getElementById('workspaceCutterDropInput') === null;
|
||||
});
|
||||
check(checks.cutterDrop.fixtureInputRemoved, 'Cutter drop fixture input remains visible in later workspace states');
|
||||
|
||||
const queueEmptyActions = await win.evaluate(() => ({
|
||||
count: document.getElementById('queueCount')?.textContent?.trim() || '',
|
||||
@@ -644,6 +649,7 @@ async function run() {
|
||||
const changelogClosed = await captureUpdateChangelog();
|
||||
await win.evaluate(() => dismissUpdateModal());
|
||||
await win.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await win.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
checks.updateChangelogMotion = {
|
||||
collapsed: changelogCollapsed,
|
||||
opening: changelogOpening,
|
||||
@@ -1453,7 +1459,7 @@ async function run() {
|
||||
document.body.textContent || '',
|
||||
...[...document.querySelectorAll('[title], [placeholder], [aria-label]')].flatMap((element) => [element.getAttribute('title') || '', element.getAttribute('placeholder') || '', element.getAttribute('aria-label') || ''])
|
||||
].join('\n').toLocaleLowerCase('de-DE');
|
||||
const forbidden = ['verfugbar', 'uberspringen', 'fur ', 'hinzufugen', 'hinzufuegen', 'schliessen', 'auswahlen', 'auswaehlen', 'auflosung', 'zusammenfugen', 'wahle ', 'uebersicht', 'groesse', 'groessen', 'aelteste', 'qualitaet', 'waehrend', 'loeschen', 'nuetzlich', 'geprueft', 'geraet', 'zurueck', 'ausfuehren', 'wuerde', 'aelter', 'eintraege', 'oeffnen', 'ungueltig', 'kuerzere', 'gleichmaessig', 'einfuegereihenfolge', 'noetig', 'behaelt', 'faellt', 'laeuft', 'gekuerzt', 'ausserhalb', 'fliessen', 'grosser', 'aktivitaet', 'gruene', 'laengste', 'kuerzeste', 'zugehoerige'];
|
||||
const forbidden = ['verfugbar', 'uberspringen', 'fur ', 'hinzufugen', 'hinzufuegen', 'schliessen', 'auswahlen', 'auswaehlen', 'ausgewahlt', 'auflosung', 'zusammenfugen', 'zusammengefugt', 'wahle ', 'uebersicht', 'groesse', 'groessen', 'aelteste', 'qualitaet', 'waehrend', 'loeschen', 'nuetzlich', 'geprueft', 'geraet', 'zurueck', 'ausfuehren', 'wuerde', 'aelter', 'eintraege', 'offnen', 'oeffnen', 'ungultig', 'ungueltig', 'kuerzere', 'gleichmaessig', 'einfuegereihenfolge', 'noetig', 'behaelt', 'faellt', 'lauft', 'laeuft', 'gekuerzt', 'ausserhalb', 'fliessen', 'grosser', 'aktivitaet', 'gruene', 'laengste', 'kuerzeste', 'zugehoerige', 'aenderungen', 'oeffnet', 'unterstutzte', 'teil-lange', 'aufraumen', 'prufung', 'stabilitat', 'integritat'];
|
||||
return {
|
||||
localeMatches: forbidden.filter((token) => localeText.includes(token)),
|
||||
domMatches: forbidden.filter((token) => domText.includes(token))
|
||||
|
||||
Reference in New Issue
Block a user