diff --git a/src/ViperIDE.html b/src/ViperIDE.html index d7dbd76..0900c1a 100644 --- a/src/ViperIDE.html +++ b/src/ViperIDE.html @@ -32,6 +32,7 @@
+ diff --git a/src/app.js b/src/app.js index f91634b..3a7a01f 100644 --- a/src/app.js +++ b/src/app.js @@ -491,6 +491,16 @@ function wirePort(new_port) { new_port.onDisconnect(handlePortDisconnect) } +/* The "starting" sign: a spinner and a word beside the connect buttons, and + the clicked button pulsing. Cleared by the caller on success and failure. */ +function setBooting(on, type) { + QID('boot-status').hidden = !on + QID('boot-status-text').textContent = (type === 'sim') ? 'Starting simulator…' : 'Connecting…' + for (const t of ['sim', 'ws', 'ble', 'usb']) { + QID(`btn-conn-${t}`).classList.toggle('booting', on && t === type) + } +} + export async function connectDevice(type) { if (port) { const msg = (deviceState === 'reconnecting') ? 'Stop reconnecting and disconnect?' @@ -500,12 +510,26 @@ export async function connectDevice(type) { return } - const new_port = await prepareNewPort(type) - if (!new_port) { return } - /* A ?vm=1 link arrives as type 'ws' but hands back the simulator; from - here on everything - connType, the stage, audio, the button highlight - - should treat it as what it is. */ - if (new_port.isSimulator) { type = 'sim' } + /* Booting the simulator takes a while, and most of it is inside + prepareNewPort (requestAccess starts the VM): say so from the click + until the prompt is up, whether that ends in a session or an error. + Other connections only start once their picker has been answered. */ + setBooting(type === 'sim', type) + try { + const new_port = await prepareNewPort(type) + if (!new_port) { return } + /* A ?vm=1 link arrives as type 'ws' but hands back the simulator; from + here on everything - connType, the stage, audio, the button highlight - + should treat it as what it is. */ + if (new_port.isSimulator) { type = 'sim' } + setBooting(true, type) + await bringUpPort(new_port, type) + } finally { + setBooting(false) + } +} + +async function bringUpPort(new_port, type) { // Connect new port try { await new_port.connect() diff --git a/src/app_common.css b/src/app_common.css index 6ec114e..d992252 100644 --- a/src/app_common.css +++ b/src/app_common.css @@ -152,12 +152,23 @@ button.reconnecting { animation: reconnecting-pulse 1.2s ease-in-out infinite; } +/* The simulator or a board is coming up after a click on Connect */ +button.booting { + animation: reconnecting-pulse 1.2s ease-in-out infinite; +} + +#boot-status { + font-size: 0.9rem; + opacity: 0.85; + white-space: nowrap; +} + @keyframes reconnecting-pulse { 50% { opacity: 0.25; } } @media (prefers-reduced-motion: reduce) { - button.reconnecting { + button.reconnecting, button.booting { animation: none; opacity: 0.6; } diff --git a/test/browser/booting-indicator.spec.js b/test/browser/booting-indicator.spec.js new file mode 100644 index 0000000..e835bbb --- /dev/null +++ b/test/browser/booting-indicator.spec.js @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// +// Launching the simulator used to show nothing until the board appeared, so +// the click looked ignored. The page now shows a "Starting" status from the +// click until the prompt is up, and clears it when the boot fails too. +import { test, expect } from '@playwright/test' + +test('a booting indicator shows from the click until the prompt is up', async ({ page }) => { + await page.goto('/') + const status = page.locator('#boot-status') + await expect(status).toBeHidden() + await page.click('#btn-conn-sim') + await expect(status).toBeVisible({ timeout: 2000 }) + await expect(status).toContainText('Starting') + await expect(page.locator('#btn-conn-sim')).toHaveClass(/booting/) + await expect(page.locator('#xterm')).toContainText('>>>', { timeout: 45_000 }) + await expect(status).toBeHidden() + await expect(page.locator('#btn-conn-sim')).not.toHaveClass(/booting/) +}) + +test('the booting indicator shows, then clears, when the simulator fails to start', async ({ page }) => { + await page.route('**/micropython.wasm', (route) => route.abort()) + await page.goto('/') + // A failed boot can be over in well under a poll, so watch for the sign + // from inside the page instead of racing it. + await page.evaluate(() => { + const el = document.getElementById('boot-status') + window.__sawBooting = false + new MutationObserver(() => { if (!el.hidden) window.__sawBooting = true }) + .observe(el, { attributes: true, attributeFilter: ['hidden'] }) + }) + await page.click('#btn-conn-sim') + await expect.poll(() => page.evaluate(() => window.__sawBooting), { timeout: 30_000 }).toBe(true) + await expect(page.locator('#boot-status')).toBeHidden({ timeout: 30_000 }) + await expect(page.locator('#btn-conn-sim')).not.toHaveClass(/booting/) + await expect(page.locator('#xterm')).not.toContainText('>>>') +})