e3823ce470
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.
120 lines
3.6 KiB
JavaScript
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;
|
|
}
|