123 lines
6.0 KiB
TypeScript
123 lines
6.0 KiB
TypeScript
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 = `<!doctype html><html><head><meta charset="utf-8"><title>${APP_NAME}</title></head>` +
|
||
`<body><h1>${APP_MARKER}</h1></body></html>`;
|
||
|
||
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);
|
||
});
|