open-design/e2e/specs/win.spec.ts
PerishFire bb13eee765
chore: optimize CI and beta release runtime (#2231)
* chore(ci): add runtime trace summaries

* chore(ci): tighten measured workspace steps

* chore(release): tighten beta setup steps

* chore(release): slim beta windows smoke

* chore(ci): shard daemon tests

* chore(ci): harden runtime trace lookup

* chore(release): avoid mac pnpm cache in beta

* chore(ci): split critical playwright checks

* chore(release): publish beta platforms from builders

* test(e2e): update beta release workflow expectation

* chore(ci): stop gating PRs on nix check

* fix(release): keep beta latest complete
2026-05-19 18:06:28 +08:00

522 lines
18 KiB
TypeScript

// @vitest-environment node
import { execFile } from 'node:child_process';
import { mkdir, readFile, stat } from 'node:fs/promises';
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { describe, expect, test } from 'vitest';
import { createPackagedSmokeReport } from '@/vitest/packaged-report';
const execFileAsync = promisify(execFile);
const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const workspaceRoot = dirname(e2eRoot);
const toolsPackDir = resolveFromWorkspace(process.env.OD_PACKAGED_E2E_TOOLS_PACK_DIR ?? '.tmp/tools-pack');
const namespace = process.env.OD_PACKAGED_E2E_NAMESPACE ?? 'release-beta-win';
const toolsPackBin = join(workspaceRoot, 'tools', 'pack', 'bin', 'tools-pack.mjs');
const maxInstallDurationMs = Number.parseInt(process.env.OD_PACKAGED_E2E_WIN_MAX_INSTALL_MS ?? '120000', 10);
const verifyReinstallWhileRunning = process.env.OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL !== '0';
const installIdentity = resolveInstallIdentity(namespace);
const outputNamespaceRoot = join(toolsPackDir, 'out', 'win', 'namespaces', namespace);
const runtimeNamespaceRoot = join(toolsPackDir, 'runtime', 'win', 'namespaces', namespace);
const screenshotPath = join(toolsPackDir, 'screenshots', `${namespace}.png`);
const healthExpression = "fetch('/api/health').then(async response => ({ health: await response.json(), href: location.href, status: response.status, title: document.title }))";
type DesktopStatus = {
state?: string;
title?: string | null;
url?: string | null;
windowVisible?: boolean;
};
type WinInstallResult = {
desktopShortcutExists: boolean;
desktopShortcutPath: string;
installDir: string;
installPayload: {
fileCount: number;
totalBytes: number;
topLevel: Array<{
bytes: number;
fileCount: number;
path: string;
}>;
};
installerPath: string;
namespace: string;
registryEntries: unknown[];
startMenuShortcutExists: boolean;
startMenuShortcutPath: string;
timingPath: string;
uninstallerPath: string;
};
type WinStartResult = {
executablePath: string;
logPath: string;
namespace: string;
pid: number;
source: string;
status: DesktopStatus | null;
};
type WinStopResult = {
namespace: string;
remainingPids: number[];
status: string;
};
type WinCleanupResult = {
namespace: string;
residueObservation?: {
installedExeExists?: boolean;
managedProcessPids?: number[];
productNamespaceRootExists?: boolean;
registryResidues?: string[];
startMenuShortcutExists?: boolean;
uninstallerExists?: boolean;
userDesktopShortcutExists?: boolean;
};
};
type WinUninstallResult = {
namespace: string;
residueObservation?: WinCleanupResult['residueObservation'];
};
type WinInspectResult = {
eval?: {
error?: string;
ok: boolean;
value?: unknown;
};
screenshot?: {
path: string;
};
status: DesktopStatus | null;
};
type LogsResult = {
logs: Record<string, { lines: string[]; logPath: string }>;
namespace: string;
};
type TimingResult = {
action: string;
durationMs: number;
status: string;
};
type HealthEvalValue = {
health: {
ok?: unknown;
service?: unknown;
version?: unknown;
};
href: string;
status: number;
title: string;
};
type SmokeTiming = {
durationMs: number;
step: string;
};
type DirectInstallerResult = {
code: number | null;
nsisLogTail: string[];
};
const shouldRunPackagedWinSmoke = process.platform === 'win32' && process.env.OD_PACKAGED_E2E_WIN === '1';
const winDescribe = shouldRunPackagedWinSmoke ? describe : describe.skip;
winDescribe('packaged windows runtime smoke', () => {
let installed = false;
let started = false;
test('installs, starts, inspects with eval and screenshot, stops, and uninstalls the built windows artifact', async () => {
const report = await createPackagedSmokeReport('win');
let passed = false;
const timings: SmokeTiming[] = [];
try {
await measureSmokeStep(timings, 'pre-clean uninstall', async () => {
await runToolsPackJson<WinUninstallResult>('uninstall').catch(() => null);
});
const install = await measureSmokeStep(timings, 'install', async () => runToolsPackJson<WinInstallResult>('install'));
installed = true;
expect(install.namespace).toBe(namespace);
expectPathInside(install.installerPath, join(outputNamespaceRoot, 'builder'));
expectPathInside(install.installDir, join(runtimeNamespaceRoot, 'install'));
expectPathInside(install.uninstallerPath, install.installDir);
expect(basename(install.uninstallerPath)).toBe(`Uninstall ${installIdentity.displayName}.exe`);
expect(install.desktopShortcutExists).toBe(true);
expect(install.startMenuShortcutExists).toBe(true);
expect(basename(install.desktopShortcutPath)).toBe(`${installIdentity.displayName}.lnk`);
expect(basename(install.startMenuShortcutPath)).toBe(`${installIdentity.displayName}.lnk`);
expect(install.registryEntries.length).toBeGreaterThan(0);
expect(JSON.stringify(install.registryEntries)).toContain(installIdentity.displayName);
expect(JSON.stringify(install.registryEntries)).toContain(`Open Design-${installIdentity.namespaceToken}`);
expect(install.installPayload.fileCount).toBeGreaterThan(0);
expect(install.installPayload.totalBytes).toBeGreaterThan(0);
expect(install.installPayload.topLevel.length).toBeGreaterThan(0);
const installTiming = await readTiming(install.timingPath);
expect(installTiming.action).toBe('install');
expect(installTiming.status).toBe('success');
if (installTiming.durationMs > maxInstallDurationMs) {
throw new Error(
[
`windows installer exceeded ${maxInstallDurationMs}ms budget: ${installTiming.durationMs}ms`,
`installed files=${install.installPayload.fileCount} bytes=${install.installPayload.totalBytes}`,
`top-level payload=${JSON.stringify(install.installPayload.topLevel.slice(0, 8))}`,
].join('\n'),
);
}
let start = await measureSmokeStep(timings, 'start', async () => runToolsPackJson<WinStartResult>('start'));
started = true;
expect(start.namespace).toBe(namespace);
expect(start.source).toBe('installed');
expectPathInside(start.executablePath, install.installDir);
expectPathInside(start.logPath, join(runtimeNamespaceRoot, 'logs', 'desktop'));
expect(start.pid).toBeGreaterThan(0);
const inspect = await measureSmokeStep(timings, 'wait healthy inspect eval', async () => waitForHealthyDesktop());
expect(inspect.status?.state).toBe('running');
expect(inspect.status?.url).toBe('od://app/');
const value = assertHealthEvalValue(inspect.eval?.value);
expect(value.href).toBe('od://app/');
expect(value.status).toBe(200);
expect(value.health.ok).toBe(true);
expect(value.health.version).toEqual(expect.any(String));
let reinstall: DirectInstallerResult | { skipped: true } = { skipped: true };
if (verifyReinstallWhileRunning) {
reinstall = await measureSmokeStep(timings, 'direct reinstall while running', async () =>
runDirectInstaller(install.installerPath, install.installDir),
);
started = false;
expect(reinstall.code).toBe(0);
expect(reinstall.nsisLogTail.join('\n')).toContain('running instances detected before silent install');
expect(reinstall.nsisLogTail.join('\n')).toContain('running instances close exit=0');
start = await measureSmokeStep(timings, 'restart after direct reinstall', async () =>
runToolsPackJson<WinStartResult>('start'),
);
started = true;
expect(start.namespace).toBe(namespace);
expect(start.source).toBe('installed');
expectPathInside(start.executablePath, install.installDir);
const postReinstallInspect = await measureSmokeStep(timings, 'wait healthy inspect after reinstall', async () =>
waitForHealthyDesktop(),
);
expect(postReinstallInspect.status?.state).toBe('running');
}
await mkdir(dirname(screenshotPath), { recursive: true });
const screenshot = await measureSmokeStep(timings, 'inspect screenshot', async () =>
runToolsPackJson<WinInspectResult>('inspect', ['--path', screenshotPath]),
);
expect(screenshot.screenshot?.path).toBe(screenshotPath);
expect(await fileSizeBytes(screenshotPath)).toBeGreaterThan(0);
await report.saveScreenshot(screenshotPath);
const logs = await measureSmokeStep(timings, 'logs', async () => runToolsPackJson<LogsResult>('logs'));
assertLogPathsAndContent(logs);
const stop = await measureSmokeStep(timings, 'stop', async () => runToolsPackJson<WinStopResult>('stop'));
started = false;
expect(stop.namespace).toBe(namespace);
expect(stop.status).not.toBe('partial');
expect(stop.remainingPids).toEqual([]);
const uninstall = await measureSmokeStep(timings, 'uninstall remove data', async () =>
runToolsPackJson<WinUninstallResult>('uninstall', ['--remove-product-user-data']),
);
installed = false;
expect(uninstall.namespace).toBe(namespace);
expect(uninstall.residueObservation?.managedProcessPids ?? []).toEqual([]);
expect(uninstall.residueObservation?.productNamespaceRootExists).toBe(false);
expect(uninstall.residueObservation?.registryResidues ?? []).toEqual([]);
expect(uninstall.residueObservation?.installedExeExists).toBe(false);
expect(uninstall.residueObservation?.uninstallerExists).toBe(false);
expect(uninstall.residueObservation?.startMenuShortcutExists).toBe(false);
expect(uninstall.residueObservation?.userDesktopShortcutExists).toBe(false);
await report.saveSummary({
health: value,
install: {
desktopShortcutExists: install.desktopShortcutExists,
installDir: install.installDir,
installPayload: install.installPayload,
installerPath: install.installerPath,
registryEntryCount: install.registryEntries.length,
startMenuShortcutExists: install.startMenuShortcutExists,
timingPath: install.timingPath,
uninstallerPath: install.uninstallerPath,
},
installTiming,
logs: summarizeLogs(logs),
namespace,
reinstall,
screenshot: report.screenshotRelpath,
start: {
executablePath: start.executablePath,
logPath: start.logPath,
pid: start.pid,
source: start.source,
status: start.status,
},
stop,
timings,
uninstall,
});
passed = true;
} finally {
if (!passed) {
await printPackagedLogs().catch((error: unknown) => {
console.error('failed to read packaged windows logs after failure', error);
});
}
if (started) {
await runToolsPackJson<WinStopResult>('stop').catch((error: unknown) => {
console.error('failed to stop packaged windows app during cleanup', error);
});
started = false;
}
if (installed) {
await runToolsPackJson<WinUninstallResult>('uninstall').catch((error: unknown) => {
console.error('failed to uninstall packaged windows app during cleanup', error);
});
installed = false;
}
printSmokeTimings(timings);
}
}, 300_000);
});
async function measureSmokeStep<T>(timings: SmokeTiming[], step: string, run: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
try {
return await run();
} finally {
timings.push({ durationMs: Date.now() - startedAt, step });
}
}
function printSmokeTimings(timings: SmokeTiming[]): void {
const totalMs = timings.reduce((sum, timing) => sum + timing.durationMs, 0);
console.info(
[
'[windows smoke timings]',
...timings.map((timing) => `${timing.step}: ${Math.round(timing.durationMs / 100) / 10}s`),
`measured total: ${Math.round(totalMs / 100) / 10}s`,
].join('\n'),
);
}
async function runToolsPackJson<T>(action: string, extraArgs: string[] = []): Promise<T> {
const args = [
toolsPackBin,
'win',
action,
'--dir',
toolsPackDir,
'--namespace',
namespace,
'--json',
...extraArgs,
];
const result = await execFileAsync(process.execPath, args, {
cwd: workspaceRoot,
env: process.env,
maxBuffer: 20 * 1024 * 1024,
}).catch((error: unknown) => {
if (isExecError(error)) {
throw new Error(
[
`tools-pack win ${action} failed`,
`message:\n${error.message}`,
`stdout:\n${error.stdout}`,
`stderr:\n${error.stderr}`,
].join('\n'),
);
}
throw error;
});
try {
return JSON.parse(result.stdout) as T;
} catch (error) {
throw new Error(`tools-pack win ${action} did not print JSON: ${String(error)}\n${result.stdout}`);
}
}
async function runDirectInstaller(installerPath: string, installDir: string): Promise<DirectInstallerResult> {
const previousLogLines = await readNsisLogLines();
const error = await execFileAsync(installerPath, ['/S', `/D=${installDir}`], {
cwd: dirname(installerPath),
env: process.env,
maxBuffer: 20 * 1024 * 1024,
windowsVerbatimArguments: true,
}).then(
() => null,
(caught: unknown) => caught,
);
const code = isExecError(error) ? Number(error.code) : error == null ? 0 : null;
return {
code,
nsisLogTail: (await readNsisLogLines()).slice(previousLogLines.length),
};
}
async function readNsisLogLines(): Promise<string[]> {
const raw = await readFile(join(outputNamespaceRoot, 'logs', 'nsis.log'), 'utf8').catch(() => '');
return raw.split(/\r?\n/).filter((line) => line.length > 0);
}
async function waitForHealthyDesktop(): Promise<WinInspectResult> {
const timeoutMs = 90_000;
const startedAt = Date.now();
let lastResult: unknown = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const inspect = await runToolsPackJson<WinInspectResult>('inspect', ['--expr', healthExpression]);
lastResult = inspect;
if (inspect.status?.state === 'running' && inspect.eval?.ok === true) {
const value = asHealthEvalValue(inspect.eval.value);
if (value?.status === 200 && value.health.ok === true && typeof value.health.version === 'string') {
return inspect;
}
}
} catch (error) {
lastResult = error;
}
await delay(1000);
}
throw new Error(`packaged windows runtime did not become healthy: ${formatUnknown(lastResult)}`);
}
function assertLogPathsAndContent(result: LogsResult): void {
expect(result.namespace).toBe(namespace);
for (const app of ['desktop', 'web', 'daemon']) {
const entry = result.logs[app];
if (entry == null) {
throw new Error(`expected ${app} log entry`);
}
expectPathInside(entry.logPath, join(runtimeNamespaceRoot, 'logs', app));
}
const combined = Object.values(result.logs)
.flatMap((entry) => entry.lines)
.join('\n');
expect(combined).not.toMatch(/ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
expect(combined).not.toMatch(/packaged runtime failed/i);
expect(combined).not.toMatch(/standalone Next\.js server exited/i);
}
function summarizeLogs(result: LogsResult): Record<string, { lineCount: number; logPath: string }> {
return Object.fromEntries(
Object.entries(result.logs).map(([app, entry]) => [
app,
{
lineCount: entry.lines.length,
logPath: entry.logPath,
},
]),
);
}
async function printPackagedLogs(): Promise<void> {
const result = await runToolsPackJson<LogsResult>('logs');
for (const [app, entry] of Object.entries(result.logs)) {
console.error(`[${app}] ${entry.logPath}`);
console.error(entry.lines.join('\n') || '(no log lines)');
}
}
function assertHealthEvalValue(value: unknown): HealthEvalValue {
const normalized = asHealthEvalValue(value);
if (normalized == null) {
throw new Error(`unexpected health eval value: ${formatUnknown(value)}`);
}
return normalized;
}
function asHealthEvalValue(value: unknown): HealthEvalValue | null {
if (!isRecord(value)) return null;
if (typeof value.href !== 'string' || typeof value.status !== 'number' || typeof value.title !== 'string') return null;
if (!isRecord(value.health)) return null;
return value as HealthEvalValue;
}
function expectPathInside(filePath: string, expectedRoot: string): void {
const normalizedPath = resolve(filePath);
const normalizedRoot = resolve(expectedRoot);
expect(
normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`),
`${normalizedPath} should be inside ${normalizedRoot}`,
).toBe(true);
}
async function fileSizeBytes(filePath: string): Promise<number> {
return (await stat(filePath)).size;
}
async function readTiming(filePath: string): Promise<TimingResult> {
return JSON.parse(await readFile(filePath, 'utf8')) as TimingResult;
}
function resolveFromWorkspace(filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(workspaceRoot, filePath);
}
function resolveInstallIdentity(value: string): { displayName: string; namespaceToken: string } {
const namespaceToken = value.replace(/[^A-Za-z0-9._-]+/g, '-');
const displayName = /(^|[-_.])beta($|[-_.])/i.test(value)
? 'Open Design Beta'
: /(^|[-_.])preview($|[-_.])/i.test(value)
? 'Open Design Preview'
: value === 'default'
? 'Open Design'
: `Open Design ${namespaceToken}`;
return { displayName, namespaceToken };
}
function delay(ms: number): Promise<void> {
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value != null && !Array.isArray(value);
}
function isExecError(value: unknown): value is { code?: unknown; message: string; stderr: string; stdout: string } {
return (
isRecord(value) &&
typeof value.message === 'string' &&
typeof value.stdout === 'string' &&
typeof value.stderr === 'string'
);
}
function formatUnknown(value: unknown): string {
if (value instanceof Error) return `${value.name}: ${value.message}`;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}