Files
Twitch-VOD-Manager/scripts/smoke-test-live-integration.js
T
Sucukdeluxe 9f1b052afd 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.
2026-08-13 22:59:15 +02:00

744 lines
32 KiB
JavaScript

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
};