import { defineConfig, devices } from '@playwright/test'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { createOwnedRunDir } from '../scripts/e2e-run-dir.mjs'; // __dirname under ESM. const __dirname = dirname(fileURLToPath(import.meta.url)); // Repo root is one level up from ui/. const repoRoot = resolve(__dirname, '..'); // Separate port from the auth-OFF harness (8788) so both configs can coexist. if (!process.env.E2E_AUTH_PORT) process.env.E2E_AUTH_PORT = execFileSync(process.execPath, ['-e', "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})"], { encoding: 'utf8' }).trim(); const PORT = Number(process.env.E2E_AUTH_PORT); const BASE_URL = `http://127.0.0.1:${PORT}`; // Isolated throwaway data + workspace dirs — never touches real ./data/maestro.db. // // Mint the run directory once and propagate it through the environment. Config // worker imports inherit the same value, while concurrent runs/worktrees never // share or delete one another's database. // E2E_RUN_OWNED が立っているのに実行用ディレクトリが無い状態は、手で // 環境変数を export したときにしか作れない。ここで先に落とす: 一時 // ディレクトリを 1 つでも作った後に投げると、config が完走せず // globalTeardown も走らないため、作った分が掃除されず残る。 if (process.env.E2E_RUN_OWNED === '1' && !process.env.E2E_RUN_TMP) { throw new Error('E2E_RUN_OWNED=1 but E2E_RUN_TMP is not set'); } const cache = join(repoRoot, 'node_modules', '.cache'); mkdirSync(cache, { recursive: true }); if (!process.env.E2E_AUTH_TMP) { const ownedData = createOwnedRunDir(cache, 'maestro-e2e-auth-data-'); process.env.E2E_AUTH_TMP = ownedData.directory; process.env.E2E_DATA_TOKEN = ownedData.token; } const e2eTmp = process.env.E2E_AUTH_TMP; if (process.env.E2E_RUN_OWNED !== '1') { const ownedRun = createOwnedRunDir(cache, 'maestro-e2e-auth-run-'); process.env.E2E_RUN_TMP = ownedRun.directory; process.env.E2E_RUN_TOKEN = ownedRun.token; process.env.E2E_RUN_OWNED = '1'; } process.env.E2E_RUN_PREFIX = 'maestro-e2e-auth-run-'; // 冒頭のガードが「OWNED=1 なのに未設定」を弾き、上の分岐が「未 OWNED」を // 必ず設定する。したがってここでは必ず値がある(TS はこの 2 段を追えない)。 const e2eRunTmp = process.env.E2E_RUN_TMP!; process.env.E2E_CACHE_ROOT = cache; const DB_PATH = join(e2eTmp, 'e2e-auth.db'); const WORKTREE_DIR = join(e2eTmp, 'workspaces'); // The checked-in local-auth config YAML (enables POST /auth/local login). const AUTH_CONFIG_SOURCE = resolve(repoRoot, 'ui/e2e-auth/config.e2e-auth.yaml'); // Assemble a THROWAWAY config for the webServer: the checked-in local-auth YAML // plus one harness-only section appended at run time. Same shape as the auth-OFF // harness (ui/playwright.config.ts, #1394); the extra section is deliberately NOT // written into ui/e2e-auth/config.e2e-auth.yaml, because a checked-in config that // says "outbound network is allowed" reads like part of the production setup. // // What gets appended and why (#1399): WITHOUT `safety.bash_allow_network: true` // the webServer refuses to boot on any host where bwrap cannot create a user // namespace. `safety.bash_sandbox` defaults to `auto`, and worker-bootstrap.ts // (#1235) treats "network blocking is configured but cannot take effect" as a // startup error rather than a silent downgrade. The dev container hits exactly // that (the bwrap binary is present, `--unshare-all` is denied), and whether a CI // runner does depends on its container settings — pinning this makes the boot // outcome the same everywhere instead of a property of the host. Nothing is // actually opened up: these specs never reach an LLM, so no agent Bash command // ever runs. // // The write is unconditional on every config evaluation (main process + worker). // That is idempotent: e2eTmp is stable across evaluations in this process and is // inherited by the webServer subprocess via the environment. const AAO_CONFIG = join(e2eTmp, 'e2e-auth-config.yaml'); writeFileSync( AAO_CONFIG, `${readFileSync(AUTH_CONFIG_SOURCE, 'utf8')}\nsafety:\n bash_allow_network: true\n`, ); export default defineConfig({ testDir: resolve(__dirname, 'e2e-auth'), testMatch: '**/*.spec.ts', // Build+boot can be slow; roomy per-test budget. timeout: 60_000, expect: { timeout: 15_000 }, // 走り全体の上限。1 件 60 秒 × 21 件が全部詰まると、サーバー起動の 120 秒と // 合わせて CI のジョブ上限(30 分・ci.yml の timeout-minutes)を食い破り、 // ログが途中で切れた赤になって原因が読めなくなる。実測は 21 件で 58.6 秒 // (CI run 3212)なので 10 分は十分な余裕がありつつ、暴走はここで止まる。 globalTimeout: 600_000, fullyParallel: false, workers: 1, retries: 0, reporter: [['list']], globalTeardown: resolve(__dirname, 'e2e-global-teardown.ts'), use: { baseURL: BASE_URL, headless: true, // 表示言語を日本語に固定する。ここが抜けていると UI は英語で描画され、 // e2e-auth の spec が持つ日本語の文字列('追加'・'リンクが無効です' 等)は // 一つも当たらない。認証なし側(ui/playwright.config.ts)は最初からこれを // 置いていて、こちら側だけ抜けていた(#1399)。 locale: 'ja-JP', // NOTE: the wide viewport that keeps the desktop nav (data-testid="nav-*") // out of the hamburger drawer is set on the chromium project below, AFTER // the Desktop Chrome device spread (which would otherwise pin 1280×720). // Under LOCAL AUTH the TopBar carries extra right-side chrome (user chip, // change-password, logout) that collapses it to compact mode at 1280 — and // the drawer nav exposes no nav-* testids. // This sandbox disables the Chromium sandbox (bwrap/userns off). Launch // with --no-sandbox or every browser launch fails to spawn. launchOptions: { args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], }, trace: 'retain-on-failure', }, projects: [ { name: 'chromium', // Device spread sets 1280×720; override AFTER it so the wide viewport wins. use: { ...devices['Desktop Chrome'], viewport: { width: 1600, height: 900 } }, }, ], // Boot the real orchestrator (worker mode) with LOCAL AUTH ON (AAO_CONFIG // points at the local-auth YAML). The bootstrap admin is seeded at startup; // member users are seeded by the spec's beforeAll into the same temp DB. // The webServer command builds both dist trees before starting Node. webServer: { command: 'node scripts/e2e-web-server.mjs', cwd: repoRoot, url: `${BASE_URL}/ui`, reuseExistingServer: false, timeout: 120_000, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, PORT: String(PORT), E2E_RUN_TMP: e2eRunTmp, DB_PATH, WORKTREE_DIR, AAO_CONFIG, AAO_MODE: 'worker', LOG_LEVEL: 'warn', MCP_ENCRYPTION_KEY: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', }, }, });