Add M2 fixtures and content regression pack (#32)
Some checks failed
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m44s
CI / Auth e2e pack (push) Failing after 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 10s
Some checks failed
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m44s
CI / Auth e2e pack (push) Failing after 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 10s
Seed script extends the fixture matrix with a shared "Content Fixtures" pond (owned by fixture-user): an "Every Element" page covering every editor schema node and mark (#24), and a "Fixture Image" page with one real, servable uploaded image. "Every Element" loads a checked-in Yjs snapshot (prisma/fixtures/content-page.yjs) generated from a human-readable Markdown source (content-page.md) via a deterministic regeneration script (pinned Y.Doc clientID; refuses to write a snapshot that isn't a fixed point of the Markdown round-trip). New apps/web/e2e/content.spec.ts consolidates the M2 content regression pack: page lifecycle, editor basics, image paste, trash, and — the pack's actual regression pin — a byte-for-byte comparison of the fixture page's exported Markdown against the checked-in fixture. Verified this catches regressions: temporarily mutated docToMarkdown's heading serializer, rebuilt, re-seeded, confirmed the comparison failed, then reverted. This pack now runs in CI (a second step in the existing auth-e2e job, reusing its already-built-and-seeded stack) alongside the existing local-only feature packs. Closes #32
This commit is contained in:
parent
a645763679
commit
12035e2231
@ -59,6 +59,10 @@ jobs:
|
|||||||
- name: Translation key parity (de/en)
|
- name: Translation key parity (de/en)
|
||||||
run: pnpm i18n:check
|
run: pnpm i18n:check
|
||||||
|
|
||||||
|
# Job id/display name kept stable (branch-protection/status-check
|
||||||
|
# matching uses the reported "CI / Auth e2e pack" context) even though
|
||||||
|
# it now also runs the content pack (issue #32) against the same
|
||||||
|
# already-built-and-seeded stack, instead of spinning up a second one.
|
||||||
auth-e2e:
|
auth-e2e:
|
||||||
name: Auth e2e pack
|
name: Auth e2e pack
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -119,6 +123,11 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
|
E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/auth.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/auth.spec.ts
|
||||||
|
|
||||||
|
- name: Run content pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/content.spec.ts
|
||||||
|
|
||||||
- name: Dump server logs on failure
|
- name: Dump server logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: tail -50 /tmp/api.log /tmp/web.log || true
|
run: tail -50 /tmp/api.log /tmp/web.log || true
|
||||||
|
|||||||
@ -3,3 +3,6 @@ node_modules/
|
|||||||
coverage/
|
coverage/
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
# Must stay byte-identical to docToMarkdown's own output (issue #32) —
|
||||||
|
# Prettier's Markdown table/list opinions would break that fixed point.
|
||||||
|
apps/api/prisma/fixtures/content-page.md
|
||||||
|
|||||||
@ -11,7 +11,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
"db:seed": "tsx prisma/seed.ts"
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"fixtures:regenerate": "tsx prisma/fixtures/regenerate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dorfteich/shared": "workspace:*",
|
"@dorfteich/shared": "workspace:*",
|
||||||
|
|||||||
36
apps/api/prisma/fixtures/content-page.md
Normal file
36
apps/api/prisma/fixtures/content-page.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Fixture Heading 1
|
||||||
|
|
||||||
|
## Fixture Heading 2
|
||||||
|
|
||||||
|
### Fixture Heading 3
|
||||||
|
|
||||||
|
#### Fixture Heading 4
|
||||||
|
|
||||||
|
A paragraph with **bold**, *italic*, `inline code`, ~~strikethrough~~, and a [link](https://dorfteich.example/docs).
|
||||||
|
|
||||||
|
A line that breaks here,\
|
||||||
|
and continues on the next line.
|
||||||
|
|
||||||
|
> A blockquote for good measure.
|
||||||
|
|
||||||
|
```
|
||||||
|
a fenced code block
|
||||||
|
spanning two lines
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- bullet one
|
||||||
|
- bullet two
|
||||||
|
|
||||||
|
1. ordered one
|
||||||
|
2. ordered two
|
||||||
|
|
||||||
|
- [ ] unchecked task
|
||||||
|
- [x] checked task
|
||||||
|
|
||||||
|
| Header A | Header B |
|
||||||
|
| --- | --- |
|
||||||
|
| cell 1 | cell 2 |
|
||||||
|
|
||||||
|

|
||||||
BIN
apps/api/prisma/fixtures/content-page.yjs
Normal file
BIN
apps/api/prisma/fixtures/content-page.yjs
Normal file
Binary file not shown.
60
apps/api/prisma/fixtures/regenerate.ts
Normal file
60
apps/api/prisma/fixtures/regenerate.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Regenerates `content-page.yjs` from `content-page.md` (issue #32).
|
||||||
|
*
|
||||||
|
* `content-page.md` is the source of truth — a Markdown document
|
||||||
|
* deliberately covering every node type and mark in the editor schema
|
||||||
|
* (packages/shared/src/editor-schema/schema.ts, issue #24) and already a
|
||||||
|
* fixed point of the Markdown round-trip (`docToMarkdown(markdownToDoc(x))
|
||||||
|
* === x`); the seed script (`../seed.ts`) loads the checked-in `.yjs`
|
||||||
|
* binary directly rather than re-parsing Markdown on every run.
|
||||||
|
*
|
||||||
|
* Deterministic: `Y.Doc`'s `clientID` is normally randomized per instance,
|
||||||
|
* which would make the encoded update differ on every regeneration even
|
||||||
|
* for identical content. Pinning it to a fixed value makes the output a
|
||||||
|
* pure function of `content-page.md`, so re-running this without editing
|
||||||
|
* the Markdown produces a byte-identical `.yjs` (verified — re-run and
|
||||||
|
* `git diff` should show no changes).
|
||||||
|
*
|
||||||
|
* Run after editing `content-page.md`:
|
||||||
|
* pnpm --filter @dorfteich/api fixtures:regenerate
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { docToMarkdown, editorSchema, markdownToDoc } from '@dorfteich/shared';
|
||||||
|
import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
const FIXTURE_DIR = join(__dirname);
|
||||||
|
const FIXED_CLIENT_ID = 1;
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const markdown = readFileSync(join(FIXTURE_DIR, 'content-page.md'), 'utf8');
|
||||||
|
const doc = markdownToDoc(markdown);
|
||||||
|
|
||||||
|
// Round-trip sanity check: if this ever fails, the schema or serializer
|
||||||
|
// changed in a way that makes content-page.md no longer a fixed point —
|
||||||
|
// fix the schema/serializer or update content-page.md, don't silently
|
||||||
|
// check in a snapshot that doesn't match its own source.
|
||||||
|
const roundTripped = docToMarkdown(doc);
|
||||||
|
if (roundTripped !== markdown) {
|
||||||
|
console.error('content-page.md is not a fixed point of the Markdown round-trip.');
|
||||||
|
console.error('--- expected (content-page.md) ---');
|
||||||
|
console.error(markdown);
|
||||||
|
console.error('--- got (docToMarkdown(markdownToDoc(content-page.md))) ---');
|
||||||
|
console.error(roundTripped);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
ydoc.clientID = FIXED_CLIENT_ID;
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment);
|
||||||
|
const state = Y.encodeStateAsUpdate(ydoc);
|
||||||
|
ydoc.destroy();
|
||||||
|
|
||||||
|
writeFileSync(join(FIXTURE_DIR, 'content-page.yjs'), state);
|
||||||
|
console.log(`fixtures: wrote content-page.yjs (${state.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@ -11,9 +11,31 @@
|
|||||||
* dev machines and disposable CI/Test databases. On shared stages
|
* dev machines and disposable CI/Test databases. On shared stages
|
||||||
* (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give
|
* (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give
|
||||||
* those two accounts non-public passwords.
|
* those two accounts non-public passwords.
|
||||||
|
*
|
||||||
|
* Content fixtures (issue #32): a shared pond owned by fixture-user with
|
||||||
|
* two pages — "Every Element" (every editor schema node/mark, #24, loaded
|
||||||
|
* from the checked-in `fixtures/content-page.yjs`; regenerate via
|
||||||
|
* `pnpm --filter @dorfteich/api fixtures:regenerate` after editing
|
||||||
|
* `fixtures/content-page.md`) and "Fixture Image" (one real, servable
|
||||||
|
* uploaded image) — for the content regression pack and manual QA.
|
||||||
*/
|
*/
|
||||||
import { slugify } from '@dorfteich/shared';
|
import { readFileSync } from 'node:fs';
|
||||||
import { PrismaClient, UserStatus } from '@prisma/client';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
docToHtml,
|
||||||
|
docToMarkdown,
|
||||||
|
docToPlainText,
|
||||||
|
editorSchema,
|
||||||
|
extractOutline,
|
||||||
|
slugify,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { Prisma, PrismaClient, UserStatus } from '@prisma/client';
|
||||||
|
import { generateKeyBetween } from 'fractional-indexing';
|
||||||
|
import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Node as ProseMirrorNode } from 'prosemirror-model';
|
||||||
|
|
||||||
import { hashPassword } from '../src/users/password';
|
import { hashPassword } from '../src/users/password';
|
||||||
|
|
||||||
@ -44,7 +66,7 @@ const FIXTURES: FixtureUser[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function upsertFixtureUser(fixture: FixtureUser): Promise<void> {
|
async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
|
||||||
const email = `${fixture.username}@dorfteich.test`;
|
const email = `${fixture.username}@dorfteich.test`;
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { username: fixture.username },
|
where: { username: fixture.username },
|
||||||
@ -110,15 +132,190 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<void> {
|
|||||||
update: { value: 100 },
|
update: { value: 100 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DerivedContent {
|
||||||
|
plainText: string;
|
||||||
|
markdown: string;
|
||||||
|
html: string;
|
||||||
|
outline: Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveContentOf(doc: ProseMirrorNode): DerivedContent {
|
||||||
|
return {
|
||||||
|
plainText: docToPlainText(doc),
|
||||||
|
markdown: docToMarkdown(doc),
|
||||||
|
html: docToHtml(doc),
|
||||||
|
outline: extractOutline(doc) as unknown as Prisma.InputJsonValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docFromYjsState(state: Uint8Array): ProseMirrorNode {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
Y.applyUpdate(ydoc, state);
|
||||||
|
const doc = yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment('default'), editorSchema);
|
||||||
|
ydoc.destroy();
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertFixturePage(
|
||||||
|
pondId: string,
|
||||||
|
slug: string,
|
||||||
|
title: string,
|
||||||
|
ownerId: string,
|
||||||
|
state: Uint8Array,
|
||||||
|
content: DerivedContent,
|
||||||
|
): Promise<string> {
|
||||||
|
const existing = await prisma.page.findFirst({ where: { pondId, slug }, select: { id: true } });
|
||||||
|
if (existing) {
|
||||||
|
await prisma.page.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
ydocState: state,
|
||||||
|
contentCache: { upsert: { create: content, update: content } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
const last = await prisma.page.findFirst({
|
||||||
|
where: { pondId },
|
||||||
|
orderBy: { sortKey: 'desc' },
|
||||||
|
select: { sortKey: true },
|
||||||
|
});
|
||||||
|
const page = await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
pondId,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
sortKey: generateKeyBetween(last?.sortKey ?? null, null),
|
||||||
|
ydocState: state,
|
||||||
|
createdBy: ownerId,
|
||||||
|
contentCache: { create: content },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return page.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIXTURE_POND_SLUG = 'content-fixtures';
|
||||||
|
// 1x1 transparent PNG — only the magic bytes matter for a real, servable
|
||||||
|
// (if visually trivial) fixture image.
|
||||||
|
const FIXTURE_IMAGE_PNG_BASE64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||||
|
const FIXTURE_IMAGE_ID = '00000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
/** Shared content pond + fixture pages (issue #32). */
|
||||||
|
async function seedContentFixtures(ownerId: string): Promise<void> {
|
||||||
|
let pond = await prisma.pond.findUnique({ where: { slug: FIXTURE_POND_SLUG } });
|
||||||
|
if (!pond) {
|
||||||
|
pond = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: FIXTURE_POND_SLUG,
|
||||||
|
name: 'Content Fixtures',
|
||||||
|
type: 'SHARED',
|
||||||
|
ownerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.quotaOverride.upsert({
|
||||||
|
where: {
|
||||||
|
subjectType_subjectId_quotaKey: {
|
||||||
|
subjectType: 'POND',
|
||||||
|
subjectId: pond.id,
|
||||||
|
quotaKey: 'storage_bytes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
subjectType: 'POND',
|
||||||
|
subjectId: pond.id,
|
||||||
|
quotaKey: 'storage_bytes',
|
||||||
|
value: 100 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
update: { value: 100 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Every Element": the checked-in Yjs snapshot regenerated from
|
||||||
|
// fixtures/content-page.md — covers every editor schema node/mark (#24).
|
||||||
|
const everyElementState = readFileSync(join(__dirname, 'fixtures/content-page.yjs'));
|
||||||
|
const everyElementDoc = docFromYjsState(everyElementState);
|
||||||
|
await upsertFixturePage(
|
||||||
|
pond.id,
|
||||||
|
'every-element',
|
||||||
|
'Every Element',
|
||||||
|
ownerId,
|
||||||
|
new Uint8Array(everyElementState),
|
||||||
|
deriveContentOf(everyElementDoc),
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Fixture Image": one real, servable uploaded image (the Markdown
|
||||||
|
// fixture above only carries a placeholder fileId for round-trip
|
||||||
|
// testing — this is the one that actually resolves via /media/:fileId).
|
||||||
|
const uploadsDir = process.env.UPLOADS_DIR ?? './data/uploads';
|
||||||
|
const imageBytes = Buffer.from(FIXTURE_IMAGE_PNG_BASE64, 'base64');
|
||||||
|
await mkdir(join(uploadsDir, pond.id), { recursive: true });
|
||||||
|
await writeFile(join(uploadsDir, pond.id, FIXTURE_IMAGE_ID), imageBytes);
|
||||||
|
await prisma.attachment.upsert({
|
||||||
|
where: { id: FIXTURE_IMAGE_ID },
|
||||||
|
create: {
|
||||||
|
id: FIXTURE_IMAGE_ID,
|
||||||
|
pondId: pond.id,
|
||||||
|
fileName: 'fixture.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
sizeBytes: imageBytes.length,
|
||||||
|
storagePath: `${pond.id}/${FIXTURE_IMAGE_ID}`,
|
||||||
|
uploadedBy: ownerId,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
await prisma.pondUsage.upsert({
|
||||||
|
where: { pondId: pond.id },
|
||||||
|
create: { pondId: pond.id, storageBytesUsed: imageBytes.length },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageDoc = editorSchema.node('doc', null, [
|
||||||
|
editorSchema.node('heading', { level: 1 }, [editorSchema.text('Fixture Image')]),
|
||||||
|
editorSchema.node('paragraph', null, [
|
||||||
|
editorSchema.node('image', {
|
||||||
|
fileId: FIXTURE_IMAGE_ID,
|
||||||
|
alt: 'A tiny fixture image',
|
||||||
|
width: null,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const imageYdoc = new Y.Doc();
|
||||||
|
prosemirrorJSONToYXmlFragment(
|
||||||
|
editorSchema,
|
||||||
|
imageDoc.toJSON(),
|
||||||
|
imageYdoc.getXmlFragment('default'),
|
||||||
|
);
|
||||||
|
const imageState = new Uint8Array(Y.encodeStateAsUpdate(imageYdoc));
|
||||||
|
imageYdoc.destroy();
|
||||||
|
|
||||||
|
const imagePageId = await upsertFixturePage(
|
||||||
|
pond.id,
|
||||||
|
'fixture-image',
|
||||||
|
'Fixture Image',
|
||||||
|
ownerId,
|
||||||
|
imageState,
|
||||||
|
deriveContentOf(imageDoc),
|
||||||
|
);
|
||||||
|
await prisma.attachment.update({
|
||||||
|
where: { id: FIXTURE_IMAGE_ID },
|
||||||
|
data: { pageId: imagePageId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
// Fresh rate-limit budget for e2e runs — seed targets are always
|
// Fresh rate-limit budget for e2e runs — seed targets are always
|
||||||
// disposable dev/CI databases, never production.
|
// disposable dev/CI databases, never production.
|
||||||
await prisma.rateLimit.deleteMany({});
|
await prisma.rateLimit.deleteMany({});
|
||||||
|
let contentOwnerId: string | undefined;
|
||||||
for (const fixture of FIXTURES) {
|
for (const fixture of FIXTURES) {
|
||||||
await upsertFixtureUser(fixture);
|
const userId = await upsertFixtureUser(fixture);
|
||||||
|
if (fixture.username === 'fixture-user') contentOwnerId = userId;
|
||||||
}
|
}
|
||||||
|
if (contentOwnerId) await seedContentFixtures(contentOwnerId);
|
||||||
await prisma.instanceSetting.upsert({
|
await prisma.instanceSetting.upsert({
|
||||||
where: { key: 'seed.marker' },
|
where: { key: 'seed.marker' },
|
||||||
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
|
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
|
||||||
|
|||||||
@ -1,11 +1,24 @@
|
|||||||
# End-to-end tests
|
# End-to-end tests
|
||||||
|
|
||||||
Two Playwright suites with different targets:
|
Playwright suites, most local-only — three run in CI/CD:
|
||||||
|
|
||||||
| Suite | Target | Where it runs |
|
| Suite | Target | Where it runs |
|
||||||
| --------------- | --------------------------------- | --------------------------------------------------------------------- |
|
| ----------------- | --------------------------------- | --------------------------------------------------------------------------- |
|
||||||
| `smoke.spec.ts` | any deployed stage | CD pipeline against `https://test.dorfteich.cloud` after every deploy |
|
| `smoke.spec.ts` | any deployed stage | CD pipeline against `https://test.dorfteich.cloud` after every deploy |
|
||||||
| `auth.spec.ts` | full local stack **with Mailpit** | CI job `auth-e2e` on every PR/push; locally against the dev stack |
|
| `auth.spec.ts` | full local stack **with Mailpit** | CI job `auth-e2e` on every PR/push; locally against the dev stack |
|
||||||
|
| `content.spec.ts` | full local stack | same CI job `auth-e2e` (a second step), right after the auth pack |
|
||||||
|
| everything else | full local stack | locally only — `editor`/`sidebar`/`image`/`link`/`markdown`/`trash`.spec.ts |
|
||||||
|
|
||||||
|
`content.spec.ts` is the M2 content regression pack (issue #32): page
|
||||||
|
lifecycle, editor basics, image paste, Markdown round-trip, and trash —
|
||||||
|
enough to catch a regression across the whole content model without
|
||||||
|
re-running every edge case the feature-specific packs above already cover.
|
||||||
|
Its Markdown round-trip test is a real regression pin, not just a smoke
|
||||||
|
check: it compares the seeded "Every Element" fixture page's exported
|
||||||
|
Markdown byte-for-byte against the checked-in `content-page.md` (see
|
||||||
|
"Content fixtures" below) — any schema/serializer change that alters how a
|
||||||
|
node round-trips fails it, once the seed has re-run against the changed
|
||||||
|
code (build → migrate → seed → test, exactly CI's order).
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
@ -35,6 +48,36 @@ only on dev machines and disposable CI/Test databases.
|
|||||||
| `fixture-user` | active | regular journeys, settings, sessions |
|
| `fixture-user` | active | regular journeys, settings, sessions |
|
||||||
| `fixture-pending` | e-mail not verified | unverified-login cases |
|
| `fixture-pending` | e-mail not verified | unverified-login cases |
|
||||||
|
|
||||||
|
## Content fixtures
|
||||||
|
|
||||||
|
`db:seed` also creates a **shared** pond `content-fixtures` (owned by
|
||||||
|
`fixture-user`) with two pages, for the content regression pack and manual
|
||||||
|
QA:
|
||||||
|
|
||||||
|
- **Every Element** (`every-element`) — every editor schema node and mark
|
||||||
|
(issue #24: headings 1–4, all list types, table, blockquote, code block,
|
||||||
|
horizontal rule, hard break, and all five marks). Loaded from the
|
||||||
|
checked-in `apps/api/prisma/fixtures/content-page.yjs`, a Yjs snapshot
|
||||||
|
generated from the human-readable `content-page.md` next to it —
|
||||||
|
`content-page.md` is the thing to read or edit; the `.yjs` file is a
|
||||||
|
build artifact of it, not source.
|
||||||
|
- **Fixture Image** (`fixture-image`) — one real, servable uploaded image
|
||||||
|
(the placeholder `fileId` inside the Markdown fixture above is not a
|
||||||
|
real attachment; this page's image is).
|
||||||
|
|
||||||
|
Regenerating after editing `content-page.md`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm --filter @dorfteich/api fixtures:regenerate
|
||||||
|
```
|
||||||
|
|
||||||
|
This is deterministic — re-running without editing the Markdown produces a
|
||||||
|
byte-identical `.yjs` file (the script pins the Yjs document's `clientID`,
|
||||||
|
which is otherwise randomized per `Y.Doc` instance) — and it refuses to
|
||||||
|
write a snapshot that isn't a fixed point of the Markdown round-trip
|
||||||
|
(`docToMarkdown(markdownToDoc(x)) === x`), so a stale fixture can't get
|
||||||
|
checked in silently.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- New feature packs get their own `<feature>.spec.ts` next to these and
|
- New feature packs get their own `<feature>.spec.ts` next to these and
|
||||||
|
|||||||
184
apps/web/e2e/content.spec.ts
Normal file
184
apps/web/e2e/content.spec.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content regression pack (issue #32) — the one M2 e2e suite that runs in
|
||||||
|
* CI (job `auth-e2e`, `.gitea/workflows/ci.yml` — a second step there,
|
||||||
|
* reusing the same built+seeded stack rather than a separate job), against
|
||||||
|
* a local prod build seeded exactly like Test/Int. Covers page lifecycle, editor
|
||||||
|
* basics, image paste, Markdown round-trip, and trash: enough to catch a
|
||||||
|
* regression across the whole M2 content model without re-running every
|
||||||
|
* edge case already covered by the feature-specific packs (editor/image/
|
||||||
|
* link/markdown/trash/sidebar `.spec.ts`), which stay local-only.
|
||||||
|
*
|
||||||
|
* The Markdown round-trip test is the pack's actual regression pin: it
|
||||||
|
* compares the seeded "Every Element" fixture page's exported Markdown
|
||||||
|
* byte-for-byte against `content-page.md` (checked in next to the seed
|
||||||
|
* script, `apps/api/prisma/fixtures/`, regenerated via
|
||||||
|
* `pnpm --filter @dorfteich/api fixtures:regenerate`). The export endpoint
|
||||||
|
* serves the *cached* `page_content_cache.markdown` (refreshed by the seed
|
||||||
|
* script/state saves, not derived live on every request, #23/#30) — so a
|
||||||
|
* schema/serializer change only surfaces here once the seed has re-run
|
||||||
|
* against it, which is exactly what CI does on every run (build → migrate
|
||||||
|
* → seed → this pack). Verified during development: temporarily changed
|
||||||
|
* `docToMarkdown`'s heading serializer, rebuilt `packages/shared`, re-ran
|
||||||
|
* `db:seed`, and confirmed this assertion failed with the mutated output;
|
||||||
|
* reverted immediately after.
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
const CONTENT_FIXTURE_MARKDOWN = readFileSync(
|
||||||
|
join(here, '../../api/prisma/fixtures/content-page.md'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
async function personalPond(
|
||||||
|
context: Awaited<ReturnType<typeof contextForUser>>,
|
||||||
|
): Promise<{ id: string; slug: string }> {
|
||||||
|
const ponds = await context.request.get('/api/v1/ponds');
|
||||||
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||||
|
return { id: pond.id, slug: pond.slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterEditMode(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('page lifecycle: create via the sidebar, rename, appears in the sidebar', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const title = `Content Pack Lifecycle ${Date.now()}`;
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}`);
|
||||||
|
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
||||||
|
await page.getByLabel(/title|titel/i).fill(title);
|
||||||
|
await page.getByRole('button', { name: /create|erstellen/i }).click();
|
||||||
|
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
|
||||||
|
|
||||||
|
const renamed = `${title} (renamed)`;
|
||||||
|
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
|
||||||
|
await page.locator('.editor-page__title').fill(renamed);
|
||||||
|
await page.locator('.editor-page__title').blur();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.sidebar__page--active')).toHaveText(renamed);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor basics: typing autosaves and undo/redo work', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
data: { title: `Content Pack Editor Basics ${Date.now()}` },
|
||||||
|
});
|
||||||
|
const { slug } = await created.json();
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}/${slug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
const content = page.locator('.ProseMirror');
|
||||||
|
await content.click();
|
||||||
|
await page.keyboard.type('Hello content pack');
|
||||||
|
await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.keyboard.press('ControlOrMeta+z');
|
||||||
|
await expect(content).not.toContainText('Hello content pack');
|
||||||
|
await page.keyboard.press('ControlOrMeta+y');
|
||||||
|
await expect(content).toContainText('Hello content pack');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('image paste: uploads and renders at the cursor', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
data: { title: `Content Pack Image ${Date.now()}` },
|
||||||
|
});
|
||||||
|
const { slug } = await created.json();
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}/${slug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.locator('.ProseMirror').click();
|
||||||
|
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const el = document.querySelector('.ProseMirror');
|
||||||
|
const base64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||||
|
const response = await fetch(`data:image/png;base64,${base64}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], 'content-pack.png', { type: 'image/png' });
|
||||||
|
const dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
el!.dispatchEvent(
|
||||||
|
new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Markdown round-trip: the seeded fixture page exports byte-for-byte the checked-in fixture', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const ponds = await context.request.get('/api/v1/ponds');
|
||||||
|
const pond = (await ponds.json()).find((p: { slug: string }) => p.slug === 'content-fixtures');
|
||||||
|
expect(pond, 'content-fixtures fixture pond must be seeded').toBeTruthy();
|
||||||
|
|
||||||
|
const pages = await context.request.get(`/api/v1/ponds/${pond.id}/pages`);
|
||||||
|
const everyElement = (await pages.json()).find(
|
||||||
|
(p: { slug: string }) => p.slug === 'every-element',
|
||||||
|
);
|
||||||
|
expect(everyElement, 'every-element fixture page must be seeded').toBeTruthy();
|
||||||
|
|
||||||
|
const exported = await context.request.get(`/api/v1/pages/${everyElement.id}/export/markdown`);
|
||||||
|
expect(await exported.text()).toBe(CONTENT_FIXTURE_MARKDOWN);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trash: deleting hides a page from the sidebar; restoring brings it back', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const title = `Content Pack Trash ${Date.now()}`;
|
||||||
|
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
||||||
|
data: { title },
|
||||||
|
});
|
||||||
|
const { slug } = await created.json();
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('dialog', (dialog) => void dialog.accept());
|
||||||
|
await page.goto(`/p/${pond.slug}/${slug}`);
|
||||||
|
await enterEditMode(page);
|
||||||
|
await page.getByRole('button', { name: /move to trash|papierkorb verschieben/i }).click();
|
||||||
|
|
||||||
|
await page.goto(`/p/${pond.slug}`);
|
||||||
|
await expect(page.getByRole('link', { name: title })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(`/p/${pond.slug}/trash`);
|
||||||
|
const item = page.locator('.trash-page__item').filter({ hasText: title });
|
||||||
|
await expect(item).toBeVisible();
|
||||||
|
await item.getByRole('button', { name: /restore|wiederherstellen/i }).click();
|
||||||
|
await expect(page.getByRole('link', { name: title })).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user