Files
Oxicloud/tests/e2e/scripts/finish-codegen.mjs
T
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

121 lines
4.5 KiB
JavaScript

// Assemble a real spec from a codegen recording.
//
// Usage: node scripts/finish-codegen.mjs <templatePath> <outName> (recording on stdin)
//
// Takes the recorder template the user started from (scenarios/codegen/<x>.spec.ts),
// keeps its SETUP (everything before page.pause(), minus test.setTimeout), and
// splices in the recorded steps read from stdin — writing scenarios/<outName>.spec.ts.
//
// The pasted recording may be either bare action lines, e.g.
// await page.getByText('…').click();
// or a whole codegen file, e.g.
// import { test, expect } from '@playwright/test';
// test('test', async ({ page }) => { await page.…; });
// In the latter case the import + test(...) wrapper are stripped, leaving the body.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const argv = process.argv.slice(2);
/** Sanitize a name to scenarios/<base>.spec.ts; exit non-zero if invalid/taken. */
function resolveOutPath(rawName) {
const base = (rawName ?? '')
.trim()
.replace(/\.spec\.ts$/, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!base) {
console.error('finish-codegen: empty/invalid output name');
process.exit(1);
}
const outPath = `scenarios/${base}.spec.ts`;
if (existsSync(outPath)) {
console.error(`finish-codegen: ${outPath} already exists — pick another name`);
process.exit(1);
}
return { base, outPath };
}
// `--resolve <name>`: validate a name + print its spec path (no stdin, no write).
// The just recipe loops this to re-prompt until a free, valid name is given.
if (argv[0] === '--resolve') {
console.log(resolveOutPath(argv[1]).outPath);
process.exit(0);
}
const [templatePath, rawOutName] = argv;
if (!templatePath || !rawOutName) {
console.error('usage: finish-codegen.mjs <templatePath> <outName> (recording on stdin)');
console.error(' or: finish-codegen.mjs --resolve <outName>');
process.exit(1);
}
const recording = readFileSync(0, 'utf-8'); // stdin
const template = readFileSync(templatePath, 'utf-8');
const { base, outPath } = resolveOutPath(rawOutName);
/** Body between the first `=> {` and the final `});` of a test file/snippet. */
function callbackBody(src) {
const m = src.match(/=>\s*\{([\s\S]*)\}\s*\)\s*;?\s*$/);
return m ? m[1] : null;
}
/** Re-indent non-empty lines to a common 2-space base. */
function reindent(lines, spaces = 2) {
const body = lines.filter((l) => l.trim());
const min = body.length ? Math.min(...body.map((l) => l.match(/^\s*/)[0].length)) : 0;
const pad = ' '.repeat(spaces);
return lines.map((l) => (l.trim() ? pad + l.slice(min) : ''));
}
// ── 1. helpers import from the template (fix ../helpers → ./helpers) ──────────
const helperImport =
(template.match(/^import .*from ['"]\.\.\/helpers['"];?$/m) || [])[0]?.replace(
/\.\.\/helpers/,
'./helpers',
) ?? "import { test } from './helpers';";
// ── 2. setup lines = template body before page.pause(), minus setTimeout ──────
const tBody = callbackBody(template);
const setupLines = reindent(
(tBody ?? '')
.split('\n')
.filter((l) => l.trim() && !/page\.pause\(/.test(l) && !/test\.setTimeout\(/.test(l)),
);
// ── 3. recorded steps from stdin (strip wrapper if a whole file was pasted) ───
let recBody = recording;
if (/^\s*(import\b|test\s*\()/.test(recording)) {
recBody = callbackBody(recording) ?? recording;
}
let actionLines = recBody.split('\n').filter((l) => !/^\s*import\s/.test(l));
while (actionLines.length && !actionLines[0].trim()) actionLines.shift();
while (actionLines.length && !actionLines[actionLines.length - 1].trim()) actionLines.pop();
actionLines = reindent(actionLines);
if (!actionLines.length) {
console.error('finish-codegen: no recorded steps found in the pasted input');
process.exit(1);
}
// ── 4. compose ───────────────────────────────────────────────────────────────
const imports = [helperImport];
if (/\bexpect\(/.test(actionLines.join('\n'))) {
imports.push("import { expect } from '@playwright/test';");
}
const out = [
...imports,
'',
`test('${base.replace(/'/g, "\\'")}', async ({ page }) => {`,
...setupLines,
'',
' // recorded steps',
...actionLines,
'});',
'',
].join('\n');
writeFileSync(outPath, out);
console.error(`✓ wrote ${outPath}`);
console.log(outPath); // stdout = machine-readable path; the recipe runs it