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 = {}; 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(context: BrowserContext, url: string, data: unknown): Promise { const response = await context.request.post(url, { data }); if (!response.ok()) throw new Error(`post ${url} → ${response.status()}`); return response.json() as Promise; } 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}`, }); 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(); // #120: the panel must be opaque — --color-surface was undefined and the // whole .modal background silently dropped, letting the page bleed through. await expect(dialog).toHaveCSS('background-color', 'rgb(255, 255, 255)'); 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(); });