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 e7ccf467..f08ec966 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 6674cd20..ef717dbb 100644 --- a/src/routes/row/+page.svelte +++ b/src/routes/row/+page.svelte @@ -14,6 +14,7 @@ finish lands with the next slice. -->