dorfteich/apps/web/e2e/import-vault.spec.ts
Claude Fable 5 704ebe48a6
Some checks failed
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Deploy to Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Failing after 1m0s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Has been cancelled
Vault import dialog in the pond settings (#118)
An admin-only 'Import an Obsidian vault' section on the pond settings
page opens a dialog with everything the #117 endpoint expects: the ZIP,
an indented mount-parent picker over the page tree (the MovePageDialog
pattern), a multi-select over the pond's label tree, and the
frontmatter radio (strip / keep as code block). Submit uploads and
polls the job with a vault-sized budget (600 x 1 s), then invalidates
pages, graph, phantom-links, and labels so the sidebar tree, graph, and
pickers show the import without a reload — and links to the mount page.

apiUploadFile now takes extra multipart fields (the options JSON);
existing callers are unchanged.

e2e import-vault.spec.ts: an admin imports the fixture vault through
the dialog and the app shows the folder tree under the mount page, a
rewritten Obsidian link navigates to the right page, the embedded image
renders, and the nested tag labels exist next to the dialog's extra
label; a plain editor gets no section at all. 3x flake-free locally
(CI wiring lands with #119).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:26:26 +02:00

136 lines
5.1 KiB
TypeScript

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type BrowserContext } from '@playwright/test';
import { zipSync } from 'fflate';
import { contextForUser } from './helpers';
/**
* Obsidian vault import pack (issues #117/#118): a pond admin imports the
* checked-in fixture vault through the settings dialog and the result shows
* up in the app — folder tree, rewritten wikilinks, tag labels, embedded
* image. A plain editor sees no such section. The ZIP is built from the
* fixture directory here, so the vault stays reviewable as plain files.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const FIXTURE_DIR = join(
dirname(fileURLToPath(import.meta.url)),
'../../../fixtures/import/obsidian-vault',
);
function fixtureZip(): Buffer {
const entries: Record<string, Uint8Array> = {};
const walk = (dir: string): void => {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else entries[relative(FIXTURE_DIR, path)] = new Uint8Array(readFileSync(path));
}
};
walk(FIXTURE_DIR);
return Buffer.from(zipSync(entries));
}
async function json<T>(context: BrowserContext, url: string, data: unknown): Promise<T> {
const response = await context.request.post(url, { data });
if (!response.ok()) throw new Error(`post ${url}${response.status()}`);
return response.json() as Promise<T>;
}
test('a pond admin imports an Obsidian vault through the settings dialog', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ts = Date.now();
const pond = await json<{ id: string; slug: string }>(context, '/api/v1/ponds', {
name: `Vault Pond ${ts}`,
});
const mount = await json<{ id: string; slug: string; title: string }>(
context,
`/api/v1/ponds/${pond.id}/pages`,
{ title: `Vault Mount ${ts}` },
);
const label = await json<{ id: string; name: string }>(
context,
`/api/v1/ponds/${pond.id}/labels`,
{ name: `import-${ts}` },
);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/settings`);
// The admin-only section opens the dialog.
await page.locator('.vault-import__open').click();
const dialog = page.locator('.vault-import-dialog');
await expect(dialog).toBeVisible();
await dialog.locator('input[type="file"]').setInputFiles({
name: 'vault.zip',
mimeType: 'application/zip',
buffer: fixtureZip(),
});
// Mount under the prepared page and tag everything with the extra label.
await dialog
.locator('li', { hasText: `Vault Mount ${ts}` })
.locator('input')
.check();
await dialog
.locator('li', { hasText: `import-${ts}` })
.locator('input')
.check();
await dialog.locator('.vault-import-dialog__start').click();
// The job runs; the dialog offers the way into the imported pages.
const doneLink = dialog.getByRole('link');
await expect(doneLink).toBeVisible({ timeout: 30_000 });
await doneLink.click();
// The sidebar shows the vault's folder structure under the mount page.
const mountItem = page
.locator('.sidebar__page-item')
.filter({ has: page.locator(`.sidebar__page:text-is("Vault Mount ${ts}")`) })
.first();
await expect(
mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }),
).toBeVisible();
await expect(page.locator('.sidebar__page:text-is("Startseite")')).toBeVisible();
// A rewritten Obsidian link navigates to the right imported page, and the
// display text still reads like the original note name.
await page.goto(`/p/${pond.slug}/startseite`);
await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click();
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`));
// The note's image came in as a pond file, and its tags became labels
// (nested #status/aktiv included) next to the dialog's extra label.
await expect(page.locator('.editor-content img').first()).toBeVisible();
const labels = await context.request.get(`/api/v1/ponds/${pond.id}/labels`);
const tree = JSON.stringify(await labels.json());
expect(tree).toContain('status');
expect(tree).toContain('aktiv');
expect(tree).toContain(label.name);
await context.close();
});
test('a plain editor gets no vault-import section', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const editor = await contextForUser(browser, BASE_URL, 'fixture-editor');
const ts = Date.now();
const pond = await json<{ id: string; slug: string }>(owner, '/api/v1/ponds', {
name: `Vault Gate ${ts}`,
});
await json(owner, `/api/v1/ponds/${pond.id}/members`, {
usernameOrEmail: 'fixture-editor',
role: 'editor',
});
const page = await editor.newPage();
await page.goto(`/p/${pond.slug}/settings`);
await expect(page.locator('.pond-settings-page')).toBeVisible();
await expect(page.locator('.vault-import__open')).toHaveCount(0);
await owner.close();
await editor.close();
});