import { test, expect, type Page } from '@playwright/test'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, resolve, join } from 'node:path'; import { tmpdir } from 'node:os'; // ── Local-auth shared-space E2E ─────────────────────────────────────────────── // // Unlike ui/e2e/spaces.spec.ts (auth OFF => synthetic 'local' user, empty member // list, /api/users/pickable === []), this harness boots the orchestrator with // LOCAL LOGIN ENABLED (ui/playwright.auth.config.ts passes AAO_CONFIG). Real // users sign in through the browser, so the member invite + avatar features — // which depend on real identities (owner row, pickable users, space_members) — // can finally be exercised end-to-end. They are unreachable in the auth-off suite. // // The bootstrap admin (admin@test.local, id='local', role admin) is auto-seeded // at startup. Member users (alice/bob) are seeded into the SAME temp DB in // beforeAll, using the BUILT Repository — by the time tests run the webServer is // up, so schema + migrations exist. const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); // __dirname is /ui/e2e-auth → repo root is two levels up. const repoRoot = resolve(__dirname, '..', '..'); // Same deterministic DB path the webServer uses (ui/playwright.auth.config.ts // builds it identically: /maestro-e2e-auth/e2e-auth.db). Computing it // independently — rather than importing the config — keeps the spec decoupled // from Playwright's config-resolution internals while guaranteeing both open // the SAME SQLite file. const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db'); const ADMIN = { email: 'admin@test.local', password: 'AdminPass123!' }; const ALICE = { email: 'alice@test.local', password: 'AlicePass123!', name: 'Alice' }; const BOB = { email: 'bob@test.local', password: 'BobPass123!', name: 'Bob' }; // Space-OWNING admin used to create + manage shared spaces. NOTE: this is a // SEPARATE seeded admin with a REAL uuid — NOT the bootstrap admin (whose id is // literally 'local'). The space create handler nulls owner_id when the creator's // id === 'local' (it treats that as the no-auth synthetic user), so a space // created by the bootstrap admin has NO owner row and the avatar stack (members // >= 2) can never form. A non-'local' owner is required to exercise the feature. const MANAGER = { email: 'manager@test.local', password: 'ManagerPass123!', name: 'Manager' }; // Same benign-path tolerance as the auth-off suite, MINUS /api/auth/me (here it // is mounted and returns 200). With auth on, the per-task console/browser probes // and MCP/SSH/org probes still 401/404 benignly. const BENIGN_PATHS = new Set([ '/api/users/me/orgs', '/api/mcp/connections', '/api/mcp/servers', '/api/mcp/user-servers', '/api/ssh/connections', ]); const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i; const BENIGN_PATH_RE = /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/; // The space members GET is auth-gated. During the logout→login handoff (test 3), // the header avatar query can fire ONE request after the cookie is cleared but // before the new session lands → a transient 401. Only a 401 (unauthenticated) // is benign here; a 403 (authenticated-but-forbidden) would be a real authz bug // and is NOT excused. The avatar assertion still proves the eventual 200. const SPACE_MEMBERS_RE = /\/api\/local\/spaces\/[^/]+\/members$/; function trackFatalErrors(page: Page): string[] { const fatalErrors: string[] = []; page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); const transientMembers401 = res.status() === 401 && SPACE_MEMBERS_RE.test(pathname); if ( !BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname) && !BENIGN_PATH_RE.test(pathname) && !transientMembers401 ) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); return fatalErrors; } /** Log a real user in via the browser form. Success → 302 `/` → 302 `/ui` → * SPA at `/ui/`. Failure → `/auth/login?error=…`. We wait for the redirect * chain to settle off the login page, assert we landed on the SPA (not back on * login with an error), then ensure the SPA route is loaded. */ async function login(page: Page, email: string, password: string) { await page.goto('/auth/login'); await page.fill('input[name="email"]', email); await page.fill('input[name="password"]', password); await Promise.all([ page.waitForURL(/\/ui(\/|$)/, { timeout: 15_000 }), page.click('button[type="submit"]'), ]); expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login'); } async function logout(page: Page) { await page.goto('/auth/logout'); } /** Open Spaces, create a case space with a unique title, select it, and return * the detail locator + the resolved space id. Mirrors the auth-off helper. */ async function createAndOpenCaseSpace(page: Page, label: string) { await page.goto('/ui'); await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); await page.getByTestId('create-space-btn').click(); const titleInput = page.getByTestId('space-title-input'); await expect(titleInput).toBeVisible(); const caseTitle = `${label}-${Date.now()}`; await titleInput.fill(caseTitle); await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail .locator('[data-testid="space-row"][data-space-kind="case"]') .filter({ hasText: caseTitle }); await expect(caseRow).toBeVisible(); const spaceId = await caseRow.getAttribute('data-space-id'); expect(spaceId).toBeTruthy(); await caseRow.click(); const detail = page.getByTestId('space-detail'); await expect(detail).toBeVisible(); return { detail, caseTitle, rail, spaceId: spaceId as string }; } /** Navigate the already-open space to 設定 → メンバー and return the panel. */ async function openMembersPanel(page: Page) { await page.getByTestId('space-tab-settings').click(); await page.getByTestId('space-settings-nav-members').click(); const panel = page.getByTestId('space-members-panel'); await expect(panel).toBeVisible({ timeout: 15_000 }); return panel; } /** Open the invite picker, pick `name` from the candidate list, and add them * (role defaults to 編集者/editor). Asserts the real-user list shows (NOT the * no-auth hint) before selecting. */ async function inviteMember(page: Page, name: string) { const panel = page.getByTestId('space-members-panel'); await panel.getByTestId('space-member-invite').click(); const picker = panel.getByTestId('space-member-picker'); await expect(picker).toBeVisible({ timeout: 15_000 }); // Real active-user pool → list, not the no-auth "認証を有効化すると…" hint. await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toHaveCount(0); // Candidate rows are buttons named " "; scope by role // + name regex so the name/email substrings don't trip strict mode. await picker.getByRole('button', { name: new RegExp(name) }).click(); await picker.getByRole('button', { name: '追加' }).click(); } test.beforeAll(() => { // The webServer is already up (Playwright waits on its url before running), // so the schema + migrations + bootstrap admin all exist. Seed members into // the SAME deterministic DB the server opened. const dbPath = DB_PATH; // Import the BUILT Repository (named export `class Repository`). const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as { Repository: new (dbPath: string) => { getUserByEmail: (email: string) => unknown; createLocalUser: (p: { email: string; password: string; name?: string; role: string; status: string; }) => unknown; close?: () => void; }; }; const repo = new Repository(dbPath); try { // Member users (role 'user') + a space-owning admin (role 'admin', real id). const seeds = [ { ...ALICE, role: 'user' as const }, { ...BOB, role: 'user' as const }, { ...MANAGER, role: 'admin' as const }, ]; for (const u of seeds) { if (!repo.getUserByEmail(u.email)) { repo.createLocalUser({ email: u.email, password: u.password, name: u.name, role: u.role, status: 'active', }); } } } finally { repo.close?.(); } }); // 1. Sanity: auth is actually ON. The bootstrap admin logs in and /api/auth/me // reports an active admin. (Under auth-off this endpoint 404s.) test('admin login + bootstrap: /api/auth/me reports active admin', async ({ page }) => { await login(page, ADMIN.email, ADMIN.password); const res = await page.request.get('/api/auth/me'); expect(res.ok(), `auth/me status ${res.status()}`).toBeTruthy(); const me = (await res.json()) as { email?: string; role?: string; status?: string }; expect(me.role).toBe('admin'); expect(me.email).toBe(ADMIN.email); expect(me.status).toBe('active'); }); // 2. THE avatar feature: a (non-'local') owning admin creates a case space, // invites alice (editor) via the real picker (NON-empty now that alice is a // real active user), and the header avatar stack appears (members >= 2: // owner manager + alice). test('invite alice → member row + header avatars appear', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await login(page, MANAGER.email, MANAGER.password); await createAndOpenCaseSpace(page, 'E2E共有'); const panel = await openMembersPanel(page); // Before inviting anyone, only the owner (admin) is a member → no avatar stack. await expect(page.getByTestId('space-member-avatars')).toHaveCount(0); // Invite alice via the real picker (non-empty now that she is a real active // user; also asserts the no-auth hint is absent). await inviteMember(page, ALICE.name); // Alice now appears as a member row in the panel. const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name }); await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 }); // AND the header avatar stack renders now that the space has >= 2 members. // We reload first: the header avatar query keys on ['spaceMembers'] while the // panel invalidates ['space-members'] — different keys, so the header does NOT // live-refetch after an in-session invite (a real, minor product cache-key // mismatch — see the harness report). A reload is what a user sees on the next // navigation; this asserts the actual rendered avatar feature, not a cache race. await page.reload(); await expect(page.getByTestId('space-detail')).toBeVisible(); await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 }); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 3. Member collaboration: alice logs in, sees the shared space in her rail // (membership grants visibility), opens it, and the avatar stack renders for // her too. She is NOT admin and not the owner, so she gets read-only member UI. test('alice sees + opens the shared space, avatars render', async ({ page }) => { const fatalErrors = trackFatalErrors(page); // The owning admin creates a fresh shared space and invites alice. await login(page, MANAGER.email, MANAGER.password); const { spaceId } = await createAndOpenCaseSpace(page, 'E2E協働'); const panel = await openMembersPanel(page); await inviteMember(page, ALICE.name); const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name }); await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 }); // Switch to alice. await logout(page); await login(page, ALICE.email, ALICE.password); // The shared space is visible in alice's rail (space_members membership). await page.getByTestId('nav-spaces').click(); const rail = page.getByTestId('space-rail'); await expect(rail).toBeVisible(); const sharedRow = rail.locator(`[data-testid="space-row"][data-space-id="${spaceId}"]`); await expect(sharedRow).toBeVisible({ timeout: 15_000 }); // She can open it (membership grants access — no 403). await sharedRow.click(); await expect(page.getByTestId('space-detail')).toBeVisible(); // The avatar stack renders for her as well (>= 2 members). await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 }); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // 4. Role + remove: the owning admin changes alice's role to viewer, then // removes her. The member row disappears and the avatar stack collapses back // (owner only => members < 2). test('admin changes alice role then removes her', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await login(page, MANAGER.email, MANAGER.password); await createAndOpenCaseSpace(page, 'E2E権限'); let panel = await openMembersPanel(page); await inviteMember(page, ALICE.name); // Resolve alice's member row + her userId from the row testid suffix // (`space-member-{userId}`; the row wrapper is the first prefix match). const aliceRow = () => page.getByTestId('space-members-panel') .locator('div[data-testid^="space-member-"]') .filter({ hasText: ALICE.name }) .first(); await expect(aliceRow()).toBeVisible({ timeout: 15_000 }); const testid = await aliceRow().getAttribute('data-testid'); const aliceId = (testid ?? '').replace(/^space-member-/, ''); expect(aliceId).toBeTruthy(); // Avatars present while she is a member (reload so the header's ['spaceMembers'] // query picks up the invite — see the cache-key note in test 2 / the report). await page.reload(); await expect(page.getByTestId('space-detail')).toBeVisible(); await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 }); // Re-open the panel (reload reset the tab) and change alice editor → viewer. panel = await openMembersPanel(page); const roleSelect = panel.getByTestId(`space-member-role-${aliceId}`); await roleSelect.selectOption('viewer'); await expect(roleSelect).toHaveValue('viewer'); // Remove her (auto-accept the confirm dialog). The panel's own query // invalidates on the same key, so her row disappears in-session. page.once('dialog', (d) => d.accept()); await panel.getByTestId(`space-member-remove-${aliceId}`).click(); await expect(panel.getByTestId(`space-member-${aliceId}`)).toHaveCount(0, { timeout: 15_000 }); // After a reload the header avatar stack collapses (owner only => < 2 members). await page.reload(); await expect(page.getByTestId('space-detail')).toBeVisible(); await expect(page.getByTestId('space-member-avatars')).toHaveCount(0, { timeout: 15_000 }); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); });