mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
#!/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 MIN_OBSERVED_FRAMES = 3;
|
|
|
|
const API_NONE = 0;
|
|
const TRANSPORT_MEMORY = 0;
|
|
const FALLBACK_NONE = 0;
|
|
const DXGI_FORMAT_UNKNOWN = 0;
|
|
const EXPECTED_ACTIVE_STRATEGY = 'wgc';
|
|
|
|
const EXPECTED_DIAGNOSTICS = {
|
|
apiType: API_NONE,
|
|
transport: TRANSPORT_MEMORY,
|
|
fallbackReason: FALLBACK_NONE,
|
|
dxgiFormat: DXGI_FORMAT_UNKNOWN,
|
|
};
|
|
|
|
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 observedFrameCount(diagnostics) {
|
|
if (!diagnostics) return 0;
|
|
const accepted = Number(diagnostics.frameSinkAccepted ?? 0);
|
|
const coalesced = Number(diagnostics.frameSinkCoalesced ?? 0);
|
|
const droppedWithoutSink = Number(diagnostics.mediaFramesDroppedWithoutSink ?? 0);
|
|
const total = accepted + coalesced + droppedWithoutSink;
|
|
return Number.isFinite(total) ? total : 0;
|
|
}
|
|
|
|
async function waitForAdvancingFrames(screenCapture, fixture, stalls) {
|
|
let lastDiagnostics = null;
|
|
const start = Date.now();
|
|
while (Date.now() - start < FRAME_TIMEOUT_MS) {
|
|
lastDiagnostics = screenCapture.getDiagnostics?.() ?? lastDiagnostics;
|
|
const frameCount = observedFrameCount(lastDiagnostics);
|
|
if (frameCount >= MIN_OBSERVED_FRAMES) {
|
|
return {frameCount, diagnostics: lastDiagnostics};
|
|
}
|
|
await delay(100);
|
|
}
|
|
throw new Error(
|
|
`${fixture} capture did not deliver advancing frames within ${FRAME_TIMEOUT_MS}ms (frames=${observedFrameCount(lastDiagnostics)}, stalls=${JSON.stringify(stalls)}, diagnostics=${JSON.stringify(lastDiagnostics)})`,
|
|
);
|
|
}
|
|
|
|
function assertEqualDiagnostic(fixture, diagnostics, key, expected) {
|
|
if (diagnostics?.[key] !== expected) {
|
|
throw new Error(`${fixture} expected diagnostics.${key}=${expected}, got ${JSON.stringify(diagnostics)}`);
|
|
}
|
|
}
|
|
|
|
function assertFixtureDiagnostics(fixture, diagnostics) {
|
|
const expected = {...EXPECTED_DIAGNOSTICS};
|
|
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;
|
|
if (fallbackOverride !== undefined) expected.fallbackReason = fallbackOverride;
|
|
|
|
if (diagnostics?.activeStrategy !== EXPECTED_ACTIVE_STRATEGY) {
|
|
throw new Error(
|
|
`${fixture} expected activeStrategy=${EXPECTED_ACTIVE_STRATEGY}, got ${JSON.stringify(diagnostics)}`,
|
|
);
|
|
}
|
|
assertEqualDiagnostic(fixture, diagnostics, 'apiType', expected.apiType);
|
|
assertEqualDiagnostic(fixture, diagnostics, 'transport', expected.transport);
|
|
assertEqualDiagnostic(fixture, diagnostics, 'fallbackReason', expected.fallbackReason);
|
|
assertEqualDiagnostic(fixture, diagnostics, 'dxgiFormat', expected.dxgiFormat);
|
|
assertEqualDiagnostic(fixture, diagnostics, 'injectionMethod', '');
|
|
}
|
|
|
|
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',
|
|
});
|
|
const stalls = [];
|
|
screenCapture.on('stalled', (message) => stalls.push(message ?? ''));
|
|
let result;
|
|
try {
|
|
result = await screenCapture.start();
|
|
started = true;
|
|
const observed = await waitForAdvancingFrames(screenCapture, fixture, stalls);
|
|
assertFixtureDiagnostics(fixture, observed.diagnostics);
|
|
console.log(
|
|
`[fixture-smoke] PASS ${fixture}: start=${JSON.stringify(result)} frames=${observed.frameCount} stalls=${JSON.stringify(stalls)} 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;
|
|
}
|