#!/usr/bin/env node // SPDX-License-Identifier: AGPL-3.0-or-later import {spawn} from 'node:child_process'; import {existsSync} from 'node:fs'; import {createRequire} from 'node:module'; import {dirname, join} from 'node:path'; import {setTimeout as delay} from 'node:timers/promises'; import {fileURLToPath} from 'node:url'; const require = createRequire(import.meta.url); const capture = require('../index.js'); const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const DEFAULT_FIXTURES = ['d3d11-present-fixture']; const ALL_FIXTURES = [ 'd3d9-present-fixture', 'd3d10-present-fixture', 'd3d11-present-fixture', 'd3d12-present-fixture', 'opengl-swapbuffers-fixture', 'vulkan-present-fixture', ]; const WIDTH = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_WIDTH ?? '640', 10); const HEIGHT = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_HEIGHT ?? '360', 10); const FRAME_RATE = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_FPS ?? '30', 10); const START_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_START_TIMEOUT_MS ?? '15000', 10); const FRAME_TIMEOUT_MS = Number.parseInt(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_FRAME_TIMEOUT_MS ?? '15000', 10); const TRANSPORT_MEMORY = 0; const TRANSPORT_SHARED_TEXTURE = 1; const API_OPENGL = 1; const API_D3D9 = 3; const API_D3D10 = 4; const API_D3D11 = 5; const API_D3D12 = 6; const API_VULKAN = 7; const FALLBACK_NONE = 0; const FALLBACK_SHARED_TEXTURE_UNSUPPORTED = 1; const EXPECTED_DIAGNOSTICS = { 'd3d9-present-fixture': { apiType: API_D3D9, transport: TRANSPORT_SHARED_TEXTURE, fallbackReason: FALLBACK_NONE, requiresDxgiFormat: true, }, 'd3d10-present-fixture': { apiType: API_D3D10, transport: TRANSPORT_SHARED_TEXTURE, fallbackReason: FALLBACK_NONE, requiresDxgiFormat: true, }, 'd3d11-present-fixture': { apiType: API_D3D11, transport: TRANSPORT_SHARED_TEXTURE, fallbackReason: FALLBACK_NONE, requiresDxgiFormat: true, }, 'd3d12-present-fixture': { apiType: API_D3D12, transport: TRANSPORT_SHARED_TEXTURE, fallbackReason: FALLBACK_NONE, requiresDxgiFormat: true, }, 'opengl-swapbuffers-fixture': { apiType: API_OPENGL, transportOneOf: [TRANSPORT_SHARED_TEXTURE, TRANSPORT_MEMORY], fallbackReasonOneOf: [FALLBACK_NONE, FALLBACK_SHARED_TEXTURE_UNSUPPORTED], requiresDxgiFormatWhenShared: true, }, 'vulkan-present-fixture': { apiType: API_VULKAN, transport: TRANSPORT_SHARED_TEXTURE, fallbackReason: FALLBACK_NONE, requiresDxgiFormat: true, }, 'i686-present-fixture': { apiType: API_D3D11, transport: TRANSPORT_SHARED_TEXTURE, fallbackReason: FALLBACK_NONE, requiresDxgiFormat: true, }, }; function envFlag(name) { return /^(1|true|yes|on)$/i.test(process.env[name] ?? ''); } function selectedFixtures() { const raw = process.argv.slice(2).join(',') || process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURES || ''; if (!raw.trim()) return DEFAULT_FIXTURES; const names = raw .split(',') .map((name) => name.trim()) .filter(Boolean); return names.flatMap((name) => (name === 'all' ? ALL_FIXTURES : [name])); } function fixtureTarget(fixture) { return fixture === 'i686-present-fixture' ? 'i686-pc-windows-msvc' : null; } function fixtureExePath(fixture) { const target = fixtureTarget(fixture); const targetDir = target ? join(ROOT, 'test-apps', fixture, 'target', target, 'release') : join(ROOT, 'test-apps', fixture, 'target', 'release'); return join(targetDir, `${fixture}.exe`); } function envKeyForFixture(fixture) { return `FLUXER_WIN_GAME_CAPTURE_FIXTURE_ARGS_${fixture.toUpperCase().replaceAll(/[^A-Z0-9]+/g, '_')}`; } function fixtureEnvValue(baseName, fixture) { const fixtureKey = `${baseName}_${fixture.toUpperCase().replaceAll(/[^A-Z0-9]+/g, '_')}`; return process.env[fixtureKey] ?? process.env[baseName]; } function splitExtraArgs(raw) { return (raw ?? '') .split(/\s+/) .map((arg) => arg.trim()) .filter(Boolean); } function extraFixtureArgs(fixture) { return [ ...splitExtraArgs(process.env.FLUXER_WIN_GAME_CAPTURE_FIXTURE_ARGS), ...splitExtraArgs(process.env[envKeyForFixture(fixture)]), ]; } function diagnosticOverride(baseName, fixture) { const raw = fixtureEnvValue(baseName, fixture); if (raw === undefined || raw === '') return undefined; const value = Number.parseInt(raw, 10); if (!Number.isFinite(value)) throw new Error(`Invalid ${baseName} override: ${raw}`); return value; } function runCommand(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: options.cwd ?? ROOT, env: process.env, stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], windowsHide: false, }); let stdout = ''; let stderr = ''; child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); if (options.echo) process.stdout.write(chunk); }); child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); if (options.echo) process.stderr.write(chunk); }); child.once('error', reject); child.once('exit', (code, signal) => { if (code === 0) { resolve({stdout, stderr}); return; } reject(new Error(`${command} ${args.join(' ')} failed with ${signal ?? code}\n${stderr || stdout}`)); }); }); } async function buildFixture(fixture) { const manifest = join(ROOT, 'test-apps', fixture, 'Cargo.toml'); if (!existsSync(manifest)) throw new Error(`unknown fixture: ${fixture}`); const exe = fixtureExePath(fixture); if (envFlag('FLUXER_WIN_GAME_CAPTURE_FIXTURE_SKIP_BUILD') && existsSync(exe)) { console.log(`[fixture-smoke] using existing ${fixture}`); return exe; } const args = ['build', '--release', '--manifest-path', manifest]; const target = fixtureTarget(fixture); if (target) args.push('--target', target); console.log(`[fixture-smoke] building ${fixture}`); await runCommand('cargo', args, {echo: envFlag('FLUXER_WIN_GAME_CAPTURE_FIXTURE_VERBOSE')}); if (!existsSync(exe)) throw new Error(`fixture build did not produce ${exe}`); return exe; } function waitForHwnd(child, fixture) { return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; const timeout = setTimeout(() => { reject(new Error(`${fixture} did not print HWND within ${START_TIMEOUT_MS}ms\n${stderr || stdout}`)); }, START_TIMEOUT_MS); const finish = (hwnd) => { clearTimeout(timeout); resolve(hwnd); }; child.stdout.on('data', (chunk) => { const text = chunk.toString(); stdout += text; const match = stdout.match(/HWND=(\d+)/); if (match) finish(match[1]); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); if (envFlag('FLUXER_WIN_GAME_CAPTURE_FIXTURE_VERBOSE')) process.stderr.write(chunk); }); child.once('exit', (code, signal) => { clearTimeout(timeout); reject(new Error(`${fixture} exited before capture started (${signal ?? code})\n${stderr || stdout}`)); }); }); } function frameSignature(frame) { const bytes = frame.data; const width = Math.max(1, frame.width); const height = Math.max(1, frame.height); const stride = Math.max(1, frame.strideY || (frame.format === 'bgra' ? width * 4 : width)); const rows = frame.format === 'bgra' ? Math.min(height, 12) : Math.min(height, 32); let hash = 2166136261; for (let y = 0; y < rows; y += 1) { const row = y * stride; const rowBytes = frame.format === 'bgra' ? Math.min(stride, width * 4, 256) : Math.min(stride, width, 256); for (let x = 0; x < rowBytes; x += 4) { hash ^= bytes[row + x] ?? 0; hash = Math.imul(hash, 16777619) >>> 0; } } return hash >>> 0; } async function waitForAdvancingFrames(screenCapture, fixture) { const signatures = new Set(); let frameCount = 0; let lastDiagnostics = null; const onFrame = (frame) => { frameCount += 1; signatures.add(frameSignature(frame)); lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics; }; screenCapture.on('frame', onFrame); const start = Date.now(); try { while (Date.now() - start < FRAME_TIMEOUT_MS) { lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics; const nativeFrames = Number(lastDiagnostics?.frameCounter ?? 0); if (frameCount >= 3 && (signatures.size >= 2 || nativeFrames >= 3)) { return {frameCount, signatures: signatures.size, diagnostics: lastDiagnostics}; } await delay(100); } throw new Error( `${fixture} capture did not deliver advancing frames within ${FRAME_TIMEOUT_MS}ms (frames=${frameCount}, signatures=${signatures.size}, diagnostics=${JSON.stringify(lastDiagnostics)})`, ); } finally { screenCapture.off('frame', onFrame); } } function assertEqualDiagnostic(fixture, diagnostics, key, expected) { if (diagnostics?.[key] !== expected) { throw new Error(`${fixture} expected diagnostics.${key}=${expected}, got ${JSON.stringify(diagnostics)}`); } } function assertOneOfDiagnostic(fixture, diagnostics, key, expected) { if (!expected.includes(diagnostics?.[key])) { throw new Error( `${fixture} expected diagnostics.${key} in [${expected.join(', ')}], got ${JSON.stringify(diagnostics)}`, ); } } function assertFixtureDiagnostics(fixture, diagnostics) { const expectedBase = EXPECTED_DIAGNOSTICS[fixture]; if (!expectedBase) return; const expected = {...expectedBase}; const apiOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_API_TYPE', fixture); const transportOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_TRANSPORT', fixture); const fallbackOverride = diagnosticOverride('FLUXER_WIN_GAME_CAPTURE_EXPECT_FALLBACK_REASON', fixture); if (apiOverride !== undefined) expected.apiType = apiOverride; if (transportOverride !== undefined) { expected.transport = transportOverride; delete expected.transportOneOf; expected.requiresDxgiFormat = expected.transport === TRANSPORT_SHARED_TEXTURE; } if (fallbackOverride !== undefined) { expected.fallbackReason = fallbackOverride; delete expected.fallbackReasonOneOf; } assertEqualDiagnostic(fixture, diagnostics, 'apiType', expected.apiType); if (expected.transportOneOf) { assertOneOfDiagnostic(fixture, diagnostics, 'transport', expected.transportOneOf); } else { assertEqualDiagnostic(fixture, diagnostics, 'transport', expected.transport); } if (expected.fallbackReason !== undefined) { assertEqualDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReason); } else if (expected.fallbackReasonOneOf) { assertOneOfDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReasonOneOf); } if (diagnostics?.activeStrategy !== 'game-hook') { throw new Error(`${fixture} expected activeStrategy=game-hook, got ${JSON.stringify(diagnostics)}`); } if ( (expected.requiresDxgiFormat || (expected.requiresDxgiFormatWhenShared && diagnostics?.transport === TRANSPORT_SHARED_TEXTURE)) && Number(diagnostics?.dxgiFormat ?? 0) === 0 ) { throw new Error(`${fixture} expected a non-zero shared texture DXGI format, got ${JSON.stringify(diagnostics)}`); } } async function runFixture(fixture) { const exe = await buildFixture(fixture); const args = ['--frames', '900', '--width', String(WIDTH), '--height', String(HEIGHT), '--windowed']; if (fixture === 'vulkan-present-fixture') args.push('--resize-at', '120'); args.push(...extraFixtureArgs(fixture)); const child = spawn(exe, args, { cwd: dirname(exe), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: false, }); let started = false; try { const hwnd = await waitForHwnd(child, fixture); const screenCapture = new capture.ScreenCapture({ sourceId: `window:${hwnd}:0`, sourceKind: 'game', width: WIDTH, height: HEIGHT, frameRate: FRAME_RATE, injectionMethod: process.env.FLUXER_WIN_GAME_CAPTURE_INJECTION_METHOD || 'auto', }); let result; try { result = await screenCapture.start(); started = true; const observed = await waitForAdvancingFrames(screenCapture, fixture); assertFixtureDiagnostics(fixture, observed.diagnostics); console.log( `[fixture-smoke] PASS ${fixture}: start=${JSON.stringify(result)} frames=${observed.frameCount} signatures=${observed.signatures} diagnostics=${JSON.stringify(observed.diagnostics)}`, ); } finally { if (started) await screenCapture.stop().catch(() => {}); } } finally { if (!child.killed) child.kill(); } } async function main() { if (process.platform !== 'win32') { console.log('[fixture-smoke] SKIP: Windows game-capture fixtures only run on Windows'); return 0; } if (!capture.isSupported()) { throw new Error(`win-game-capture binding unavailable: ${capture.loadError?.message ?? 'unknown error'}`); } for (const fixture of selectedFixtures()) { await runFixture(fixture); } return 0; } try { process.exitCode = await main(); } catch (error) { console.error(`[fixture-smoke] FAIL: ${error.stack ?? error.message}`); process.exitCode = 1; }