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
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
135 lines
5.3 KiB
TypeScript
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();
|
|
});
|