maestro/ui/e2e/app-share-public.spec.ts
oss-sync b857c33ef6
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (f6d625db)
2026-06-26 03:35:45 +00:00

123 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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