This commit is contained in:
@@ -2,7 +2,6 @@ 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 ADMIN USER MANAGEMENT E2E ──────────────────────────────────────
|
||||
//
|
||||
@@ -29,7 +28,8 @@ const require = createRequire(import.meta.url);
|
||||
const repoRoot = resolve(__dirname, '..', '..');
|
||||
|
||||
// Same deterministic DB the webServer opens (see playwright.auth.config.ts).
|
||||
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
|
||||
const e2eTmp = process.env.E2E_AUTH_TMP;
|
||||
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
|
||||
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
|
||||
|
||||
// The bootstrap admin from config.e2e-auth.yaml.
|
||||
@@ -167,10 +167,50 @@ test('admin can list users, promote a member to admin, and approve a pending use
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
test('system admin opens the event log and filters its results', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
const resourceId = `e2e-event-log-${Date.now()}`;
|
||||
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
|
||||
Repository: new (dbPath: string) => {
|
||||
recordEvent: (input: Record<string, unknown>) => { id: number };
|
||||
close?: () => void;
|
||||
};
|
||||
};
|
||||
const repo = new Repository(DB_PATH);
|
||||
try {
|
||||
repo.recordEvent({
|
||||
eventType: 'task.failed', resourceType: 'task', resourceId, result: 'failed',
|
||||
dedupeKey: crypto.randomUUID(), correlationId: crypto.randomUUID(),
|
||||
});
|
||||
} finally {
|
||||
repo.close?.();
|
||||
}
|
||||
|
||||
await login(page, ADMIN.email, ADMIN.password);
|
||||
await page.goto('/ui?page=users');
|
||||
await page.getByRole('tab', { name: /イベントログ|Event log/ }).click();
|
||||
await expect(page.getByRole('heading', { name: /イベントログ|Event log/ })).toBeVisible();
|
||||
|
||||
// 絞り込みはイベントログの領域の中だけを見る。上の帯にも「検索」という名前の
|
||||
// ボタン(shell-search)があり、ページ全体から探すと 2 つ当たって落ちる。
|
||||
// AdminEventLogPage の <section aria-labelledby="admin-event-log-title"> が
|
||||
// この領域(role=region)。
|
||||
const eventLog = page.getByRole('region', { name: /イベントログ|Event log/ });
|
||||
await eventLog.getByLabel(/種別|Type/).selectOption('task.failed');
|
||||
await eventLog.getByLabel(/リソースID|Resource ID/).fill(resourceId);
|
||||
await eventLog.getByRole('button', { name: /検索|Search/ }).click();
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: resourceId });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText('task.failed');
|
||||
await expect(row).toContainText('failed');
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// Negative/visibility: a regular member gets NO Users tab and is denied the
|
||||
// admin API. The 403/401 is fetched via page.request (NOT navigation), so the
|
||||
// navigation-scoped fatal tracker never observes the expected denial.
|
||||
test('a non-admin member sees no Users tab and is denied /api/admin/users', async ({ page }) => {
|
||||
test('a non-admin member sees no admin tabs and is denied admin APIs', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
// OUTSIDER is a plain 'user' that no test promotes, so the non-admin denial
|
||||
@@ -180,10 +220,13 @@ test('a non-admin member sees no Users tab and is denied /api/admin/users', asyn
|
||||
|
||||
// The Users tab is admin+auth gated; a regular user never sees it.
|
||||
await expect(page.getByTestId('nav-users')).toHaveCount(0);
|
||||
await expect(page.getByRole('tab', { name: /イベントログ|Event log/ })).toHaveCount(0);
|
||||
|
||||
// Admin-only API is denied for a regular user (401 or 403 depending on guard).
|
||||
const denied = await page.request.get('/api/admin/users');
|
||||
expect([401, 403]).toContain(denied.status());
|
||||
const eventLogDenied = await page.request.get('/api/admin/event-log');
|
||||
expect(eventLogDenied.status()).toBe(403);
|
||||
|
||||
expect(fatalErrors, `fatal errors (DOM navigation only):\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* ログアウトの道具(issue #1484)。
|
||||
*
|
||||
* ログイン直後の画面は、個人ワークスペースを自動で開くために
|
||||
* `GET /api/local/spaces/:id/tool-policy` を投げる。これは一覧
|
||||
* (`GET /api/local/spaces`)が返ってから始まる 2 往復目なので、テストが
|
||||
* 次の利用者へ切り替えようとする頃にちょうど飛んでいることがある。
|
||||
*
|
||||
* ここで `/auth/logout` へそのまま移ると、次の順序が起こりうる。
|
||||
*
|
||||
* 1. 画面が tool-policy を投げる(この時点ではまだログイン済み)
|
||||
* 2. サーバが `/auth/logout` を処理し、セッションを壊す
|
||||
* 3. tool-policy に 401 が返る
|
||||
* 4. `/auth/login` の表示が始まり、古い画面がようやく捨てられる
|
||||
*
|
||||
* 3 と 4 の間はわずか数ミリ秒だが、その間だけ**古い画面は生きたまま 401 を
|
||||
* 受け取る**。spec が仕掛けた `page.on('response')` はそれを拾い、赤になる。
|
||||
* コードを変えずに回し直すと緑になるのはこのためで、CI の走り 3312 が実際に
|
||||
* これで落ちた(3313 は同じコードで緑)。
|
||||
*
|
||||
* 直し方は待ち時間を足すことではない。**合図を消す前に画面を畳む**——
|
||||
* `about:blank` へ移せば画面の JavaScript はそこで止まり、セッションが死んで
|
||||
* いる間に要求を出せる主体がいなくなる。競争そのものが無くなる。
|
||||
*
|
||||
* 逆順(先にログアウト、あとで画面を畳む)に戻すと、この 401 は再び起こる。
|
||||
*/
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export async function logout(page: Page) {
|
||||
// 先に画面を畳む。ここを通さずに /auth/logout へ移ると、上のコメントの
|
||||
// 3〜4 の隙間で古い画面が 401 を受け取る。
|
||||
await page.goto('about:blank');
|
||||
await page.goto('/auth/logout');
|
||||
}
|
||||
@@ -2,7 +2,8 @@ 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';
|
||||
import { openSpaceTab } from '../e2e/helpers/spaceTabs';
|
||||
import { logout } from './helpers/session';
|
||||
|
||||
// ── Local-auth shared-space E2E ───────────────────────────────────────────────
|
||||
//
|
||||
@@ -28,7 +29,9 @@ const repoRoot = resolve(__dirname, '..', '..');
|
||||
// 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 e2eTmp = process.env.E2E_AUTH_TMP;
|
||||
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
|
||||
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
|
||||
|
||||
const ADMIN = { email: '[email protected]', password: 'AdminPass123!' };
|
||||
const ALICE = { email: '[email protected]', password: 'AlicePass123!', name: 'Alice' };
|
||||
@@ -55,12 +58,6 @@ const BENIGN_PATHS = new Set([
|
||||
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[] = [];
|
||||
@@ -68,12 +65,10 @@ function trackFatalErrors(page: Page): string[] {
|
||||
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
|
||||
!BENIGN_PATH_RE.test(pathname)
|
||||
) {
|
||||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||||
}
|
||||
@@ -97,10 +92,6 @@ async function login(page: Page, email: string, password: string) {
|
||||
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) {
|
||||
@@ -132,7 +123,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
|
||||
|
||||
/** Navigate the already-open space to 設定 → メンバー and return the panel. */
|
||||
async function openMembersPanel(page: Page) {
|
||||
await page.getByTestId('space-tab-settings').click();
|
||||
await openSpaceTab(page, 'settings');
|
||||
await page.getByTestId('space-settings-nav-members').click();
|
||||
const panel = page.getByTestId('space-members-panel');
|
||||
await expect(panel).toBeVisible({ timeout: 15_000 });
|
||||
@@ -232,7 +223,11 @@ test('invite alice → member row + header avatars appear', async ({ page }) =>
|
||||
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 });
|
||||
// 招待の一覧(space-member-picker)は除く。候補として同じ名前を出すので、
|
||||
// 含めたままだと「行が出た」と言いながら一覧を見ているだけになりうる。
|
||||
const aliceRow = panel
|
||||
.locator('[data-testid^="space-member-"]:not([data-testid^="space-member-picker"])')
|
||||
.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.
|
||||
@@ -259,7 +254,11 @@ test('alice sees + opens the shared space, avatars render', async ({ page }) =>
|
||||
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 });
|
||||
// 招待の一覧(space-member-picker)は除く。候補として同じ名前を出すので、
|
||||
// 含めたままだと「行が出た」と言いながら一覧を見ているだけになりうる。
|
||||
const aliceRow = panel
|
||||
.locator('[data-testid^="space-member-"]:not([data-testid^="space-member-picker"])')
|
||||
.filter({ hasText: ALICE.name });
|
||||
await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Switch to alice.
|
||||
@@ -295,10 +294,16 @@ test('admin changes alice role then removes her', async ({ 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).
|
||||
// (`space-member-{userId}`).
|
||||
//
|
||||
// 招待の一覧(space-member-picker)を必ず外すこと。あれも
|
||||
// `space-member-` で始まる div で、中に候補として同じ名前が出る。追加した
|
||||
// 直後は行がまだ描かれていないのに一覧だけ残っている瞬間があり、そこを掴むと
|
||||
// userId が 'picker' になって、この後の space-member-role-picker が永遠に
|
||||
// 見つからない(#1399。実際にこの並びで落ちた)。
|
||||
const aliceRow = () =>
|
||||
page.getByTestId('space-members-panel')
|
||||
.locator('div[data-testid^="space-member-"]')
|
||||
.locator('div[data-testid^="space-member-"]:not([data-testid^="space-member-picker"])')
|
||||
.filter({ hasText: ALICE.name })
|
||||
.first();
|
||||
await expect(aliceRow()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { openSpaceTab } from '../e2e/helpers/spaceTabs';
|
||||
|
||||
// ── Local-auth SHARING-SCOPE E2E ──────────────────────────────────────────────
|
||||
//
|
||||
@@ -43,7 +43,8 @@ const require = createRequire(import.meta.url);
|
||||
const repoRoot = resolve(__dirname, '..', '..');
|
||||
|
||||
// Same deterministic DB + worktree dir the webServer opens (playwright.auth.config.ts).
|
||||
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
|
||||
const e2eTmp = process.env.E2E_AUTH_TMP;
|
||||
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
|
||||
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
|
||||
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
|
||||
|
||||
@@ -68,7 +69,6 @@ const BENIGN_PATHS = new Set([
|
||||
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+$/;
|
||||
const SPACE_MEMBERS_RE = /\/api\/local\/spaces\/[^/]+\/members$/;
|
||||
|
||||
function trackFatalErrors(page: Page): string[] {
|
||||
const fatalErrors: string[] = [];
|
||||
@@ -76,12 +76,10 @@ function trackFatalErrors(page: Page): string[] {
|
||||
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
|
||||
!BENIGN_PATH_RE.test(pathname)
|
||||
) {
|
||||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||||
}
|
||||
@@ -101,10 +99,6 @@ async function login(page: Page, email: string, password: string) {
|
||||
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 and select the seeded shared space; returns the detail locator. */
|
||||
async function openSharedSpace(page: Page) {
|
||||
await page.goto('/ui');
|
||||
@@ -163,7 +157,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
|
||||
}
|
||||
|
||||
async function openMembersPanel(page: Page) {
|
||||
await page.getByTestId('space-tab-settings').click();
|
||||
await openSpaceTab(page, 'settings');
|
||||
await page.getByTestId('space-settings-nav-members').click();
|
||||
const panel = page.getByTestId('space-members-panel');
|
||||
await expect(panel).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { logout } from './helpers/session';
|
||||
|
||||
// ── Local-auth WORKSPACE-INVITE-LINK E2E ──────────────────────────────────────
|
||||
//
|
||||
@@ -41,7 +41,9 @@ const repoRoot = resolve(__dirname, '..', '..');
|
||||
// builds it identically: <tmpdir>/maestro-e2e-auth/e2e-auth.db). Computing it
|
||||
// independently keeps the spec decoupled from Playwright's config internals while
|
||||
// guaranteeing both open the SAME SQLite file.
|
||||
const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db');
|
||||
const e2eTmp = process.env.E2E_AUTH_TMP;
|
||||
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
|
||||
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
|
||||
|
||||
const OWNER = { email: '[email protected]', password: 'OwnerPass123!', name: 'InviteOwner' };
|
||||
const JOINER = { email: '[email protected]', password: 'JoinerPass123!', name: 'InviteJoiner' };
|
||||
@@ -74,11 +76,18 @@ function trackFatalErrors(page: Page): string[] {
|
||||
if (res.status() >= 400) {
|
||||
const { pathname } = new URL(res.url());
|
||||
const expectedInvitePreview404 = res.status() === 404 && INVITE_PREVIEW_RE.test(pathname);
|
||||
// ログアウトした直後、次のログイン画面へ移る一瞬に /api/auth/me が 401 で
|
||||
// 返ることがある。これは「今の利用者は誰か」を尋ねて「誰でもない」と答えられた
|
||||
// だけで、不具合ではない(useAuthState はこの 401 を未ログインの合図として使う)。
|
||||
// 出るかどうかは画面の入れ替わりとの競争で決まる。パスと状態を両方指定している
|
||||
// ので、/api/auth/me が 500 を返せば今までどおり失敗として残る(#1399)。
|
||||
const loggedOutAuthProbe401 = res.status() === 401 && pathname === '/api/auth/me';
|
||||
if (
|
||||
!BENIGN_ASSET.test(pathname) &&
|
||||
!BENIGN_PATHS.has(pathname) &&
|
||||
!BENIGN_PATH_RE.test(pathname) &&
|
||||
!expectedInvitePreview404
|
||||
!expectedInvitePreview404 &&
|
||||
!loggedOutAuthProbe401
|
||||
) {
|
||||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||||
}
|
||||
@@ -100,10 +109,6 @@ async function login(page: Page, email: string, password: string) {
|
||||
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');
|
||||
}
|
||||
|
||||
/** Mint an invite for the seeded case space via the owner's authed page.request.
|
||||
* Returns the captured token. */
|
||||
async function createInvite(page: Page, role: 'viewer' | 'editor'): Promise<string> {
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
|
||||
// ── M0: WORKSPACE FILE → AGENT INPUT E2E ─────────────────────────────────────
|
||||
//
|
||||
@@ -27,7 +26,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const repoRoot = resolve(__dirname, '..', '..');
|
||||
|
||||
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
|
||||
const e2eTmp = process.env.E2E_AUTH_TMP;
|
||||
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
|
||||
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
|
||||
|
||||
const USER = { email: '[email protected]', password: 'WsFile123!', name: 'WsFileUser' };
|
||||
@@ -129,10 +129,12 @@ test('opening a fresh chat surfaces step 4 guidance: artifacts live in the outpu
|
||||
|
||||
// Open the Files tab of the opened chat, then switch to the output/ section.
|
||||
// Brand-new chat → output/ is empty → the step 4 hint explains where the
|
||||
// agent's artifacts will land. (The FileBrowser section switcher buttons are
|
||||
// plain-text 'workspace'/'input'/'output'/'logs'.)
|
||||
// agent's artifacts will land. The section switcher button is addressed by its
|
||||
// testid: its label is the plain folder name 'output', and the breadcrumb root
|
||||
// (task-files-breadcrumb-root) carries the same label, so a by-name lookup
|
||||
// matches two elements.
|
||||
await page.getByTestId('space-chat-tab-files').click();
|
||||
await page.getByRole('button', { name: 'output', exact: true }).click();
|
||||
await page.getByTestId('task-file-section-output').click();
|
||||
await expect(page.getByTestId('output-empty-hint')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId('output-empty-hint')).toContainText('output/');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user