Files
Bradley Nelson e3823ce470 test(e2e): Playwright + Vitest coverage harness and test instrumentation
Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
2026-06-22 00:05:06 -06:00

120 lines
3.6 KiB
JavaScript

/**
* Svelte markup preprocessor that strips test-only `data-testid` attributes at
* compile time for production builds.
*
* The attribute name, value, and any `{expression}` are removed from the
* template *before* Svelte compiles it, so production output carries no trace —
* neither in the rendered DOM nor in the JS bundle (no `'data-testid'` string
* and no attribute-setting call survive). A runtime guard could only blank the
* value; this removes the attribute outright.
*
* The attribute is kept intact when:
* - `VITE_E2E=1` is set (the e2e image / `just fe-build-e2e` build), or
* - the build is not a production build (the `vite dev` server),
* so Playwright and `playwright codegen` can rely on `getByTestId`.
*
* Only the markup *between* `<script>`/`<style>` blocks is scanned, so the token
* `data-testid` appearing inside component logic or styles is never touched.
*/
const ATTR = 'data-testid';
/** Strip only for production builds that didn't opt into test ids. */
function shouldStrip() {
if (process.env.VITE_E2E === '1') return false;
return process.env.NODE_ENV === 'production';
}
/** @returns {import('svelte/compiler').PreprocessorGroup} */
export function stripTestId() {
return {
name: 'strip-testid',
markup({ content }) {
if (!shouldStrip() || !content.includes(ATTR)) return;
return { code: stripOutsideBlocks(content) };
}
};
}
/** Run the attribute removal on markup only, leaving `<script>`/`<style>` verbatim. */
function stripOutsideBlocks(source) {
const blockRe = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
let result = '';
let last = 0;
let m;
while ((m = blockRe.exec(source)) !== null) {
result += removeAttr(source.slice(last, m.index));
result += m[0];
last = m.index + m[0].length;
}
result += removeAttr(source.slice(last));
return result;
}
/**
* Remove every `data-testid` attribute occurrence from a markup fragment,
* handling `="literal"`, `='literal'`, `={balanced expression}` (respecting
* nested braces and string literals), unquoted values, and the bare boolean
* form. One preceding space is consumed so no double-space is left behind.
*/
function removeAttr(markup) {
let out = '';
let i = 0;
while (i < markup.length) {
const idx = markup.indexOf(ATTR, i);
if (idx === -1) {
out += markup.slice(i);
break;
}
const prev = markup[idx - 1];
const after = markup[idx + ATTR.length];
const boundaryBefore = prev === undefined || /\s/.test(prev);
const boundaryAfter = after === undefined || after === '=' || /[\s/>]/.test(after);
if (!boundaryBefore || !boundaryAfter) {
out += markup.slice(i, idx + ATTR.length);
i = idx + ATTR.length;
continue;
}
// Emit up to the attribute, dropping one leading whitespace char if present.
const cut = prev !== undefined && /\s/.test(prev) ? idx - 1 : idx;
out += markup.slice(i, cut);
// Advance past the attribute and its value (if any).
let j = idx + ATTR.length;
if (markup[j] === '=') {
j++;
const q = markup[j];
if (q === '"' || q === "'") {
j++;
while (j < markup.length && markup[j] !== q) j++;
j++; // consume the closing quote
} else if (q === '{') {
let depth = 0;
while (j < markup.length) {
const c = markup[j];
if (c === '"' || c === "'" || c === '`') {
const sq = c;
j++;
while (j < markup.length && markup[j] !== sq) {
if (markup[j] === '\\') j++;
j++;
}
} else if (c === '{') {
depth++;
} else if (c === '}') {
depth--;
if (depth === 0) {
j++;
break;
}
}
j++;
}
} else {
while (j < markup.length && !/[\s/>]/.test(markup[j])) j++;
}
}
i = j;
}
return out;
}