← back to Studio Zero
test/fallback.spec.js
72 lines
// Regression guard for the demo-fallback HONESTY banner (DTD-A refinement, 2026-07-26).
// When a LIVE provider call fails (e.g. an expired CLI/OAuth token 401s), server.js
// substitutes canned demo content with `fallback:true`; the front-end MUST raise a
// persistent "generation unavailable" banner so fabricated demo is never mistaken for
// a real result. This test forces the failure path (live API mode + a bogus key → 401)
// and asserts the banner is visible, honest, badge-distinct, and survives navigation.
//
// Self-contained: spawns its own studio-zero on a scratch port, then tears it down.
// Run: node test/fallback.spec.js (needs `playwright` resolvable, e.g. via NODE_PATH)
const { chromium } = require('playwright');
const { spawn } = require('child_process');
const path = require('path');
const PORT = process.env.FB_PORT || '4174';
const BASE = 'http://127.0.0.1:' + PORT;
function waitReady(url, ms) {
const http = require('http'); const t0 = Date.now();
return new Promise((resolve, reject) => {
(function ping() {
const r = http.get(url, res => { res.resume(); resolve(); });
r.on('error', () => { if (Date.now() - t0 > ms) reject(new Error('server never came up')); else setTimeout(ping, 150); });
})();
});
}
(async () => {
const srv = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, PORT, SZ_PROVIDER: 'api', ANTHROPIC_API_KEY: 'sk-ant-bogus-401-forcing' },
stdio: 'ignore',
});
const log = []; const ok = (c, m) => log.push((c ? 'PASS ' : 'FAIL ') + m);
const errs = [];
try {
await waitReady(BASE + '/api/config', 8000);
const browser = await chromium.launch();
const page = await browser.newPage();
page.on('console', m => { if (m.type() === 'error') errs.push('console:' + m.text()); });
page.on('pageerror', e => errs.push('pageerror:' + e.message));
await page.goto(BASE, { waitUntil: 'networkidle' });
ok(/LIVE/.test((await page.locator('#modeChip').textContent()).trim()), 'mode chip is LIVE (bogus key still resolves a provider)');
await page.click('#startBtn');
await page.waitForSelector('#genBtn');
await page.click('#genBtn');
await page.waitForFunction(() => { const o = document.querySelector('#mdOut'); return o && o.textContent && !/working/i.test(o.textContent); }, null, { timeout: 60000, polling: 400 });
ok(await page.locator('#fbanner').isVisible(), 'persistent fallback banner visible after failed live call');
const bt = (await page.locator('#fbanner').textContent()).trim();
ok(/Generation unavailable/i.test(bt), 'banner says "Generation unavailable"');
ok(/placeholder demo content/i.test(bt), 'banner labels the output as placeholder demo content');
ok(/invalid x-api-key/i.test(bt), 'banner surfaces the real failure reason');
ok(/fallback/i.test((await page.locator('#outBadge').textContent()).trim()), 'badge distinguishes fallback from intentional demo');
// Persistence: navigate away and back — banner must rehydrate from saved state, not vanish.
await page.click('#nextBtn'); await page.waitForTimeout(200);
await page.locator('#stepper .sdot').nth(0).click(); await page.waitForTimeout(300);
ok(await page.locator('#fbanner').isVisible(), 'banner persists across step navigation');
await browser.close();
} catch (e) {
ok(false, 'HARNESS: ' + e.message);
} finally {
srv.kill('SIGKILL');
}
console.log(log.join('\n'));
console.log('\nconsole/page errors: ' + (errs.length ? '\n' + errs.join('\n') : 'NONE'));
process.exit(log.some(l => l.startsWith('FAIL')) || errs.length ? 1 : 0);
})();