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);
});