/ concept-collection / stan-web-ide
Sign in
concept-collection / stan-web-ide
stan-web-ide / scripts / smoke.mjs
183 lines · 9.1 KBBlameHistoryRaw
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));
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 // summary.csv opens as the CSV table view
123 await explorerItem('summary.csv').click();
124 await page.waitForTimeout(800);
125 const summaryTable = page.locator('.csv-view:visible');
126 check('summary opens as csv table', await summaryTable.locator('.csv-table').count() === 1);
127 check('summary table has beta row', await summaryTable.locator('td', { hasText: /^beta$/ }).count() === 1);
128 check('summary table meta shows rows', (await summaryTable.locator('.csv-view-meta').innerText()).includes('rows × 10 columns'));
129 // click-to-sort by rhat
130 await summaryTable.locator('th', { hasText: /^rhat/ }).click();
131 await page.waitForTimeout(300);
132 check('sort by rhat', (await summaryTable.locator('.csv-view-meta').innerText()).includes('sorted by rhat'));
133 await page.screenshot({ path: out + '/s-summary.png' });
134 // a draws file renders too (1000 rows)
135 await explorerItem('chain_1.csv').click();
136 await page.waitForTimeout(800);
137 check('chain csv shows 1,000 rows', (await page.locator('.csv-view:visible .csv-view-meta').innerText()).includes('1,000 rows'));
139 // form edit round-trip: bump quick.sample's num_samples, run, check
140 // the recorded sampling_opts.json (proves form → YAML → runner)
141 await explorerItem('quick.sample').click();
142 await page.waitForTimeout(600);
143 // scope to the visible pane: the fit.sample form stays in the DOM
144 const quickForm = page.locator('.sample-editor:visible');
145 const samplesInput = quickForm.locator('.sample-params input').nth(2);
146 await samplesInput.fill('150');
147 await samplesInput.blur();
148 await page.waitForTimeout(300);
149 check('form edit marks tab dirty', await page.locator('.mw-tab.dirty').count() >= 1);
150 await quickForm.locator('.sample-run-button').click();
151 check('quick.sample sampling completed', await waitForOutput('files to /out/quick', 120_000));
152 await page.waitForTimeout(800);
153 await explorerItem('quick').click();
154 await page.waitForTimeout(400);
155 // both out/fit and out/quick hold one; /out/quick sorts last
156 await explorerItem('sampling_opts.json').last().click();
157 await page.waitForTimeout(800);
158 const opts = normalize(await page.locator('.view-lines').first().innerText());
159 check('sampling_opts records form-edited num_samples', opts.includes('"num_samples": 150'));
160 await page.screenshot({ path: out + '/s-opts.png' });
161 }
163 // project lifecycle basics
164 await page.getByTitle('Back to projects').click();
165 await page.waitForTimeout(800);
166 check('back on landing', await page.locator('.landing-project').count() === 1);
167 await page.getByRole('button', { name: 'New project', exact: true }).click();
168 await page.waitForTimeout(1500);
169 check('empty project opens fit.sample form', await page.locator('.sample-editor').count() === 1);
170 await page.goto('http://127.0.0.1:4173/#/project/nope1234', { waitUntil: 'networkidle' });
171 await page.waitForTimeout(800);
172 check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1);
174 if (errors.length) {
175 console.log('page errors:');
176 for (const e of errors.slice(0, 10)) console.log(' ' + e);
177 } else {
178 console.log('no page errors');
179 }
180} finally {
181 await browser.close();
182 previewProc.kill();
moveopenescclose