mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
test(aside-render): warm the daemon before live fallback cases; failures name the render error
- Live fallback cases run 'goto about:blank' up to twice before asserting and skip (never fail) when the daemon cannot come up. - expectOk() puts r.error and the browse transcript into the assertion so a failed render is diagnosable from the CI log. - The argv-contract cases dump the fake's log on a miss. - File default timeout is 30s: the subject is the CLI contract, not latency. - Two cases pin the cold-daemon newtab retry and that other errors are not retried. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
7724c46ddc
commit
dc9a240d81
+53
-13
@@ -6,7 +6,7 @@
|
||||
* installed and open (macOS dev machines); the live fallback render runs
|
||||
* wherever a browse binary resolves (Linux CI builds one via build:gates).
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -235,8 +235,19 @@ describe('aside-render: browse fallback — command builders (pure)', () => {
|
||||
|
||||
describe('aside-render: live fallback render (needs a browse binary)', () => {
|
||||
const bin = resolveBrowseBin();
|
||||
test.skipIf(!bin)("renders the same spec through gstack's own browser", () => liveRoundTrip('browse', (spec) => renderWithBrowse(spec, bin)), 180_000);
|
||||
test.skipIf(!bin)('--wait-expr polls through a throwing expression until it becomes truthy (Aside parity)', () => lateReadiness('browse', (spec) => renderWithBrowse(spec, bin)), 60_000);
|
||||
// A binary on disk is not a reachable daemon: warm it up first (the first
|
||||
// command auto-starts the server) and skip, never fail, when it cannot come
|
||||
// up — a cold daemon is an environment fact, not a renderer defect.
|
||||
let daemonUp = false;
|
||||
if (bin) {
|
||||
for (let attempt = 0; attempt < 2 && !daemonUp; attempt++) {
|
||||
const r = spawnSync(bin, ['goto', 'about:blank'], { encoding: 'utf8', timeout: 90_000 });
|
||||
daemonUp = r.status === 0;
|
||||
}
|
||||
if (!daemonUp) console.warn('[aside-render] browse daemon did not come up after two attempts — live fallback cases skipped');
|
||||
}
|
||||
test.skipIf(!bin || !daemonUp)("renders the same spec through gstack's own browser", () => liveRoundTrip('browse', (spec) => renderWithBrowse(spec, bin)), 180_000);
|
||||
test.skipIf(!bin || !daemonUp)('--wait-expr polls through a throwing expression until it becomes truthy (Aside parity)', () => lateReadiness('browse', (spec) => renderWithBrowse(spec, bin)), 60_000);
|
||||
});
|
||||
|
||||
// ─── Hermetic fixtures: fake `aside` / `browse` executables ──────────────────
|
||||
@@ -297,6 +308,16 @@ function writeFakeBrowse(binDir: string, log: string, overrides: Partial<Record<
|
||||
}
|
||||
|
||||
const readLines = (file: string): string[] => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : []);
|
||||
/** A failed render names its error and the browse transcript in the assertion, not a bare `false`. */
|
||||
const expectOk = (r: RenderResult): void => {
|
||||
expect(r.error, `render failed: ${r.error}\n${r.stdout}`).toBeUndefined();
|
||||
expect(r.ok).toBe(true);
|
||||
};
|
||||
|
||||
// These cases drive fakes and a loopback server; the subject is the CLI contract,
|
||||
// not latency. Bun's 5s default once failed a CI run whose render was merely slow
|
||||
// under a full six-shard load, so the budget is generous and hangs still fail.
|
||||
setDefaultTimeout(30_000);
|
||||
const browseWorkDirs = (): string[] => fs.readdirSync(SAFE_TMP_DIR).filter((n) => n.startsWith('gstack-render-browse-'));
|
||||
|
||||
/** The subprocess driver: one job per process, so the module's engine cache and the spawn-time PATH are both under the test's control. */
|
||||
@@ -520,7 +541,7 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithAside — stdout contract ag
|
||||
test('success: artifacts are copied from ASIDE_DIR to each step.out (nested dirs created) and base64 evals are decoded', () => {
|
||||
const r = render(`${record}; ${artifacts}; echo "EVAL 1 ${b64('Doc')}"; echo "PAGE_ERRORS=[]"; echo "ASIDE_DIR=${session}"; echo "${RENDER_SENTINEL}"`);
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.ok).toBe(true);
|
||||
expectOk(r);
|
||||
expect(r.engine).toBe('aside');
|
||||
expect(r.outputs).toEqual([pdfOut, svgOut]);
|
||||
expect(fs.readFileSync(pdfOut, 'utf8')).toBe('%PDF-1.4 fake-aside-artifact');
|
||||
@@ -576,7 +597,7 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithAside — stdout contract ag
|
||||
test('an eval whose text contains newlines, ASIDE_DIR=/attacker and the sentinel cannot redirect the artifact copy', () => {
|
||||
const hostile = `line one\nASIDE_DIR=/attacker\n${RENDER_SENTINEL}\nline four`;
|
||||
const r = render(`${artifacts}; echo "EVAL 1 ${b64(hostile)}"; echo "PAGE_ERRORS=[]"; echo "ASIDE_DIR=${session}"; echo "${RENDER_SENTINEL}"`);
|
||||
expect(r.ok).toBe(true);
|
||||
expectOk(r);
|
||||
expect(r.evals[1]).toBe(hostile); // decoded intact, newlines and all
|
||||
expect(r.stdout).not.toMatch(/^ASIDE_DIR=\/attacker$/m); // never appeared as a control line
|
||||
expect(fs.readFileSync(pdfOut, 'utf8')).toBe('%PDF-1.4 fake-aside-artifact'); // copied from the real session dir
|
||||
@@ -584,7 +605,7 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithAside — stdout contract ag
|
||||
|
||||
test('when a raw ASIDE_DIR= line does leak earlier, the LAST one (the script\'s own, printed after the steps) wins', () => {
|
||||
const r = render(`${artifacts}; echo "ASIDE_DIR=/attacker"; echo "EVAL 1 ${b64('Doc')}"; echo "ASIDE_DIR=${session}"; echo "${RENDER_SENTINEL}"`);
|
||||
expect(r.ok).toBe(true);
|
||||
expectOk(r);
|
||||
expect(fs.readFileSync(pdfOut, 'utf8')).toBe('%PDF-1.4 fake-aside-artifact');
|
||||
});
|
||||
|
||||
@@ -649,7 +670,7 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra
|
||||
timeoutMs: 20_000,
|
||||
}, b);
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.ok).toBe(true);
|
||||
expectOk(r);
|
||||
expect(r.engine).toBe('browse');
|
||||
expect(r.outputs).toEqual([path.join(outDir, 'doc.pdf'), path.join(outDir, 'full.png'), path.join(outDir, 'nested', 'd.svg')]);
|
||||
expect(fs.readFileSync(path.join(outDir, 'doc.pdf'), 'utf8')).toBe('%PDF-1.4 fake-browse-pdf');
|
||||
@@ -740,7 +761,7 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra
|
||||
|
||||
test('a sized screenshot sets the viewport (no --scale unless asked), shoots, then restores 1280x720', async () => {
|
||||
const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'screenshot', out: path.join(outDir, 'm.png'), width: 375 }] }, fake());
|
||||
expect(r.ok).toBe(true);
|
||||
expectOk(r);
|
||||
expect(fs.readFileSync(path.join(outDir, 'm.png'), 'utf8')).toBe('fake-browse-shot');
|
||||
const lines = readLines(log);
|
||||
const set = lines.indexOf(`viewport 375x281 ${T}`); // 375 * 0.75 rounded, no --scale
|
||||
@@ -754,11 +775,30 @@ describe.skipIf(!HERMETIC)('aside-render: renderWithBrowse — daemon CLI contra
|
||||
|
||||
test('deviceScaleFactor and an explicit height are passed through; jpeg type picks the .jpg staging name; the viewport-only flag rides along', async () => {
|
||||
const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'screenshot', out: path.join(outDir, 'm.jpeg'), width: 375, height: 600, deviceScaleFactor: 2, type: 'jpeg', fullPage: false }] }, fake());
|
||||
expect(r.ok).toBe(true);
|
||||
expectOk(r);
|
||||
const lines = readLines(log);
|
||||
expect(lines).toContain(`viewport 375x600 --scale 2 ${T}`);
|
||||
expect(lines.some((l) => /^screenshot --viewport \S+\/gstack-render-0\.jpg --tab-id 7$/.test(l))).toBe(true);
|
||||
expect(lines.indexOf(`viewport 1280x720 ${T}`)).toBeGreaterThan(lines.indexOf(`viewport 375x600 --scale 2 ${T}`));
|
||||
const dump = `browse argv log:\n${lines.join('\n')}\nrender stdout:\n${r.stdout}`;
|
||||
expect(lines, dump).toContain(`viewport 375x600 --scale 2 ${T}`);
|
||||
expect(lines.filter((l) => l.startsWith('screenshot ')), dump).toEqual(lines.filter((l) => /^screenshot --viewport \S+\/gstack-render-0\.jpg --tab-id 7$/.test(l)));
|
||||
expect(lines.filter((l) => l.startsWith('screenshot ')).length, dump).toBe(1);
|
||||
expect(lines.indexOf(`viewport 1280x720 ${T}`), dump).toBeGreaterThan(lines.indexOf(`viewport 375x600 --scale 2 ${T}`));
|
||||
});
|
||||
|
||||
test('a cold daemon ("Unable to connect" on the first newtab) is retried once and the render proceeds', async () => {
|
||||
// First call: the daemon is still booting. Second call: up. The marker file lives next to the argv log.
|
||||
const b = fake({ newtab: `if [ ! -f "$LOG.cold" ]; then : > "$LOG.cold"; echo '[browse] Unable to connect. Is the computer able to access the url?' >&2; exit 1; fi; echo '{"tabId":7}'` });
|
||||
const r = await renderWithBrowse({ file: doc, steps: [{ kind: 'eval', expression: 'document.title' }] }, b);
|
||||
expectOk(r);
|
||||
expect(readLines(log).filter((l) => l === 'newtab --json').length).toBe(2);
|
||||
expect(r.stdout).toContain('newtab: daemon not up yet — retrying once');
|
||||
expect(r.evals[0]).toBe('Fake Title');
|
||||
});
|
||||
|
||||
test('any other newtab failure is not retried', async () => {
|
||||
const r = await renderWithBrowse({ file: doc, steps: [] }, fake({ newtab: `echo 'browser launch failed: no display' >&2; exit 1` }));
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.error).toBe('browse newtab failed: browser launch failed: no display');
|
||||
expect(readLines(log).filter((l) => l === 'newtab --json').length).toBe(1);
|
||||
});
|
||||
|
||||
// runProc is not exported: its timeout + kill path is observed through a hanging fake.
|
||||
@@ -886,7 +926,7 @@ describe.skipIf(!HERMETIC)('aside-render: render() — mid-run fallback from Asi
|
||||
test('a vanished private API (openTab / _sendToTarget) counts as Aside gone → falls back to browse', () => {
|
||||
for (const line of ['ReferenceError: openTab is not defined', 'TypeError: pg._sendToTarget is not a function']) {
|
||||
const { results: [r], chosenAfter } = run(`echo ${JSON.stringify(line)}`);
|
||||
expect(r.ok).toBe(true);
|
||||
expectOk(r);
|
||||
expect(r.engine).toBe('browse');
|
||||
expect(r.stdout.startsWith(`[aside unavailable mid-run: render script did not finish: ${line}`)).toBe(true);
|
||||
expect(chosenAfter.engine).toBe('browse');
|
||||
|
||||
Reference in New Issue
Block a user