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>, ): 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(); });