sync: update from private repo (f6d625db)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-26 03:35:45 +00:00
parent 29ccaf1e92
commit b857c33ef6
371 changed files with 31312 additions and 8172 deletions
+189
View File
@@ -0,0 +1,189 @@
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 ──────────────────────────────────────
//
// REQUIRES `npm run test:e2e:auth` + a running server (ui/playwright.auth.config.ts
// boots the real orchestrator with LOCAL AUTH on via ui/e2e-auth/config.e2e-auth.yaml).
// Do NOT expect this to run in the sandbox — there is no live server here.
//
// Admin user management is admin-only AND auth-only: the Users tab (page=users)
// renders only when `isAdmin && authEnabled` (App.tsx), and /api/admin/users is
// guarded by requireAdmin. So this can only be exercised under the local-auth
// harness, logged in as an admin. The bootstrap admin ([email protected]) is
// seeded by the server at startup; a regular member + a pending user are seeded
// in beforeAll into the SAME deterministic temp DB the webServer opens (the exact
// pattern used by sharing-scope.auth.spec.ts).
//
// Happy path: admin opens Users, the seeded users are listed, admin promotes the
// regular user to admin and approves the pending user — verified via the
// /api/admin/users API in the admin's authed browser context (the same dual
// UI+API proof sharing-scope uses). Negative/visibility: a regular member who
// logs in gets NO Users tab and is denied /api/admin/users (401/403).
const __dirname = dirname(fileURLToPath(import.meta.url));
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 DB_PATH = join(e2eTmp, 'e2e-auth.db');
// The bootstrap admin from config.e2e-auth.yaml.
const ADMIN = { email: '[email protected]', password: 'AdminPass123!' };
// Seeded personas (own *@adminmgmt.local namespace so they never collide with the
// other auth specs that share this DB).
const MEMBER = { email: '[email protected]', password: 'MemberPass123!', name: 'AdminMgmtMember' };
const PENDING = { email: '[email protected]', password: 'PendingPass123!', name: 'AdminMgmtPending' };
// A regular user that is NEVER promoted, reserved for the denial test (the
// promotion test mutates MEMBER → admin in the shared DB, so MEMBER cannot prove
// the non-admin denial afterwards).
const OUTSIDER = { email: '[email protected]', password: 'OutsiderPass123!', name: 'AdminMgmtOutsider' };
let memberId = '';
let pendingId = '';
let outsiderId = '';
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;
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());
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
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');
}
test.beforeAll(async () => {
// webServer is already up (schema + migrations + bootstrap admin exist). Seed a
// regular active member and a PENDING user into the SAME deterministic DB.
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (dbPath: string) => {
getUserByEmail: (email: string) => { id: string } | null;
createLocalUser: (p: {
email: string; password: string; name?: string; role: string; status: string;
}) => { id: string };
close?: () => void;
};
};
const repo = new Repository(DB_PATH);
try {
const ensure = (u: typeof MEMBER, role: string, status: string) => {
const existing = repo.getUserByEmail(u.email);
if (existing) return existing.id;
return repo.createLocalUser({
email: u.email, password: u.password, name: u.name, role, status,
}).id;
};
memberId = ensure(MEMBER, 'user', 'active');
pendingId = ensure(PENDING, 'user', 'pending');
outsiderId = ensure(OUTSIDER, 'user', 'active');
} finally {
repo.close?.();
}
});
test('seed sanity: the personas exist with the expected roles/status', async ({ page }) => {
expect(memberId, 'member seeded').toBeTruthy();
expect(pendingId, 'pending user seeded').toBeTruthy();
await login(page, ADMIN.email, ADMIN.password);
const res = await page.request.get('/api/admin/users');
expect(res.status(), 'admin lists users').toBe(200);
const users = (await res.json()) as Array<{ id: string; role: string; status: string }>;
const member = users.find((u) => u.id === memberId);
const pending = users.find((u) => u.id === pendingId);
expect(member?.role).toBe('user');
expect(pending?.status).toBe('pending');
});
// Happy path: admin opens the Users page, the seeded users render in the list,
// and admin role-promotes the member + approves the pending user. The mutations
// fire PATCH /api/admin/users/:id; we assert the resulting state via the API in
// the same authed context (UI list rendering + API state = the dual proof used
// across the auth suite).
test('admin can list users, promote a member to admin, and approve a pending user', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, ADMIN.email, ADMIN.password);
await page.goto('/ui?page=users');
// The Users tab is admin+auth gated; with an admin logged in it renders.
await expect(page.getByTestId('nav-users')).toBeVisible();
await expect(page).toHaveURL(/[?&]page=users(&|$)/);
// The seeded users appear in the list (the list renders by email/name text).
await expect(page.getByText(MEMBER.email, { exact: false })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(PENDING.email, { exact: false })).toBeVisible();
// Drive the mutations through the API in the admin's authed browser context.
// (The Users page controls are i18n-text driven without stable test-ids; the
// page.request path exercises the SAME endpoints the buttons call — PATCH
// /api/admin/users/:id — without coupling to translation strings.)
const promote = await page.request.patch(`/api/admin/users/${memberId}`, {
data: { role: 'admin' },
});
expect(promote.ok(), 'promote member to admin').toBeTruthy();
const approve = await page.request.patch(`/api/admin/users/${pendingId}`, {
data: { status: 'active' },
});
expect(approve.ok(), 'approve pending user').toBeTruthy();
// Verify the resulting state via the list endpoint.
const after = await page.request.get('/api/admin/users');
expect(after.status()).toBe(200);
const users = (await after.json()) as Array<{ id: string; role: string; status: string }>;
expect(users.find((u) => u.id === memberId)?.role).toBe('admin');
expect(users.find((u) => u.id === pendingId)?.status).toBe('active');
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 }) => {
const fatalErrors = trackFatalErrors(page);
// OUTSIDER is a plain 'user' that no test promotes, so the non-admin denial
// holds regardless of test order in the shared DB.
await login(page, OUTSIDER.email, OUTSIDER.password);
await page.goto('/ui');
// The Users tab is admin+auth gated; a regular user never sees it.
await expect(page.getByTestId('nav-users')).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());
expect(fatalErrors, `fatal errors (DOM navigation only):\n${fatalErrors.join('\n')}`).toEqual([]);
});
+1 -1
View File
@@ -114,7 +114,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
+14 -2
View File
@@ -119,8 +119,20 @@ async function openSharedSpace(page: Page) {
return detail;
}
/** Open the seeded chat in the already-open shared space; returns the conversation. */
/**
* Open the seeded chat in the already-open shared space; returns the conversation.
*
* The chat list splits by owner scope ('自分' / '他のメンバー'). The seeded chat is
* owned by `manager`, so a viewing MEMBER finds it under '他のメンバー' (the default
* '自分' scope hides it). The toggle only renders when others' chats exist
* (`hasOthersTasks`), so for the OWNER (manager) it is absent and the chat is already
* visible under '自分'. Click the others-scope tab only when it is present.
*/
async function openSeededChat(page: Page) {
const othersScope = page.getByTestId('space-chat-scope-others');
if (await othersScope.count()) {
await othersScope.click();
}
const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${chatTaskId}"]`);
await expect(chatRow).toBeVisible({ timeout: 15_000 });
await chatRow.click();
@@ -140,7 +152,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
@@ -0,0 +1,156 @@
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 ─────────────────────────────────────
//
// Covers the user-observable parts of M0 (spec:
// docs/superpowers/specs/2026-06-24-workspace-consolidation-m0-file-input-consistency-design.md):
//
// 1. Creating a case workspace through the UI scaffolds files/input + files/output
// (scaffoldCaseSpace) — they show up as folders in the Files tab right away.
// 2. The new-chat dialog warns (data-testid="ephemeral-warning") when the user
// switches the workspace mode to 一時的 (ephemeral), and hides it for the
// default 永続 (persistent).
//
// "The agent actually sees pre-placed files" is asserted by the unit test
// (src/engine/agent-loop.test.ts → buildSystemPrompt existing workspace files),
// since asserting LLM behaviour end-to-end is non-deterministic.
//
// Runs with LOCAL AUTH on (ui/playwright.auth.config.ts → config.e2e-auth.yaml)
// against the real built server. Seeds one regular user via the built Repository
// into the same deterministic DB the webServer opens.
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 DB_PATH = join(e2eTmp, 'e2e-auth.db');
const USER = { email: '[email protected]', password: 'WsFile123!', name: 'WsFileUser' };
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');
}
/** Create a fresh case workspace via the UI (this is what triggers scaffoldCaseSpace). */
async function createAndOpenCaseSpace(page: Page, label: string): Promise<void> {
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 title = `${label}-${Date.now()}`;
await titleInput.fill(title);
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: title });
await expect(caseRow).toBeVisible();
await caseRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
test.beforeAll(async () => {
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (dbPath: string) => {
getUserByEmail: (email: string) => { id: string } | null;
createLocalUser: (p: { email: string; password: string; name?: string; role: string; status: string }) => { id: string };
close?: () => void;
};
};
const repo = new Repository(DB_PATH);
try {
if (!repo.getUserByEmail(USER.email)) {
repo.createLocalUser({ email: USER.email, password: USER.password, name: USER.name, role: 'user', status: 'active' });
}
} finally {
repo.close?.();
}
});
test('new case workspace shows input/ and output/ folders in the Files tab', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-files');
await page.getByTestId('space-tab-files').click();
await expect(page.getByTestId('space-files')).toBeVisible();
const inputTile = page.locator('[data-testid="space-file-tile"][data-kind="directory"][data-name="input"]');
const outputTile = page.locator('[data-testid="space-file-tile"][data-kind="directory"][data-name="output"]');
await expect(inputTile).toBeVisible({ timeout: 15_000 });
await expect(outputTile).toBeVisible();
});
test('fresh workspace chat list shows next-action guidance, and +新規 opens the create dialog (step 3 entry)', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-step3');
// The chat tab is the default. A brand-new workspace has no chats, so the
// empty state should guide the user to send a request and explain where files
// and outputs live (step 3 / step 4 next-action copy).
await expect(page.getByTestId('space-tab-chat')).toBeVisible();
await expect(page.getByText('「+ 新規」からエージェントに依頼を送れます')).toBeVisible();
await expect(page.getByText(/成果物は ファイルタブの output\/ に保存されます/)).toBeVisible();
// Step 3 entry: the +新規 button opens the create dialog (no LLM run needed —
// we only assert the entry point reaches the dialog).
await page.getByTestId('space-new-chat-btn').click();
await expect(page.getByTestId('ephemeral-warning')).toHaveCount(0); // dialog open, persistent default
await expect(page.getByRole('button', { name: /永続/ })).toBeVisible();
});
test('opening a fresh chat surfaces step 4 guidance: artifacts live in the output/ folder', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-step4');
// Step 3 → create a chat through the dialog. No LLM runs in this env
// (e2e-noop worker), so the job never completes — we only verify the
// create → open → "where do artifacts go" path, which is step 4's next-action.
await page.getByTestId('space-new-chat-btn').click();
await page.getByTestId('create-task-body').fill('step4 の成果物の在りかを確認するためのチャット');
await page.getByTestId('create-task-submit').click();
// The created chat opens inline (handleSpaceCreate → onSelectSpaceTask).
await expect(page.getByTestId('space-conversation')).toBeVisible({ timeout: 15_000 });
// 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'.)
await page.getByTestId('space-chat-tab-files').click();
await page.getByRole('button', { name: 'output', exact: true }).click();
await expect(page.getByTestId('output-empty-hint')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('output-empty-hint')).toContainText('output/');
});
test('new-chat dialog warns when ephemeral mode is selected, not for persistent', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-warn');
await page.getByTestId('space-new-chat-btn').click();
// Default is persistent → no warning.
await expect(page.getByTestId('ephemeral-warning')).toHaveCount(0);
// Switch to 一時的 → warning appears.
await page.getByRole('button', { name: '一時的' }).click();
await expect(page.getByTestId('ephemeral-warning')).toBeVisible();
// Switch back to 永続 → warning gone.
await page.getByRole('button', { name: /永続/ }).click();
await expect(page.getByTestId('ephemeral-warning')).toHaveCount(0);
});