1// End-to-end smoke test: landing → sample project → .sample form view,
2// Stan LSP diagnostics, server status. When a compile server is reachable
3// at http://localhost:8083 (e.g. the stan-wasm-server docker image), also
4// compiles + samples for real and checks the output files.
5import { chromium } from 'playwright';
6import { spawn } from 'node:child_process';
8const root = new URL('..', import.meta.url).pathname;
9const out = process.argv[2] ?? '.';
10const previewProc = spawn('npx', ['vite', 'preview', '--port', '4173', '--strictPort'], { stdio: 'ignore', cwd: root });
11for (let i = 0; i < 60; i++) {
12 const up = await fetch('http://127.0.0.1:4173/').then(r => r.ok).catch(() => false);
13 if (up) break;
14 await new Promise((r) => setTimeout(r, 500));
15}
17const serverUrl = 'http://localhost:8083';
18const haveServer = await fetch(`${serverUrl}/probe`).then(r => r.ok).catch(() => false);
19console.log(haveServer ? `compile server detected at ${serverUrl} — running full e2e` : 'no compile server — UI checks only');
21const browser = await chromium.launch({ channel: 'chrome', headless: true });
22const page = await browser.newPage({ viewport: { width: 1500, height: 900 } });
23const errors = [];
24page.on('pageerror', (e) => errors.push(e.message));
25page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
26const explorerItem = (name) => page.locator('.mw-explorer-item-label').filter({ hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) });
27const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
28// monaco renders spaces as U+00A0 — normalize before matching
29const normalize = (text) => text.replace(/\u00a0/g, ' ');
30const outputText = async () => normalize(await page.locator('.mw-output').innerText());
31const waitForOutput = async (needle, timeout = 20000) => {
32 const start = Date.now();
33 while (Date.now() - start < timeout) {
34 if ((await outputText()).includes(needle)) return true;
35 await page.waitForTimeout(250);
36 }
37 return false;
38};
40try {
41 // use 127.0.0.1: the compile server's CORS allowlist matches that origin
42 await page.goto('http://127.0.0.1:4173/', { waitUntil: 'networkidle' });
43 await page.waitForTimeout(1200);
44 check('landing renders', await page.locator('.landing-empty').count() === 1);
45 await page.screenshot({ path: out + '/s-landing.png' });
47 // sample project → fit.sample opens as the form view
48 await page.getByRole('button', { name: 'New sample project' }).click();
49 await page.waitForTimeout(1800);
50 check('URL has project route', /#\/project\/[a-z0-9]+/i.test(page.url()));
51 check('fit.sample opens as form', await page.locator('.sample-editor h2', { hasText: 'fit.sample' }).count() === 1);
52 check('form: stan file selected', await page.locator('.sample-editor select').first().inputValue() === 'linear.stan');
53 check('form: num_chains = 4', await page.locator('.sample-params input').first().inputValue() === '4');
54 check('run button enabled', await page.locator('.sample-run-button').isEnabled());
55 await page.screenshot({ path: out + '/s-form.png' });
57 // server status bar item
58 await page.waitForTimeout(1500);
59 const statusText = normalize(await page.locator('.mw-statusbar').innerText());
60 check('server status item shows', statusText.includes('Stan server:'));
61 check(`server status is ${haveServer ? 'connected' : 'offline'}`, statusText.includes(haveServer ? 'connected' : 'offline'));
63 // Stan editor: highlighting + LSP diagnostics
64 await explorerItem('linear.stan').click();
65 await page.waitForTimeout(2500); // language server warm-up
66 check('stan file opens in text editor', await page.locator('.view-lines').count() >= 1);
67 check('no error markers on valid model', await page.locator('.squiggly-error').count() === 0);
68 // introduce a syntax error and expect a marker
69 await page.locator('.view-lines').first().click();
70 await page.keyboard.press('Control+End');
71 await page.keyboard.type('\nbroken');
72 let sawMarker = false;
73 for (let i = 0; i < 40 && !sawMarker; i++) {
74 await page.waitForTimeout(250);
75 sawMarker = await page.locator('.squiggly-error').count() > 0;
76 }
77 check('LSP reports syntax error', sawMarker);
78 await page.screenshot({ path: out + '/s-lsp.png' });
79 // revert
80 for (let i = 0; i < 7; i++) await page.keyboard.press('Control+z');
81 await page.waitForTimeout(1200);
82 check('marker clears after undo', await page.locator('.squiggly-error').count() === 0);
84 if (!haveServer) {
85 // run without a server → helpful failure in the form status
86 await page.locator('.mw-tab-label', { hasText: 'fit.sample' }).click();
87 await page.waitForTimeout(400);
88 await page.locator('.sample-run-button').click();
89 let failed = false;
90 for (let i = 0; i < 40 && !failed; i++) {
91 await page.waitForTimeout(250);
92 failed = await page.locator('.sample-run-status.error').count() === 1;
93 }
94 check('run without server fails with message', failed);
95 await page.screenshot({ path: out + '/s-noserver.png' });
96 } else {
97 // ---- full e2e: compile + sample fit.sample ----
98 await page.locator('.mw-tab-label', { hasText: 'fit.sample' }).click();
99 await page.waitForTimeout(400);
100 await page.locator('.sample-run-button').click();
101 // progress bars should appear while sampling (4 chains)
102 let sawBars = 0;
103 const start = Date.now();
104 let done = false;
105 while (Date.now() - start < 360_000 && !done) {
106 sawBars = Math.max(sawBars, await page.locator('.sample-chain').count());
107 done = (await outputText()).includes('sampling completed');
108 if (!done) await page.waitForTimeout(300);
109 }
110 check('fit.sample sampling completed', done);
111 check('per-chain progress bars shown (4)', sawBars === 4);
112 await page.screenshot({ path: out + '/s-run-done.png' });
114 // output files in the explorer
115 await page.waitForTimeout(800);
116 await explorerItem('out').click();
117 await page.waitForTimeout(400);
118 await explorerItem('fit').click();
119 await page.waitForTimeout(400);
120 check('chain_1.csv written', await explorerItem('chain_1.csv').count() === 1);
121 check('summary.csv written', await explorerItem('summary.csv').count() === 1);
122 await explorerItem('summary.csv').click();
123 await page.waitForTimeout(800);
124 const summary = normalize(await page.locator('.view-lines').first().innerText());
125 check('summary has beta row', summary.includes('beta'));
126 await page.screenshot({ path: out + '/s-summary.png' });
128 // form edit round-trip: bump quick.sample's num_samples, run, check
129 // the recorded sampling_opts.json (proves form → YAML → runner)
130 await explorerItem('quick.sample').click();
131 await page.waitForTimeout(600);
132 // scope to the visible pane: the fit.sample form stays in the DOM
133 const quickForm = page.locator('.sample-editor:visible');
134 const samplesInput = quickForm.locator('.sample-params input').nth(2);
135 await samplesInput.fill('150');
136 await samplesInput.blur();
137 await page.waitForTimeout(300);
138 check('form edit marks tab dirty', await page.locator('.mw-tab.dirty').count() >= 1);
139 await quickForm.locator('.sample-run-button').click();
140 check('quick.sample sampling completed', await waitForOutput('files to /out/quick', 120_000));
141 await page.waitForTimeout(800);
142 await explorerItem('quick').click();
143 await page.waitForTimeout(400);
144 // both out/fit and out/quick hold one; /out/quick sorts last
145 await explorerItem('sampling_opts.json').last().click();
146 await page.waitForTimeout(800);
147 const opts = normalize(await page.locator('.view-lines').first().innerText());
148 check('sampling_opts records form-edited num_samples', opts.includes('"num_samples": 150'));
149 await page.screenshot({ path: out + '/s-opts.png' });
150 }
152 // project lifecycle basics
153 await page.getByTitle('Back to projects').click();
154 await page.waitForTimeout(800);
155 check('back on landing', await page.locator('.landing-project').count() === 1);
156 await page.getByRole('button', { name: 'New project', exact: true }).click();
157 await page.waitForTimeout(1500);
158 check('empty project opens fit.sample form', await page.locator('.sample-editor').count() === 1);
159 await page.goto('http://127.0.0.1:4173/#/project/nope1234', { waitUntil: 'networkidle' });
160 await page.waitForTimeout(800);
161 check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1);
163 if (errors.length) {
164 console.log('page errors:');
165 for (const e of errors.slice(0, 10)) console.log(' ' + e);
166 } else {
167 console.log('no page errors');
168 }
169} finally {
170 await browser.close();
171 previewProc.kill();
172}