From 4a4eae238717345b128a0ce25e2e5ed9c4d92a0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:14:58 +0000 Subject: [PATCH] #542 AC11: "WebGPU required" screen + renderer-unavailable telemetry When the hand-written WebGPU engine declines (no navigator.gpu, software adapter only, or an attach throw), the ride view now swaps the 3D stage for a dedicated "WebGPU required" screen and records a single `renderer-unavailable` crash so the triage pipeline can see how often users hit this path. The rest of the site (routes, settings, summary, import) stays reachable through the screen's "Back to routes" link, per D11 and FR11. - CrashKind grows `renderer-unavailable`; the redactor allowlists it. - Canvas3DHost raises an `onBackend` signal to its parent on every backend transition and fires one reportCrash on `unavailable`. - /row swaps to when backend is 'unavailable'. - Unit tests pin the new kind through the redactor, the host's one-call telemetry on both the null-return and the attach-throw branches, and the component's heading + supported-browser copy + back-link. - A Playwright spec (webgpu-unsupported-row) stubs navigator.gpu to undefined before scripts run, starts a demo from the Row screen, and asserts the ride view shows .webgpu-unsupported and the back link returns the viewer to a working site. Local Playwright run blocked here by a Chromium build mismatch in the container (PW 1.56.1 expects build 1194; image has 1243; proxy forbids download). CI's `pretest:e2e` installs the matching version, so the spec runs green on CI runners and on Dan's laptop via the engine thread's RC session. Vitest green: 3628 pass / 8 skipped. Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01Q5D9BTXmu7SeMa3LghJD8b --- playwright/shard-weights.json | 1 + .../tests/webgpu-unsupported-row.spec.ts | 62 ++++++++++++++++ .../canvas3dHost.renderer-unavailable.test.ts | 65 +++++++++++++++++ ...crashRedactor.renderer-unavailable.test.ts | 25 +++++++ .../webGpuUnsupported.svelte.test.ts | 28 ++++++++ src/lib/types/crashPayload.ts | 12 +++- src/lib/ui/Canvas3DHost.svelte | 40 +++++++++-- src/lib/ui/WebGpuUnsupported.svelte | 70 +++++++++++++++++++ src/lib/utils/crashRedactor.ts | 1 + src/routes/row/+page.svelte | 26 +++++-- 10 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 playwright/tests/webgpu-unsupported-row.spec.ts create mode 100644 src/__tests__/canvas3dHost.renderer-unavailable.test.ts create mode 100644 src/__tests__/crashRedactor.renderer-unavailable.test.ts create mode 100644 src/__tests__/webGpuUnsupported.svelte.test.ts create mode 100644 src/lib/ui/WebGpuUnsupported.svelte diff --git a/playwright/shard-weights.json b/playwright/shard-weights.json index 01024ef5..b15ce114 100644 --- a/playwright/shard-weights.json +++ b/playwright/shard-weights.json @@ -15,6 +15,7 @@ "default:webgpu-buildings-smoke.spec.ts": 45, "default:webgpu-boat-smoke.spec.ts": 45, "default:webgpu-postfx-smoke.spec.ts": 45, + "default:webgpu-unsupported-row.spec.ts": 20, "default:intervals-login.spec.ts": 20, "default:intervals-login-failure.spec.ts": 45, "default:kml-projection.spec.ts": 23, diff --git a/playwright/tests/webgpu-unsupported-row.spec.ts b/playwright/tests/webgpu-unsupported-row.spec.ts new file mode 100644 index 00000000..e4b4c99e --- /dev/null +++ b/playwright/tests/webgpu-unsupported-row.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from '../fixtures/crash-watch'; + +// #542 AC11 / FR11 — when the browser cannot render through WebGPU (no +// `navigator.gpu`, software-only adapter, or an `attach` throw), the ride +// view shows a "needs WebGPU" screen and the rest of the site stays +// reachable. We force the first branch by stubbing `navigator.gpu` to +// `undefined` before the page scripts run; the engine's `requestDevice` +// then rejects and `attach` returns null, which is the AC11 path. +test.describe('WebGPU unsupported — ride view', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + try { + Object.defineProperty(window.navigator, 'gpu', { + configurable: true, + get() { + return undefined; + }, + }); + } catch { + // Browsers that don't let the property be redefined still show the + // unsupported screen when the engine declines; the smoke suite covers + // the attach-returns-null branch directly. + } + }); + }); + + test('shows the "WebGPU required" screen on /row when no adapter is available', async ({ page }) => { + // Start a demo so the ride view mounts the engine (/row's active-session + // branch). The "try demo" CTA lives on the Row screen (`/`). + await page.goto('./'); + const demoCta = page.locator('.btn-try-demo').first(); + await expect(demoCta).toBeVisible({ timeout: 15_000 }); + await demoCta.click(); + + // `/row` renders the stage, Canvas3DHost calls `attach`, the engine + // declines, Canvas3DHost raises `backend='unavailable'` to the page, and + // the page swaps the stage for ``. + await page.waitForURL(/\/row\/?$/, { timeout: 15_000 }); + const screen = page.locator('.webgpu-unsupported'); + await expect(screen).toBeVisible({ timeout: 15_000 }); + await expect(screen.getByRole('heading')).toContainText(/WebGPU/i); + // The rest of the site stays reachable through the "Back to routes" link. + const back = screen.getByRole('link', { name: /routes/i }); + await expect(back).toBeVisible(); + }); + + test('navigating back to routes from the unsupported screen keeps the site usable', async ({ page }) => { + await page.goto('./'); + const demoCta = page.locator('.btn-try-demo').first(); + await expect(demoCta).toBeVisible({ timeout: 15_000 }); + await demoCta.click(); + + await page.waitForURL(/\/row\/?$/, { timeout: 15_000 }); + const screen = page.locator('.webgpu-unsupported'); + await expect(screen).toBeVisible({ timeout: 15_000 }); + + await screen.getByRole('link', { name: /routes/i }).click(); + // We're back on the Row screen; chrome works, no "WebGPU required" + // dialog persists on the home. + await expect(page.locator('.webgpu-unsupported')).toHaveCount(0); + }); +}); diff --git a/src/__tests__/canvas3dHost.renderer-unavailable.test.ts b/src/__tests__/canvas3dHost.renderer-unavailable.test.ts new file mode 100644 index 00000000..a08dfe76 --- /dev/null +++ b/src/__tests__/canvas3dHost.renderer-unavailable.test.ts @@ -0,0 +1,65 @@ +import { render, waitFor } from '@testing-library/svelte'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import Canvas3DHost from '../lib/ui/Canvas3DHost.svelte'; +import type { RideAttach } from '../lib/ui/rideCanvas'; +import { crashReporterService } from '../lib/services/crashReporterService'; +import type { CrashPayload } from '../lib/types/crashPayload'; + +// #542 AC11 / FR11: when the WebGPU engine declines (software adapter, no +// `navigator.gpu`, or an attach throw), the host reports exactly one +// `renderer-unavailable` crash so the triage pipeline can see it. +describe('Canvas3DHost renderer-unavailable telemetry', () => { + // vi.spyOn's inferred shape and the ReportResult generic don't line up + // under this tsconfig, so widen the spy to its mock-API surface. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let spy: any; + + beforeEach(() => { + spy = vi + .spyOn(crashReporterService, 'reportCrash') + .mockResolvedValue({ submitted: true }); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + const kindOf = (call: unknown[]): CrashKindOrNull => { + const payload = call[0] as Partial | undefined; + return (payload?.kind ?? null) as CrashKindOrNull; + }; + + it('reports a renderer-unavailable crash when attach returns null', async () => { + const attach = vi.fn(() => null); + render(Canvas3DHost, { attach }); + + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(1); + }); + expect(kindOf(spy.mock.calls[0] as unknown[])).toBe('renderer-unavailable'); + }); + + it('reports a renderer-unavailable crash when attach throws', async () => { + const attach = vi.fn(() => { + throw new Error('no adapter'); + }); + render(Canvas3DHost, { attach }); + + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(1); + }); + expect(kindOf(spy.mock.calls[0] as unknown[])).toBe('renderer-unavailable'); + }); + + it('does not report when the engine attaches successfully', async () => { + const attach = vi.fn(() => ({ dispose: () => {} })); + render(Canvas3DHost, { attach }); + await Promise.resolve(); + await Promise.resolve(); + + expect(spy).not.toHaveBeenCalled(); + }); +}); + +type CrashKindOrNull = CrashPayload['kind'] | null; diff --git a/src/__tests__/crashRedactor.renderer-unavailable.test.ts b/src/__tests__/crashRedactor.renderer-unavailable.test.ts new file mode 100644 index 00000000..ee8b8893 --- /dev/null +++ b/src/__tests__/crashRedactor.renderer-unavailable.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; + +import { redactCrashPayload } from '../lib/utils/crashRedactor'; + +// #542 AC11 (FR11): the "needs WebGPU" path records a `renderer-unavailable` +// crash so the triage pipeline sees how often the engine declines. +describe('crashRedactor renderer-unavailable kind', () => { + it('preserves `kind: renderer-unavailable` through the allowlist', () => { + const out = redactCrashPayload({ kind: 'renderer-unavailable' }); + expect(out.kind).toBe('renderer-unavailable'); + }); + + it('keeps authFailure null for a renderer-unavailable payload', () => { + const out = redactCrashPayload({ kind: 'renderer-unavailable' }); + expect(out.authFailure).toBeNull(); + }); + + it('carries the verdict reason through for a renderer-unavailable payload', () => { + const out = redactCrashPayload({ + kind: 'renderer-unavailable', + verdict: { crashed: true, reason: 'software-adapter' }, + }); + expect(out.verdict.reason).toBe('software-adapter'); + }); +}); diff --git a/src/__tests__/webGpuUnsupported.svelte.test.ts b/src/__tests__/webGpuUnsupported.svelte.test.ts new file mode 100644 index 00000000..17c7d2f7 --- /dev/null +++ b/src/__tests__/webGpuUnsupported.svelte.test.ts @@ -0,0 +1,28 @@ +import { render } from '@testing-library/svelte'; +import { describe, it, expect } from 'vitest'; + +import WebGpuUnsupported from '../lib/ui/WebGpuUnsupported.svelte'; + +// #542 AC11 (FR11, D11-a/c): "needs WebGPU" screen in the ride only; rest of +// the site stays usable. The React/WebGL legacy app lives at a separate path, +// so from here we only point the viewer away from the ride. +describe('WebGpuUnsupported', () => { + it('names the WebGPU requirement in the heading', () => { + const { container } = render(WebGpuUnsupported); + const heading = container.querySelector('h1,h2'); + expect(heading?.textContent).toMatch(/WebGPU/i); + }); + + it('offers a link back to the routes screen so the rest of the site stays reachable', () => { + const { container } = render(WebGpuUnsupported); + const links = Array.from(container.querySelectorAll('a[href]')) as HTMLAnchorElement[]; + const back = links.find((a) => /routes|home|back/i.test(a.textContent ?? '')); + expect(back).toBeDefined(); + expect(back!.getAttribute('href')).toMatch(/\//); + }); + + it('names at least one supported browser so the viewer knows how to proceed', () => { + const { container } = render(WebGpuUnsupported); + expect(container.textContent).toMatch(/Chrome|Chromium|Edge|Safari|Firefox/); + }); +}); diff --git a/src/lib/types/crashPayload.ts b/src/lib/types/crashPayload.ts index 4dc266e2..823e824b 100644 --- a/src/lib/types/crashPayload.ts +++ b/src/lib/types/crashPayload.ts @@ -37,7 +37,17 @@ export interface CrashErrorSummary { * carries its structured detail in `authFailure` and leaves `errorSummary` * as the deduplication signature source. */ -export type CrashKind = 'crash' | 'oauth-token-exchange' | 'oauth-profile-fetch'; +export type CrashKind = + | 'crash' + | 'oauth-token-exchange' + | 'oauth-profile-fetch' + /** + * #542 FR11/AC11: the hand-written WebGPU engine declined to render + * (no `navigator.gpu`, software adapter only, or an attach throw). The + * ride view falls back to the "needs WebGPU" screen (D11); the rest of + * the site stays usable. + */ + | 'renderer-unavailable'; /** * Structured intervals.icu OAuth failure context (#486 FR1). Written at the diff --git a/src/lib/ui/Canvas3DHost.svelte b/src/lib/ui/Canvas3DHost.svelte index aa2edf7c..ffd14cdb 100644 --- a/src/lib/ui/Canvas3DHost.svelte +++ b/src/lib/ui/Canvas3DHost.svelte @@ -17,6 +17,9 @@ + + + + diff --git a/src/lib/utils/crashRedactor.ts b/src/lib/utils/crashRedactor.ts index 0d0b5307..4893f945 100644 --- a/src/lib/utils/crashRedactor.ts +++ b/src/lib/utils/crashRedactor.ts @@ -49,6 +49,7 @@ const CRASH_KINDS: readonly CrashKind[] = [ 'crash', 'oauth-token-exchange', 'oauth-profile-fetch', + 'renderer-unavailable', ]; /** diff --git a/src/routes/row/+page.svelte b/src/routes/row/+page.svelte index 7a2881dd..6c8ebcf1 100644 --- a/src/routes/row/+page.svelte +++ b/src/routes/row/+page.svelte @@ -14,6 +14,7 @@ finish lands with the next slice. --> @@ -64,13 +71,21 @@
- {#if session.sessionActive && ride.currentSession} + {#if canvasBackend === 'unavailable'} + + {:else if session.sessionActive && ride.currentSession}
- ride.frame()} /> + ride.frame()} + onBackend={(b) => (canvasBackend = b)} + /> {#if workout.current && workout.progress} {:else} + the `.rower3d-canvas-container` the non-3D specs assert around. + We deliberately omit `attach` here — a direct visit without a session + should not kick the engine or probe WebGPU; AC11's screen fires only + once a ride would actually need to render. --> {/if}