mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* test(desktop): give Electron probes a CI-sized timeout The PPTX layered-background and base-href tests each spawn a throwaway Electron process (xvfb-run on Linux) and gave it 10-20s. A cold Electron start on a busy GitHub runner regularly exceeds that, and execFile then reports a bare "Command failed: xvfb-run … electron …" with no stderr — which is how an unrelated PR turns red on one probe (see #7772, four consecutive runs, and #7768 on the same case) while the file passes locally. Both files now share one probe budget (60s) and one per-test budget (90s) behind named constants instead of scattered literals. A probe that really hangs still fails, just later. * test(desktop): run the PNG paint probe sandbox-less and surface its output The paint probe was the one Electron spawn in the file without --no-sandbox --disable-gpu, and the only one that rethrew a failed spawn without the child's stdout/stderr. On the CI runner that combination is a probe that exits before app.whenReady() with a bare "Command failed" — the failure #7774's first run still showed with the larger budget. Align it with the other probes and attach the child's output on failure so the next flake, if any, says why.
231 lines
8.4 KiB
TypeScript
231 lines
8.4 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const rendererState = vi.hoisted(() => ({ loadedUrls: [] as string[] }));
|
|
|
|
vi.mock('electron', () => {
|
|
const image = {
|
|
getSize: () => ({ height: 1, width: 1 }),
|
|
toBitmap: () => Buffer.alloc(4),
|
|
toJPEG: () => Buffer.from('jpeg'),
|
|
toPNG: () => Buffer.from('png'),
|
|
};
|
|
|
|
class BrowserWindow {
|
|
readonly webContents = {
|
|
capturePage: async () => image,
|
|
debugger: {
|
|
attach: () => {
|
|
throw new Error('debugger unavailable in renderer-base test');
|
|
},
|
|
detach: () => undefined,
|
|
sendCommand: async () => undefined,
|
|
},
|
|
executeJavaScript: async (source: string): Promise<unknown> => {
|
|
if (source.includes("document.querySelectorAll('.slide")) return 0;
|
|
if (source.includes('document.documentElement.scrollHeight')) return 1;
|
|
if (source === 'window.devicePixelRatio || 1') return 1;
|
|
return true;
|
|
},
|
|
on: () => undefined,
|
|
// Real Electron webContents is an EventEmitter; the renderer's document
|
|
// load waits on `once('dom-ready')`. The stub's loadURL resolves
|
|
// immediately, so a no-op is faithful here — the load simply never has a
|
|
// dom-ready to race against.
|
|
once: () => undefined,
|
|
printToPDF: async () => Buffer.from('pdf'),
|
|
setWindowOpenHandler: () => undefined,
|
|
};
|
|
|
|
async loadURL(url: string): Promise<void> {
|
|
rendererState.loadedUrls.push(url);
|
|
}
|
|
|
|
destroy(): void {}
|
|
getContentSize(): [number, number] { return [1440, 900]; }
|
|
isDestroyed(): boolean { return false; }
|
|
setContentSize(): void {}
|
|
setOpacity(): void {}
|
|
showInactive(): void {}
|
|
}
|
|
|
|
return {
|
|
BrowserWindow,
|
|
dialog: { showSaveDialog: async () => ({ canceled: true }) },
|
|
nativeImage: { createFromBitmap: () => image },
|
|
};
|
|
});
|
|
|
|
import { exportArtifact } from '../../src/main/artifact-export.js';
|
|
import { renderDeckSlides } from '../../src/main/deck-capture.js';
|
|
|
|
const execFileP = promisify(execFile);
|
|
const desktopRoot = fileURLToPath(new URL('../..', import.meta.url));
|
|
|
|
/**
|
|
* Budget for one Electron probe process (`xvfb-run … electron <probeDir>` on
|
|
* Linux). The probes only need a second or two once Chromium is up, but a
|
|
* cold Electron start on a busy CI runner can take well over ten seconds,
|
|
* and `execFile` kills a timed-out child with a bare "Command failed" and no
|
|
* stderr — which is exactly what an otherwise green run shows when it flakes.
|
|
* Generous on purpose: a probe that really hangs still fails, just later.
|
|
*/
|
|
const ELECTRON_PROBE_TIMEOUT_MS = 60_000;
|
|
/** Per-test budget for a test that runs one probe: the probe plus setup. */
|
|
const ELECTRON_PROBE_TEST_TIMEOUT_MS = 90_000;
|
|
const legacyBaseHref = 'https://external.invalid/legacy/';
|
|
const scopedBaseHref = 'http://127.0.0.1:43123/preview/export-scope/';
|
|
const sourceHtml = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<base href="${legacyBaseHref}">
|
|
<link rel="stylesheet" href="styles/x.css">
|
|
</head>
|
|
<body>
|
|
<img src="assets/hero.png" alt="">
|
|
</body>
|
|
</html>`;
|
|
|
|
type BaseResolution = {
|
|
readonly baseURI: string;
|
|
readonly imageSrc: string;
|
|
readonly stylesheetHref: string;
|
|
};
|
|
|
|
afterEach(() => {
|
|
rendererState.loadedUrls.length = 0;
|
|
});
|
|
|
|
describe('scoped renderer base precedence', () => {
|
|
it('uses the scoped base for deck-capture CSS and images when HTML already has an external base', async () => {
|
|
// Given: a deck-capture request whose source document already declares another base.
|
|
const input = {
|
|
baseHref: scopedBaseHref,
|
|
deck: true,
|
|
html: sourceHtml,
|
|
};
|
|
|
|
// When: deck-capture builds and loads its renderer document.
|
|
const result = await renderDeckSlides(input);
|
|
|
|
// Then: Electron Chromium proves the scoped base controls every relative renderer asset.
|
|
const resolution = await probeLoadedDocument(singleLoadedUrl());
|
|
expect(result).toMatchObject({ errorCode: 'NO_SLIDES', ok: false });
|
|
expect(resolution).toEqual(expectedResolution());
|
|
}, ELECTRON_PROBE_TEST_TIMEOUT_MS);
|
|
|
|
it('uses the scoped base for artifact-export CSS and images when HTML already has an external base', async () => {
|
|
// Given: an artifact-export request whose source document already declares another base.
|
|
const input = {
|
|
baseHref: scopedBaseHref,
|
|
deck: false,
|
|
format: 'image',
|
|
html: sourceHtml,
|
|
imageFormat: 'png',
|
|
title: 'Existing base precedence',
|
|
} as const;
|
|
|
|
// When: artifact-export builds and loads its renderer document.
|
|
const result = await exportArtifact(input);
|
|
|
|
try {
|
|
// Then: Electron Chromium proves the scoped base controls every relative renderer asset.
|
|
const resolution = await probeLoadedDocument(singleLoadedUrl());
|
|
expect(result.ok).toBe(true);
|
|
expect(resolution).toEqual(expectedResolution());
|
|
} finally {
|
|
if (result.path) await rm(dirname(result.path), { force: true, recursive: true });
|
|
}
|
|
}, ELECTRON_PROBE_TEST_TIMEOUT_MS);
|
|
});
|
|
|
|
function singleLoadedUrl(): string {
|
|
expect(rendererState.loadedUrls).toHaveLength(1);
|
|
const url = rendererState.loadedUrls[0];
|
|
if (!url) throw new Error('renderer did not load a document');
|
|
return url;
|
|
}
|
|
|
|
function expectedResolution(): BaseResolution {
|
|
return {
|
|
baseURI: scopedBaseHref,
|
|
imageSrc: new URL('assets/hero.png', scopedBaseHref).href,
|
|
stylesheetHref: new URL('styles/x.css', scopedBaseHref).href,
|
|
};
|
|
}
|
|
|
|
async function probeLoadedDocument(url: string): Promise<BaseResolution> {
|
|
const probeDir = await mkdtemp(join(tmpdir(), 'od-electron-base-probe-'));
|
|
const urlFile = join(probeDir, 'url.txt');
|
|
await writeFile(join(probeDir, 'package.json'), '{"main":"main.cjs"}\n');
|
|
await writeFile(urlFile, url);
|
|
await writeFile(join(probeDir, 'main.cjs'), `
|
|
const { app, BrowserWindow } = require('electron');
|
|
const { readFile } = require('node:fs/promises');
|
|
|
|
app.whenReady().then(async () => {
|
|
const window = new BrowserWindow({
|
|
show: false,
|
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
});
|
|
await window.loadURL(await readFile(process.env.OD_BASE_PROBE_URL_FILE, 'utf8'));
|
|
const result = await window.webContents.executeJavaScript(\`({
|
|
baseURI: document.baseURI,
|
|
imageSrc: document.querySelector('img').src,
|
|
stylesheetHref: document.querySelector('link[rel="stylesheet"]').href,
|
|
})\`, true);
|
|
process.stdout.write('OD_BASE_PROBE:' + JSON.stringify(result) + '\\n');
|
|
window.destroy();
|
|
app.quit();
|
|
}).catch((error) => {
|
|
process.stderr.write(String(error && error.stack ? error.stack : error) + '\\n');
|
|
app.exit(1);
|
|
});
|
|
`);
|
|
|
|
try {
|
|
const electronRelativePath = (await readFile(
|
|
join(desktopRoot, 'node_modules', 'electron', 'path.txt'),
|
|
'utf8',
|
|
)).trim();
|
|
const electronPath = join(desktopRoot, 'node_modules', 'electron', 'dist', electronRelativePath);
|
|
const electronArgs = [probeDir, '--no-sandbox', '--disable-gpu'];
|
|
const command = process.platform === 'linux' ? 'xvfb-run' : electronPath;
|
|
const args = process.platform === 'linux' ? ['-a', electronPath, ...electronArgs] : electronArgs;
|
|
const env: NodeJS.ProcessEnv = { ...process.env, OD_BASE_PROBE_URL_FILE: urlFile };
|
|
delete env.ELECTRON_RUN_AS_NODE;
|
|
const { stdout } = await execFileP(command, args, { env, timeout: ELECTRON_PROBE_TIMEOUT_MS });
|
|
const marker = stdout.split(/\r?\n/).find((line) => line.startsWith('OD_BASE_PROBE:'));
|
|
if (!marker) throw new Error(`Electron renderer probe returned no result: ${stdout}`);
|
|
return parseBaseResolution(JSON.parse(marker.slice('OD_BASE_PROBE:'.length)));
|
|
} finally {
|
|
await rm(probeDir, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function parseBaseResolution(value: unknown): BaseResolution {
|
|
if (
|
|
typeof value !== 'object'
|
|
|| value === null
|
|
|| !('baseURI' in value)
|
|
|| typeof value.baseURI !== 'string'
|
|
|| !('imageSrc' in value)
|
|
|| typeof value.imageSrc !== 'string'
|
|
|| !('stylesheetHref' in value)
|
|
|| typeof value.stylesheetHref !== 'string'
|
|
) {
|
|
throw new Error('Electron renderer probe returned an invalid result');
|
|
}
|
|
return {
|
|
baseURI: value.baseURI,
|
|
imageSrc: value.imageSrc,
|
|
stylesheetHref: value.stylesheetHref,
|
|
};
|
|
}
|