All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Two export paths, both permission-aware (permissions.md):
- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
pages as Markdown (one `<slug>.md` per page, a `media/` directory,
wikilinks rewritten to relative `[text](slug.md)` links, image sources to
`media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
filters to the pages the requester may actually read, so a label-restricted
reader gets only their slice. Media is appended as read streams and pages as
small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
file` conversion job (the #62 queue): embedded images are inlined as data
URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
`GET /jobs/:id` and downloads `GET /jobs/:id/result`.
Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).
Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.
- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
links, label-restricted omission, docx job with inlined images, 500-page
streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
self-skips without a pandoc sidecar, as in the import pack, #64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
61 lines
2.4 KiB
TypeScript
61 lines
2.4 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
|
|
import { contextForUser } from './helpers';
|
|
|
|
/**
|
|
* Export pack (issue #65): the pond-settings ZIP download and the page-menu
|
|
* office-format export. The ZIP needs no conversion sidecar; `.docx` runs a
|
|
* conversion job and self-skips without `E2E_PANDOC` (CI's e2e stack has no
|
|
* reachable pandoc — same as the import pack, #64). Selectors are
|
|
* language-neutral (CSS classes, not button text).
|
|
*/
|
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
|
|
|
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('downloads a pond as a Markdown ZIP from pond settings', async ({ browser }) => {
|
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
|
const pond = await personalPond(context);
|
|
// Make sure the pond has at least one page to export.
|
|
await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
|
data: { title: `Export Me ${Date.now()}` },
|
|
});
|
|
const page = await context.newPage();
|
|
await page.goto(`/p/${pond.slug}/settings`);
|
|
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent('download'),
|
|
page.locator('.pond-export a').click(),
|
|
]);
|
|
expect(download.suggestedFilename()).toBe(`${pond.slug}.zip`);
|
|
|
|
await context.close();
|
|
});
|
|
|
|
test('exports a page to .docx from the page menu', async ({ browser }) => {
|
|
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 created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
|
|
data: { title: `Docx Export ${Date.now()}` },
|
|
});
|
|
const created_page = await created.json();
|
|
const page = await context.newPage();
|
|
await page.goto(`/p/${pond.slug}/${created_page.slug}`);
|
|
|
|
// The first export button is `.docx`; clicking runs the job and downloads it.
|
|
const [download] = await Promise.all([
|
|
page.waitForEvent('download', { timeout: 30000 }),
|
|
page.locator('.editor-page__export button').first().click(),
|
|
]);
|
|
expect(download.suggestedFilename()).toBe(`${created_page.slug}.docx`);
|
|
|
|
await context.close();
|
|
});
|