import { test, expect } from '@playwright/test'; import { createRequire } from 'node:module'; import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; // ── Public app-share view E2E (login-free, read-only) ───────────────────────── // // REQUIRES `npm run test:e2e` + a running server (ui/playwright.config.ts boots // the real orchestrator with auth OFF → synthetic 'local' user). This does NOT // run in the sandbox — there is no live server here. // // A workspace app (apps/{name}/index.html) inside a space can be shared via a // login-free, read-only public URL: /ui/app/:token. The public viewer // (SharedAppView → AppRunner) resolves the token through /api/app-share/:token // (no auth), renders the entry HTML in a sandboxed iframe, and exposes a // read-only gateway (no write/delete). The published-tested logic libs // (appShareUrl / sharedTabs / sharedView) are unit-covered; this proves the // end-to-end public viewer flow that has no e2e. // // We cannot create an app + share link through the LLM-less UI, so we seed engine // state directly into the throwaway DB + workspace dir (same handoff the // tool-request spec uses): the DB path is written by playwright.config.ts to // ui/.e2e-db-path, and the workspace dir is its sibling 'workspaces' folder // (E2E_TMP/{e2e.db, workspaces}). We create a space via the built Repository, // drop an entry HTML under {worktree}/space/{id}/files/apps/{app}/index.html // (matching spaceFilesDir + findEntryPath), and mint a share token. Then the // browser opens the public URL with NO session. const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const repoRoot = resolve(__dirname, '..', '..'); // playwright.config.ts hands off DB_PATH = {E2E_TMP}/e2e.db and // WORKTREE_DIR = {E2E_TMP}/workspaces (siblings). Derive the worktree dir from // the DB path so both agree on the SAME temp tree the webServer booted with. const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim(); const e2eTmp = dirname(dbPath); const WORKTREE_DIR = join(e2eTmp, 'workspaces'); const APP_NAME = 'e2e-public-app'; // A self-contained entry page (no relative assets) so the iframe render is // deterministic without extra file fetches. const APP_MARKER = 'E2E PUBLIC APP CONTENT'; const APP_HTML = `${APP_NAME}` + `

${APP_MARKER}

`; let spaceId = ''; let token = ''; test.beforeAll(async () => { const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as { Repository: new (dbPath: string) => { createSpace: (p: { kind: string; title: string; ownerId: string; visibility?: string }) => Promise<{ id: string }>; createAppShareLink: (spaceId: string, appName: string, createdBy: string | null) => { token: string }; close?: () => void; }; }; const repo = new Repository(dbPath); try { // Under no-auth the synthetic owner is 'local'. const space = await repo.createSpace({ kind: 'case', title: '案件-公開アプリ共有', ownerId: 'local', visibility: 'private' }); spaceId = space.id; // Entry HTML at the location findEntryPath() prefers: // {worktree}/space/{id}/files/apps/{app}/index.html const appDir = join(WORKTREE_DIR, 'space', spaceId, 'files', 'apps', APP_NAME); mkdirSync(appDir, { recursive: true }); writeFileSync(join(appDir, 'index.html'), APP_HTML, 'utf-8'); // Mint the public share token (createdBy null under no-auth). token = repo.createAppShareLink(spaceId, APP_NAME, null).token; } finally { repo.close?.(); } }); // Happy path: opening the public URL with NO login resolves the token, renders // the AppRunner full-screen, marks it read-only, and the seeded entry HTML loads // into the sandboxed iframe. test('public app-share URL renders the read-only viewer without login', async ({ page }) => { expect(token, 'share token seeded').toBeTruthy(); // No session is established — this is the login-free public route. await page.goto(`/ui/app/${token}`); // The AppRunner shell renders (the public viewer mounts it full-screen). const runner = page.getByTestId('app-runner'); await expect(runner).toBeVisible({ timeout: 15_000 }); // The read-only badge is shown (writable=false → the public-share copy). The // text is a hardcoded literal, not i18n, so it is locale-independent. await expect(runner).toContainText('公開共有(read-only)'); // The entry HTML is loaded into the sandboxed iframe (srcDoc), so the frame // element is present. await expect(page.getByTestId('app-runner-frame')).toBeVisible(); const frame = page.frameLocator('[data-testid="app-runner-frame"]'); await expect(frame.getByText(APP_MARKER)).toBeVisible({ timeout: 15_000 }); // The public meta API is reachable WITHOUT auth and resolves the app. const meta = await page.request.get(`/api/app-share/${token}`); expect(meta.status(), 'public meta resolves').toBe(200); const body = (await meta.json()) as { app?: { appName?: string; entryPath?: string | null } }; expect(body.app?.appName).toBe(APP_NAME); expect(body.app?.entryPath).toContain(`apps/${APP_NAME}/`); }); // Negative/visibility: an invalid/unknown token yields the public 404 screen // (and the public meta API 404s) — never the real app. test('an invalid app-share token shows the public not-found screen', async ({ page }) => { await page.goto('/ui/app/this-token-does-not-exist'); // The 404 tone screen renders; the AppRunner must NOT mount. await expect(page.getByText('アプリが見つかりません')).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId('app-runner')).toHaveCount(0); // The public meta API rejects the unknown token. const meta = await page.request.get('/api/app-share/this-token-does-not-exist'); expect(meta.status()).toBe(404); });