test(e2e): run the SvelteKit SPA Playwright suite in CI

The e2e CI job ran the legacy `scenarios/*` specs against the vanilla `static/`
frontend that upstream has since removed, so it could never pass. Point CI at
this repo's SvelteKit SPA suite (tests/e2e/spa) and wire up what it needs:

- CI: build the release binary with `--features plugins` (the admin Plugins-tab
  specs exercise the WASM runtime) and run `npm run test:coverage`, building the
  instrumented SPA with COVERAGE=1 VITE_E2E=1 so the server serves the
  data-testid-instrumented build the specs drive.
- Coverage harness: target 127.0.0.1 instead of `localhost` (which resolves to
  ::1 first on CI runners while the server binds IPv4, so readiness never
  connected) and poll `/ready` for webServer readiness; tee start-server-spa.sh
  output to a log surfaced by an always-run CI step for diagnostics.
- Files page: restore a persistent breadcrumb home link (buildCrumbs returns
  only the path folders, so there was no "go home" affordance), and fix the
  `?file=` deep-link race where the viewer→URL effect stripped the param before
  the listing loaded — a bookmarked preview link now opens the viewer.

All 101 spa specs pass locally.
This commit is contained in:
Bradley Nelson
2026-06-22 10:34:47 -06:00
parent 0a906341e1
commit 02335c0680
6 changed files with 73 additions and 34 deletions
@@ -699,17 +699,26 @@
});
});
// viewer → URL: when closed from within (X / Esc / backdrop), drop the param
// (replaceState, so closing doesn't add a history entry).
// viewer → URL: when the user closes the viewer (X / Esc / backdrop), drop the
// `?file=` param (replaceState, so closing doesn't add a history entry). Only
// act on a genuine open→closed transition: on a cold deep link the viewer
// starts closed *with* the param while the listing is still loading, and
// stripping it there would race the URL→viewer effect above and the preview
// would never open.
let viewerWasOpen = false;
$effect(() => {
const open = viewerOpen;
const hasParam = page.url.searchParams.get('file') !== null;
if (!viewerOpen && hasParam) {
const url = new URL(page.url);
url.searchParams.delete('file');
// Same-origin URL object (see note above); resolve() can't type it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
}
untrack(() => {
if (viewerWasOpen && !open && hasParam) {
const url = new URL(page.url);
url.searchParams.delete('file');
// Same-origin URL object (see note above); resolve() can't type it.
// eslint-disable-next-line svelte/no-navigation-without-resolve
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
}
viewerWasOpen = open;
});
});
/**
@@ -1607,26 +1616,31 @@
</ListToolbar>
<nav class="breadcrumb" aria-label="Breadcrumb">
<!-- Persistent home link → the root listing (bare /files canonicalizes to
the user's drive root). `buildCrumbs` returns only the path folders,
so this is the single always-present "go home" affordance. -->
<a
href={resolve('/files')}
class="breadcrumb-item breadcrumb-home breadcrumb-link"
title={t('breadcrumb.home', 'Home')}
data-testid="files-breadcrumb-home-link"
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => session.homeFolderId && onCrumbDrop(e, session.homeFolderId)}
>
<Icon name={rootIcon} />
</a>
{#each crumbs as c, i (c.id)}
{#if i > 0}
<span class="breadcrumb-separator">&gt;</span>
{/if}
<span class="breadcrumb-separator">&gt;</span>
{#if i === crumbs.length - 1}
<span class="breadcrumb-item breadcrumb-current" class:breadcrumb-home={i === 0}>
{#if i === 0}<Icon name={rootIcon} />{/if}
{c.name}
</span>
<span class="breadcrumb-item breadcrumb-current">{c.name}</span>
{:else}
<a
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
class="breadcrumb-item breadcrumb-link"
class:breadcrumb-home={i === 0}
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
data-testid={i === 0 ? 'files-breadcrumb-home-link' : `files-breadcrumb-${c.id}`}
data-testid={`files-breadcrumb-${c.id}`}
ondragover={(e) => e.dataTransfer?.types.includes(DRAG_TYPE) && e.preventDefault()}
ondrop={(e) => onCrumbDrop(e, c.id)}
>
{#if i === 0}<Icon name={rootIcon} />{/if}
{c.name}
</a>
{/if}