dorfteich/apps/web/e2e/import.spec.ts
Claude Opus 4.8 e2f942c0ff
All checks were successful
CD / Build and push images (push) Successful in 3m43s
CI / Lint, typecheck, test (push) Successful in 2m56s
CI / Auth e2e pack (push) Successful in 3m53s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 11s
Add document import UI in the sidebar (#64)
An "Import document" action in the pond sidebar: pick a .docx/.odt/.md file
(or several), upload with per-file progress, and open the new page. A
.docx/.odt polls the conversion job (queued → converting → done); a .md
imports directly and comes back already succeeded. Failures stay listed with
the localized error and a retry; concurrent imports all complete and appear.

- web apps/web/src/import/: useImport hook (upload via apiUploadFile → poll
  GET /jobs/:id → resolve the page slug → navigate; first success of a batch
  navigates, every success refreshes the sidebar) and ImportControl (hidden
  file input, accept from shared IMPORT_EXTENSIONS, per-file status list).
  Wired into Sidebar next to "new page"; `import` i18n namespace (de+en).
- api: ImportService accepts .md/.markdown and imports in-process (no job),
  returning a succeeded ConversionJobView with the created resultPageId
  ("Markdown imports directly"); the media+parse+create tail is now shared
  between the job path and the sync path (createPageFromMarkdown), and a
  conversion error on the sync path maps to an HTTP status. shared
  IMPORT_EXTENSIONS gains md/markdown.
- e2e apps/web/e2e/import.spec.ts + CI step: .docx corpus fixture opens the
  converted page (self-skips without a reachable pandoc sidecar — CI's e2e
  stack has none, same as #63; verified locally + on stage), .md opens
  directly, an unsupported .txt shows the localized error with no page
  created, and two concurrent .md imports both complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 09:38:31 +02:00

135 lines
5.3 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Document import pack (issue #64): the sidebar "import document" action.
* `.docx` goes through the conversion job (needs a pandoc sidecar — the case
* self-skips without `E2E_PANDOC`, see below); `.md` imports directly. Failures
* show the localized error and leave no page; concurrent imports both complete.
* Selectors are language-neutral (the UI follows the user's locale) — CSS
* classes, not button text.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
// The corpus fixture from #63; the test runner's cwd is `apps/web`.
const DOCX = readFileSync(join(process.cwd(), '../../fixtures/import/article.docx'));
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
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 };
}
test('imports a .docx through the sidebar and opens the converted page', async ({ browser }) => {
// `.docx` needs the pandoc sidecar reachable by the api. CI's e2e stack has
// none (jobs run container-networked; the same reason #63's real-pandoc test
// skips there), so this runs locally / on a stage with E2E_PANDOC set. The
// `.md`, failure, and concurrent cases below cover the UI flow without it.
test.skip(!process.env.E2E_PANDOC, 'needs a reachable pandoc sidecar');
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
await page.locator('.sidebar__import-input').setInputFiles({
name: 'field-notes.docx',
mimeType: DOCX_MIME,
buffer: DOCX,
});
// The conversion job completes and the client navigates to the new page
// (slug from the document's title "Field Notes").
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/field-notes`), { timeout: 30000 });
// The body keeps the converted structure (the H1 became the page title).
await expect(page.locator('.ProseMirror')).toContainText('Observations', { timeout: 15000 });
await context.close();
});
test('imports a .md directly (no job) and opens the page', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
const suffix = Date.now();
await page.locator('.sidebar__import-input').setInputFiles({
name: 'note.md',
mimeType: 'text/markdown',
buffer: Buffer.from(`# Imported Note ${suffix}\n\nHello from markdown.`),
});
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/imported-note-${suffix}`), {
timeout: 15000,
});
await expect(page.locator('.ProseMirror')).toContainText('Hello from markdown');
await context.close();
});
test('shows the localized error for an unsupported file and creates no page', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const before = (
(await (await context.request.get(`/api/v1/ponds/${pond.id}/pages`)).json()) as unknown[]
).length;
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
await page.locator('.sidebar__import-input').setInputFiles({
name: 'notes.txt',
mimeType: 'text/plain',
buffer: Buffer.from('plain text, not importable'),
});
// The failed import stays listed with a (localized, non-empty) error, and no
// page is created (the pond's page count is unchanged — no half-created page).
const failed = page.locator('.sidebar__import-item--failed');
await expect(failed).toBeVisible({ timeout: 10000 });
await expect(failed.locator('.sidebar__import-status')).not.toBeEmpty();
const after = (
(await (await context.request.get(`/api/v1/ponds/${pond.id}/pages`)).json()) as unknown[]
).length;
expect(after).toBe(before);
await context.close();
});
test('runs concurrent imports and both complete', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
const suffix = Date.now();
await page.locator('.sidebar__import-input').setInputFiles([
{ name: 'alpha.md', mimeType: 'text/markdown', buffer: Buffer.from(`# Alpha ${suffix}\n\nA`) },
{ name: 'beta.md', mimeType: 'text/markdown', buffer: Buffer.from(`# Beta ${suffix}\n\nB`) },
]);
// Both pages are created (one of them also navigates the browser).
await expect
.poll(
async () => {
const list = (await (
await context.request.get(`/api/v1/ponds/${pond.id}/pages`)
).json()) as { title: string }[];
const titles = new Set(list.map((p) => p.title));
return titles.has(`Alpha ${suffix}`) && titles.has(`Beta ${suffix}`);
},
{ timeout: 15000 },
)
.toBe(true);
await context.close();
});