1// End-to-end smoke test: landing → sample project → run .m scripts with
2// numbl (streamed output, figures in the side bar, stop button, file
3// write-back) → project lifecycle basics.
4import { chromium } from 'playwright';
5import { spawn } from 'node:child_process';
7const root = new URL('..', import.meta.url).pathname;
8const out = process.argv[2] ?? '.';
9const previewProc = spawn('npx', ['vite', 'preview', '--port', '4181', '--strictPort'], { stdio: 'ignore', cwd: root });
10await new Promise((r) => setTimeout(r, 1500));
12const browser = await chromium.launch({ channel: 'chrome', headless: true });
13const page = await browser.newPage({ viewport: { width: 1500, height: 900 } });
14const errors = [];
15page.on('pageerror', (e) => errors.push(e.message));
16page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
17const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`);
18// monaco renders spaces as U+00A0 — normalize before matching
19const outputText = async () => (await page.locator('.mw-output').innerText()).replace(/ /g, ' ');
20const waitForOutput = async (needle, timeout = 20000) => {
21 const start = Date.now();
22 while (Date.now() - start < timeout) {
23 if ((await outputText()).includes(needle)) return true;
24 await page.waitForTimeout(250);
25 }
26 return false;
27};
29try {
30 await page.goto('http://localhost:4181/', { waitUntil: 'networkidle' });
31 await page.waitForTimeout(1200);
32 check('landing renders', await page.locator('.landing-empty').count() === 1);
33 await page.screenshot({ path: out + '/n-landing.png' });
35 // sample project → IDE opens waves.m
36 await page.getByRole('button', { name: 'New sample project' }).click();
37 await page.waitForTimeout(1800);
38 check('URL has project route', /#\/project\/[a-z0-9]+/i.test(page.url()));
39 check('IDE opened with waves.m', await page.locator('.mw-tab-label', { hasText: 'waves.m' }).count() === 1);
41 // run waves.m: streamed text + Figure 1 in the secondary side bar
42 await page.locator('.mw-tab-action[title="numbl"]').click();
43 check('run output streamed', await waitForOutput('peak amplitude'));
44 await page.waitForTimeout(1500); // lazy graphics stack + first render
45 check('figure view appears', await page.locator('.mw-auxbar .mw-panel-tab', { hasText: 'Figure 1' }).count() === 1);
46 check('figure canvas rendered', await page.locator('.mw-auxbar canvas').count() >= 1);
47 await page.screenshot({ path: out + '/n-waves.png' });
49 // cross-folder run: scripts/surface_demo.m (addpath + 3-D surface)
50 await page.getByText('scripts', { exact: true }).click();
51 await page.waitForTimeout(400);
52 await page.getByText('surface_demo.m', { exact: true }).click();
53 await page.waitForTimeout(600);
54 await page.locator('.mw-tab-action[title="numbl"]').click();
55 check('surface demo output', await waitForOutput('surface rendered'));
56 await page.waitForTimeout(1000);
57 check('surface figure view', await page.locator('.mw-auxbar .mw-panel-tab', { hasText: 'Figure 1' }).count() === 1);
58 await page.screenshot({ path: out + '/n-surface.png' });
60 // stop button: animation.m runs for seconds; ▶ becomes ⏹ while running
61 await page.getByText('animation.m', { exact: true }).click();
62 await page.waitForTimeout(600);
63 await page.locator('.mw-tab-action[title="numbl"]').click();
64 const stopButton = page.locator('.mw-tab-action[title="Stop numbl"]');
65 let stopSeen = false;
66 for (let i = 0; i < 20 && !(stopSeen = await stopButton.count() === 1); i++) {
67 await page.waitForTimeout(150);
68 }
69 check('stop button shown while running', stopSeen);
70 if (stopSeen) {
71 // force: the aux bar reveal shifts layout while frames stream
72 await page.waitForTimeout(1000);
73 await stopButton.click({ force: true });
74 }
75 await page.waitForTimeout(700);
76 check('play button back after stop', await page.locator('.mw-tab-action[title="numbl"]').count() === 1);
78 // file write-back: write_results.m creates scripts/results/summary.txt
79 await page.getByText('write_results.m', { exact: true }).click();
80 await page.waitForTimeout(600);
81 await page.locator('.mw-tab-action[title="numbl"]').click();
82 check('write_results output', await waitForOutput('wrote results/summary.txt'));
83 await page.waitForTimeout(800);
84 await page.getByText('results', { exact: true }).click();
85 await page.waitForTimeout(400);
86 check('written file appears in explorer', await page.getByText('summary.txt', { exact: true }).count() === 1);
87 await page.screenshot({ path: out + '/n-results.png' });
89 // project lifecycle basics
90 await page.getByTitle('Back to projects').click();
91 await page.waitForTimeout(800);
92 check('back on landing', await page.locator('.landing-project').count() === 1);
93 await page.getByRole('button', { name: 'New project', exact: true }).click();
94 await page.waitForTimeout(1500);
95 check('empty project opens main.m', await page.locator('.mw-tab-label', { hasText: 'main.m' }).count() === 1);
96 await page.goto('http://localhost:4181/#/project/nope1234', { waitUntil: 'networkidle' });
97 await page.waitForTimeout(800);
98 check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1);
100 if (errors.length) {
101 console.log('page errors:');
102 for (const e of errors.slice(0, 10)) console.log(' ' + e);
103 } else {
104 console.log('no page errors');
105 }
106} finally {
107 await browser.close();
108 previewProc.kill();
109}