1// End-to-end smoke test: landing → sample project → .sample form view,
2// Stan LSP diagnostics, server status. When the compile server (the app's
3// default, the hosted stan-wasm-wasi instance) is reachable, also compiles
4// + 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}
17// probing also wakes the fly.io machine if it was auto-stopped
18const serverUrl = 'https://stan-wasm-wasi.fly.dev';
19const haveServer = await fetch(`${serverUrl}/probe`).then(r => r.ok).catch(() => false);
20console.log(haveServer ? `compile server detected at ${serverUrl} — running full e2e` : 'no compile server — UI checks only');
22const browser = await chromium.launch({ channel: 'chrome', headless: true });
23const page = await browser.newPage({ viewport: { width: 1500, height: 900 } });
24const errors = [];
25page.on('pageerror', (e) => errors.push(e.message));
26page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
27const explorerItem = (name) => page.locator('.mw-explorer-item-label').filter({ hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) });
28const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
29// monaco renders spaces as U+00A0 — normalize before matching
30const normalize = (text) => text.replace(/\u00a0/g, ' ');
31const outputText = async () => normalize(await page.locator('.mw-output').innerText());
32const waitForOutput = async (needle, timeout = 20000) => {
33 const start = Date.now();
34 while (Date.now() - start < timeout) {
35 if ((await outputText()).includes(needle)) return true;
36 await page.waitForTimeout(250);
37 }
38 return false;
39};
41try {
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) — the
102 // sampling phase is sub-second for this model, so poll fast and use
103 // the cheap form status (not the output panel) to detect completion
104 let sawBars = 0;
105 const start = Date.now();
106 let done = false;
107 while (Date.now() - start < 360_000 && !done) {
108 sawBars = Math.max(sawBars, await page.locator('.sample-chain').count());
109 done = (await page.locator('.sample-run-status').innerText().catch(() => '')).includes('completed');
110 if (!done) await page.waitForTimeout(50);
111 }
112 check('fit.sample sampling completed', done && await waitForOutput('sampling completed'));
113 check('per-chain progress bars shown (4)', sawBars === 4);
114 await page.screenshot({ path: out + '/s-run-done.png' });
116 // the results dashboard auto-opens on completion
117 let dashboardVisible = false;
118 for (let i = 0; i < 40 && !dashboardVisible; i++) {
119 await page.waitForTimeout(250);
120 dashboardVisible = await page.locator('.results-view:visible').count() === 1;
121 }
122 check('results dashboard auto-opens', dashboardVisible);
123 check('dashboard summary has beta row', await page.locator('.results-view:visible .results-table td', { hasText: /^beta$/ }).count() === 1);
124 // plotly loads lazily on the first plot tab
125 await page.locator('.results-tab', { hasText: 'Trace plots' }).click();
126 let plotsRendered = 0;
127 for (let i = 0; i < 60 && plotsRendered < 10; i++) {
128 await page.waitForTimeout(250);
129 plotsRendered = await page.locator('.results-view:visible .js-plotly-plot').count();
130 }
131 check('trace plots render (11 params)', plotsRendered === 11);
132 await page.screenshot({ path: out + '/s-dashboard.png' });
134 // back on the form, the View results button now shows
135 await page.locator('.mw-tab-label', { hasText: 'fit.sample' }).click();
136 await page.waitForTimeout(400);
137 check('View results button shows', await page.locator('.sample-results-button:visible').count() === 1);
139 // output files in the explorer (fit.sample → fit.out/)
140 await page.waitForTimeout(800);
141 await explorerItem('fit.out').click();
142 await page.waitForTimeout(400);
143 check('chain_1.csv written', await explorerItem('chain_1.csv').count() === 1);
144 check('summary.csv written', await explorerItem('summary.csv').count() === 1);
145 // summary.csv opens as the CSV table view
146 await explorerItem('summary.csv').click();
147 await page.waitForTimeout(800);
148 const summaryTable = page.locator('.csv-view:visible');
149 check('summary opens as csv table', await summaryTable.locator('.csv-table').count() === 1);
150 check('summary table has beta row', await summaryTable.locator('td', { hasText: /^beta$/ }).count() === 1);
151 check('summary table meta shows rows', (await summaryTable.locator('.csv-view-meta').innerText()).includes('rows × 10 columns'));
152 // click-to-sort by rhat
153 await summaryTable.locator('th', { hasText: /^rhat/ }).click();
154 await page.waitForTimeout(300);
155 check('sort by rhat', (await summaryTable.locator('.csv-view-meta').innerText()).includes('sorted by rhat'));
156 await page.screenshot({ path: out + '/s-summary.png' });
157 // a draws file renders too (1000 rows)
158 await explorerItem('chain_1.csv').click();
159 await page.waitForTimeout(800);
160 check('chain csv shows 1,000 rows', (await page.locator('.csv-view:visible .csv-view-meta').innerText()).includes('1,000 rows'));
162 // form edit round-trip: bump quick.sample's num_samples, run, check
163 // the recorded sampling_opts.json (proves form → YAML → runner)
164 await explorerItem('quick.sample').click();
165 await page.waitForTimeout(600);
166 // scope to the visible pane: the fit.sample form stays in the DOM
167 const quickForm = page.locator('.sample-editor:visible');
168 const samplesInput = quickForm.locator('.sample-params input').nth(2);
169 await samplesInput.fill('150');
170 await samplesInput.blur();
171 await page.waitForTimeout(300);
172 check('form edit marks tab dirty', await page.locator('.mw-tab.dirty').count() >= 1);
173 await quickForm.locator('.sample-run-button').click();
174 check('quick.sample sampling completed', await waitForOutput('files to /quick.out', 120_000));
175 // the quick.out dashboard auto-opens and reflects the form edit
176 await page.waitForTimeout(1500);
177 const quickSubtitle = normalize(await page.locator('.results-view:visible .results-subtitle').innerText());
178 check('dashboard records form-edited num_samples', quickSubtitle.includes('150 samples'));
179 await page.screenshot({ path: out + '/s-opts.png' });
180 }
182 if (haveServer) {
183 // after a reload, the dashboard reopens from the persisted output folder
184 await page.reload({ waitUntil: 'networkidle' });
185 await page.waitForTimeout(2500);
186 await explorerItem('fit.out').click();
187 await page.waitForTimeout(400);
188 await explorerItem('run.json').first().click();
189 await page.waitForTimeout(1500);
190 check('dashboard reopens after reload', await page.locator('.results-view:visible .results-table td', { hasText: /^beta$/ }).count() === 1);
191 }
193 // project lifecycle basics
194 await page.getByTitle('Back to projects').click();
195 await page.waitForTimeout(800);
196 check('back on landing', await page.locator('.landing-project').count() === 1);
197 await page.getByRole('button', { name: 'New project', exact: true }).click();
198 await page.waitForTimeout(1500);
199 check('empty project opens fit.sample form', await page.locator('.sample-editor').count() === 1);
200 await page.goto('http://127.0.0.1:4173/#/project/nope1234', { waitUntil: 'networkidle' });
201 await page.waitForTimeout(800);
202 check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1);
204 if (errors.length) {
205 console.log('page errors:');
206 for (const e of errors.slice(0, 10)) console.log(' ' + e);
207 } else {
208 console.log('no page errors');
209 }
210} finally {
211 await browser.close();
212 previewProc.kill();
213}