// End-to-end smoke test: landing → sample project → run .m scripts with // numbl (streamed output, figures in the side bar, stop button, file // write-back) → project lifecycle basics. import { chromium } from 'playwright'; import { spawn } from 'node:child_process'; const root = new URL('..', import.meta.url).pathname; const out = process.argv[2] ?? '.'; const previewProc = spawn('npx', ['vite', 'preview', '--port', '4181', '--strictPort'], { stdio: 'ignore', cwd: root }); await new Promise((r) => setTimeout(r, 1500)); const browser = await chromium.launch({ channel: 'chrome', headless: true }); const page = await browser.newPage({ viewport: { width: 1500, height: 900 } }); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); const check = (name, ok) => console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); // monaco renders spaces as U+00A0 — normalize before matching const outputText = async () => (await page.locator('.mw-output').innerText()).replace(/\u00a0/g, ' '); const waitForOutput = async (needle, timeout = 20000) => { const start = Date.now(); while (Date.now() - start < timeout) { if ((await outputText()).includes(needle)) return true; await page.waitForTimeout(250); } return false; }; try { await page.goto('http://localhost:4181/', { waitUntil: 'networkidle' }); await page.waitForTimeout(1200); check('landing renders', await page.locator('.landing-empty').count() === 1); await page.screenshot({ path: out + '/n-landing.png' }); // sample project → IDE opens waves.m await page.getByRole('button', { name: 'New sample project' }).click(); await page.waitForTimeout(1800); check('URL has project route', /#\/project\/[a-z0-9]+/i.test(page.url())); check('IDE opened with waves.m', await page.locator('.mw-tab-label', { hasText: 'waves.m' }).count() === 1); // run waves.m: streamed text + Figure 1 in the secondary side bar await page.locator('.mw-tab-action[title="numbl"]').click(); check('run output streamed', await waitForOutput('peak amplitude')); await page.waitForTimeout(1500); // lazy graphics stack + first render check('figure view appears', await page.locator('.mw-auxbar .mw-panel-tab', { hasText: 'Figure 1' }).count() === 1); check('figure canvas rendered', await page.locator('.mw-auxbar canvas').count() >= 1); await page.screenshot({ path: out + '/n-waves.png' }); // cross-folder run: scripts/surface_demo.m (addpath + 3-D surface) await page.getByText('scripts', { exact: true }).click(); await page.waitForTimeout(400); await page.getByText('surface_demo.m', { exact: true }).click(); await page.waitForTimeout(600); await page.locator('.mw-tab-action[title="numbl"]').click(); check('surface demo output', await waitForOutput('surface rendered')); await page.waitForTimeout(1000); check('surface figure view', await page.locator('.mw-auxbar .mw-panel-tab', { hasText: 'Figure 1' }).count() === 1); await page.screenshot({ path: out + '/n-surface.png' }); // stop button: animation.m runs for seconds; ▶ becomes ⏹ while running await page.getByText('animation.m', { exact: true }).click(); await page.waitForTimeout(600); await page.locator('.mw-tab-action[title="numbl"]').click(); const stopButton = page.locator('.mw-tab-action[title="Stop numbl"]'); let stopSeen = false; for (let i = 0; i < 20 && !(stopSeen = await stopButton.count() === 1); i++) { await page.waitForTimeout(150); } check('stop button shown while running', stopSeen); if (stopSeen) { // force: the aux bar reveal shifts layout while frames stream await page.waitForTimeout(1000); await stopButton.click({ force: true }); } await page.waitForTimeout(700); check('play button back after stop', await page.locator('.mw-tab-action[title="numbl"]').count() === 1); // file write-back: write_results.m creates scripts/results/summary.txt await page.getByText('write_results.m', { exact: true }).click(); await page.waitForTimeout(600); await page.locator('.mw-tab-action[title="numbl"]').click(); check('write_results output', await waitForOutput('wrote results/summary.txt')); await page.waitForTimeout(800); await page.getByText('results', { exact: true }).click(); await page.waitForTimeout(400); check('written file appears in explorer', await page.getByText('summary.txt', { exact: true }).count() === 1); await page.screenshot({ path: out + '/n-results.png' }); // mip: first run downloads mip core + the inpoly package await page.getByText('mip_demo.m', { exact: true }).click(); await page.waitForTimeout(600); await page.locator('.mw-tab-action[title="numbl"]').click(); check('mip demo output (install + run)', await waitForOutput('points inside: 201 / 2000', 90000)); await page.waitForTimeout(1000); check('mip demo figure', await page.locator('.mw-auxbar .mw-panel-tab', { hasText: 'Figure 1' }).count() === 1); await page.screenshot({ path: out + '/n-mip.png' }); check('system store persisted', await page.evaluate(async () => (await indexedDB.databases()).some((db) => db.name === 'numbl-web-ide-system'))); // mip: after a full reload the persisted install is reused const projectUrl = page.url(); await page.goto('about:blank'); await page.goto(projectUrl, { waitUntil: 'networkidle' }); await page.waitForTimeout(1500); await page.getByText('scripts', { exact: true }).click(); await page.waitForTimeout(400); await page.getByText('mip_demo.m', { exact: true }).click(); await page.waitForTimeout(600); const reuseStart = Date.now(); await page.locator('.mw-tab-action[title="numbl"]').click(); check('mip reuse after reload', await waitForOutput('points inside: 201 / 2000', 30000)); console.log(` (reuse run took ${((Date.now() - reuseStart) / 1000).toFixed(1)}s)`); // project lifecycle basics await page.getByTitle('Back to projects').click(); await page.waitForTimeout(800); check('back on landing', await page.locator('.landing-project').count() === 1); await page.getByRole('button', { name: 'New project', exact: true }).click(); await page.waitForTimeout(1500); check('empty project opens main.m', await page.locator('.mw-tab-label', { hasText: 'main.m' }).count() === 1); await page.goto('http://localhost:4181/#/project/nope1234', { waitUntil: 'networkidle' }); await page.waitForTimeout(800); check('unknown id falls back to landing', await page.locator('.landing-header').count() === 1); if (errors.length) { console.log('page errors:'); for (const e of errors.slice(0, 10)) console.log(' ' + e); } else { console.log('no page errors'); } } finally { await browser.close(); previewProc.kill(); }