sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+6 -5
View File
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -20,8 +20,8 @@ import { fileURLToPath } from 'node:url';
//
// 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
// tool-request spec uses): the DB path comes from the run-scoped E2E_TMP, 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
@@ -34,8 +34,9 @@ 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 e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const dbPath = join(e2eTmp, 'e2e.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
const APP_NAME = 'e2e-public-app';
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import { openSpaceTab } from './helpers/spaceTabs';
const createCaseSpace = async (page: import('@playwright/test').Page) => {
await page.goto('/ui?page=spaces');
await page.getByTestId('create-space-btn').click();
const title = `E2E自動ルール-${Date.now()}`;
await page.getByTestId('space-title-input').fill(title);
await page.getByTestId('space-form-submit').click();
const row = page.getByTestId('space-rail')
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
return await row.getAttribute('data-space-id') as string;
};
test('Auto Prompter: default OFF, retain rules while OFF, enable, and delete', async ({ page }) => {
const spaceId = await createCaseSpace(page);
await openSpaceTab(page, 'settings');
await page.getByTestId('space-settings-nav-auto-prompter').click();
const panel = page.getByTestId('space-auto-prompter-settings');
await expect(panel).toBeVisible();
const master = panel.getByRole('switch', { name: 'Auto Prompterを有効にする' });
await expect(master).toHaveAttribute('aria-checked', 'false');
await expect(panel).toContainText('オフにしても登録済みのルールは保持されます');
await panel.getByRole('button', { name: 'ルールを作成' }).click();
await panel.locator('#auto-prompter-template').selectOption('official_docs');
await panel.getByRole('button', { name: '保存' }).click();
await expect(panel.getByText('コマンドは公式資料を確認する', { exact: true })).toBeVisible();
const offState = await page.request.get(`/api/local/spaces/${encodeURIComponent(spaceId)}/auto-prompter`);
expect(offState.ok()).toBeTruthy();
const offPayload = await offState.json();
expect(offPayload).toMatchObject({ enabled: false });
expect(offPayload.rules).toHaveLength(1);
await master.click();
await expect(master).toHaveAttribute('aria-checked', 'true');
await expect.poll(async () => {
const response = await page.request.get(`/api/local/spaces/${encodeURIComponent(spaceId)}/auto-prompter`);
return (await response.json()).enabled;
}).toBe(true);
page.once('dialog', dialog => void dialog.accept());
await panel.getByRole('button', { name: '「コマンドは公式資料を確認する」を削除' }).click();
await expect(panel.getByText('コマンドは公式資料を確認する', { exact: true })).toHaveCount(0);
});
+66 -1
View File
@@ -1,4 +1,7 @@
import { test, expect, type Page } from '@playwright/test';
import Database from 'better-sqlite3';
import { resolve } from 'node:path';
import { openSpaceTab } from './helpers/spaceTabs';
// E2E smoke for the V2 cross-space calendar (top-bar "カレンダー" tab).
//
@@ -45,6 +48,34 @@ function e2eLocalToday(): string {
return `${d.getFullYear()}-${mm}-${dd}`;
}
const ADJACENT_MONTH_CLOCK = new Date('2026-07-15T12:00:00.000Z');
const ADJACENT_MONTH_FIRST = '2026-06-28';
const ADJACENT_MONTH_LAST = '2026-08-08';
async function seedTaskDate(page: Page, spaceId: string, date: string, body: string): Promise<number> {
const created = await page.request.post('/api/local/tasks', {
data: { body, piece: 'chat', spaceId, workspaceMode: 'persistent' },
});
expect(created.ok()).toBeTruthy();
const taskId = (await created.json()).task.id as number;
const e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const db = new Database(resolve(e2eTmp, 'e2e.db'));
db.pragma('busy_timeout = 8000');
db.prepare('UPDATE local_tasks SET created_at = ?, updated_at = ? WHERE id = ?')
.run(`${date}T12:00:00.000Z`, `${date}T12:00:00.000Z`, taskId);
db.close();
return taskId;
}
async function seedEvent(page: Page, spaceId: string, date: string, title: string, endDate?: string): Promise<number> {
const created = await page.request.post(`/api/local/spaces/${spaceId}/calendar/events`, {
data: { date, end_date: endDate, title },
});
expect(created.ok()).toBeTruthy();
return (await created.json()).id as number;
}
async function createAndOpenCaseSpace(page: Page, label: string) {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
@@ -72,7 +103,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
/** Add an event for today in the currently-open space's calendar tab. */
async function addTodayEvent(page: Page, title: string) {
await page.getByTestId('space-tab-calendar').click();
await openSpaceTab(page, 'calendar');
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
const today = e2eLocalToday();
await page.getByTestId(`space-cal-day-${today}`).click();
@@ -144,3 +175,37 @@ test('cross calendar: a space with activity shows its dot + day panel groups by
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
test('cross calendar: adjacent month days show badges, details, and spanning bars', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.clock.setFixedTime(ADJACENT_MONTH_CLOCK);
const { caseTitle, spaceId } = await createAndOpenCaseSpace(page, 'E2E横断隣接月');
const first = ADJACENT_MONTH_FIRST;
const last = ADJACENT_MONTH_LAST;
const firstTaskId = await seedTaskDate(page, spaceId, first, `横断先頭タスク-${Date.now()}`);
const lastTaskId = await seedTaskDate(page, spaceId, last, `横断末尾タスク-${Date.now()}`);
const firstEvent = `横断先頭予定-${Date.now()}`;
const lastEvent = `横断末尾予定-${Date.now()}`;
await seedEvent(page, spaceId, first, firstEvent);
await seedEvent(page, spaceId, last, lastEvent);
const spanningEvent = `横断両境界予定-${Date.now()}`;
const spanningId = await seedEvent(page, spaceId, first, spanningEvent, last);
await page.getByTestId('nav-calendar').click();
const firstCell = page.getByTestId(`cross-cal-day-${first}`);
const lastCell = page.getByTestId(`cross-cal-day-${last}`);
await expect(firstCell.getByTestId(`cross-cal-dot-${spaceId}`)).toHaveAttribute('title', /タスク.*1.*予定.*2/);
await expect(lastCell.getByTestId(`cross-cal-dot-${spaceId}`)).toHaveAttribute('title', /タスク.*1.*予定.*2/);
await expect(page.getByTestId(`cross-cal-bar-${spanningId}`)).toHaveCount(6);
await firstCell.click();
let group = page.getByTestId(`cross-cal-space-${spaceId}`);
await expect(group.getByText(caseTitle, { exact: true })).toBeVisible();
await expect(group.getByTestId(`cross-cal-task-${firstTaskId}`)).toBeVisible();
await expect(group.getByText(firstEvent, { exact: true })).toBeVisible();
await expect(group.getByText(spanningEvent, { exact: true })).toBeVisible();
await lastCell.click();
group = page.getByTestId(`cross-cal-space-${spaceId}`);
await expect(group.getByTestId(`cross-cal-task-${lastTaskId}`)).toBeVisible();
await expect(group.getByText(lastEvent, { exact: true })).toBeVisible();
await expect(group.getByText(spanningEvent, { exact: true })).toBeVisible();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+27
View File
@@ -0,0 +1,27 @@
/**
* ワークスペースの横帯のタブを開くための道具(issue #1360 段 1)。
*
* アプリ・カレンダー・自動実行・設定の 4 つは「その他」の中へ畳んだので、押す前に
* その入口を開く必要がある。目印(`space-tab-apps` など)は畳む前と同じなので、
* ここを通せば呼ぶ側は今までどおり id だけを指定すればよい。
*
* チャットとファイルは横帯に出たままなので、この道具は要らない。
*/
import { expect, type Page } from '@playwright/test';
/** 「その他」の中に畳んである画面。 */
export type OverflowSpaceTab = 'apps' | 'calendar' | 'schedules' | 'settings';
/**
* 畳んである画面を開く。「その他」を押してからメニューの項目を押す。
* 押したあとメニューは閉じる(項目を選ぶと閉じる作り)。
*/
export async function openSpaceTab(page: Page, tab: OverflowSpaceTab): Promise<void> {
const more = page.getByTestId('space-tab-more');
await expect(more).toBeVisible();
await more.click();
const item = page.getByTestId(`space-tab-${tab}`);
await expect(item).toBeVisible();
await item.click();
await expect(page.getByTestId('space-tab-more-menu')).toHaveCount(0);
}
+102
View File
@@ -0,0 +1,102 @@
import { expect, test } from '@playwright/test';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const dbPath = resolve(e2eTmp, 'e2e.db');
type SeedRepository = {
ensureLocalUser: () => void;
createUser: (input: Record<string, unknown>) => { id: string };
createSpace: (input: Record<string, unknown>) => Promise<{ id: string }>;
createLocalTask: (input: Record<string, unknown>) => Promise<{ id: number }>;
recordEvent: (input: Record<string, unknown>) => { id: number };
createNotificationItem: (input: Record<string, unknown>) => { id: string };
createAgentReminder: (input: Record<string, unknown>) => { id: string };
close: () => void;
};
const openRepository = (): SeedRepository => {
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (path: string) => SeedRepository;
};
const repository = new Repository(dbPath);
// The notification_items recipient FK must not depend on the web server
// having completed its no-auth bootstrap before this fixture runs.
repository.ensureLocalUser();
return repository;
};
test('notification bell shows unread inbox and opens the notification center', async ({ page }) => {
const title = `E2E未読通知-${Date.now()}`;
const repo = openRepository();
try {
const space = await repo.createSpace({ kind: 'case', title: `E2E通知-${Date.now()}`, ownerId: 'local', visibility: 'private' });
const task = await repo.createLocalTask({ title, body: '', ownerId: 'local', spaceId: space.id });
const event = repo.recordEvent({
eventType: 'task.failed', resourceType: 'task', resourceId: `e2e-${Date.now()}`,
result: 'failed', dedupeKey: crypto.randomUUID(), correlationId: crypto.randomUUID(),
});
repo.createNotificationItem({
eventId: event.id, recipientUserId: 'local', category: 'failed', title,
summary: 'E2E notification summary', spaceId: space.id,
resourceType: 'job', resourceId: `e2e-${event.id}`, occurredAt: new Date().toISOString(),
metadata: { localTaskId: task.id },
});
} finally {
repo.close();
}
await page.goto('/ui');
const seededInbox = await page.request.get('/api/notifications/inbox?limit=5');
expect(seededInbox.ok()).toBeTruthy();
expect((await seededInbox.json() as { items: Array<{ title: string }> }).items.map(item => item.title)).toContain(title);
const bell = page.getByTestId('notification-bell-trigger');
await expect(bell).toBeVisible();
await expect(page.getByTestId('notification-bell-badge')).toBeVisible();
await bell.click();
const panel = page.getByTestId('notification-bell-panel');
await expect(panel).toBeVisible();
await expect(panel.getByRole('button', { name: new RegExp(title) })).toBeVisible();
await panel.getByRole('button', { name: /すべて表示|View all/ }).click();
await expect(page).toHaveURL(/[?&]page=notifications(&|$)/);
await expect(page.getByRole('heading', { name: /通知センター|Notification center/ })).toBeVisible();
const row = page.getByRole('button', { name: new RegExp(title) });
await expect(row).toBeVisible();
await expect(row.getByText(/未読|Unread/)).toBeAttached();
await row.click();
await expect(page).toHaveURL(/[?&]space=[^&]+.*[?&]chat=\d+/);
await expect(page.getByTestId('notification-bell-badge')).toHaveCount(0);
});
test('upcoming reminders show only reminders assigned to the current user', async ({ page }) => {
const mine = `E2E本人の予定-${Date.now()}`;
const other = `E2E他人の予定-${Date.now()}`;
const repo = openRepository();
try {
const otherUser = repo.createUser({
email: `other-reminder-${crypto.randomUUID()}@example.test`, name: 'Other reminder owner',
role: 'user', status: 'active',
});
repo.createAgentReminder({ ownerId: 'local', title: mine, body: mine, dueAt: '2099-01-02T03:04:00.000Z', timezoneId: 'UTC' });
repo.createAgentReminder({ ownerId: otherUser.id, title: other, body: other, dueAt: '2099-01-02T04:04:00.000Z', timezoneId: 'UTC' });
} finally {
repo.close();
}
await page.goto('/ui?page=notifications&notificationTab=upcoming');
await expect(page.getByRole('tab', { name: /予定|Upcoming/ })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(mine, { exact: true })).toBeVisible();
await expect(page.getByText(other, { exact: true })).toHaveCount(0);
const response = await page.request.get('/api/notifications/upcoming');
expect(response.ok()).toBeTruthy();
const body = await response.json() as { items: Array<{ title: string }> };
expect(body.items.map(item => item.title)).toContain(mine);
expect(body.items.map(item => item.title)).not.toContain(other);
});
+20 -12
View File
@@ -12,12 +12,20 @@ import { test, expect, type Page } from '@playwright/test';
// create a custom piece from the sidebar, then edit + save it through the editor.
//
// Selector grounding (read from the real components):
// - PiecesPage.tsx: the "+" button next to the "Custom Pieces" section opens an
// inline input with placeholder="piece-name"; Enter creates the piece via
// POST /api/pieces and selects it.
// - PieceEditor.tsx: a Visual/YAML mode toggle ("Visual" / "YAML" literal
// buttons), a <textarea> for the YAML, and a footer "Save" button (enabled
// only when dirty). These are English literals, not i18n keys.
// - PiecesPage.tsx: the "+" button next to the custom-workflows section opens
// an inline input with placeholder="piece-name"; Enter creates the piece via
// POST /api/pieces and selects it. The two section headings USED to be
// hardcoded English ("Default Workflows" / "Custom Workflows"); issue #1430
// moved them behind i18n (pieces.json sidebar.defaultSection/customSection).
// This runner's locale is ja-JP, so every assertion on those headings has to
// accept both languages -- the same shape as spaces.spec.ts uses for the
// Default/Custom section labels. An English-only literal here does not fail
// loudly: getByText simply never matches, and a toHaveCount(0) assertion
// passes for the wrong reason.
// - PieceEditor.tsx: a Visual/YAML mode toggle, a <textarea> for the YAML, and
// a footer "Save" button (enabled only when dirty). "YAML" and "Save" are
// still literals; "Visual", "Delete" and the read-only badge are i18n
// (settings.json pieceEditor.*, common.json delete), so match both languages.
// Backend state is verified through GET /api/pieces?source=custom — the same
// endpoint the page reads.
@@ -56,9 +64,9 @@ test('create a custom piece from the sidebar, then edit + save it via the editor
await page.goto('/ui?page=pieces');
await expect(page).toHaveURL(/[?&]page=pieces(&|$)/);
// The "Custom Pieces" section + its "+" create affordance render. The "+" is
// The custom-workflows section + its "+" create affordance render. The "+" is
// the only button in that section header (title=newPieceTitle).
await expect(page.getByText('Custom Pieces', { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/^(Custom Workflows|カスタムワークフロー)$/)).toBeVisible({ timeout: 15_000 });
const createToggle = page.locator('button[title]').filter({ hasText: '+' }).first();
await createToggle.click();
@@ -139,17 +147,17 @@ test('a built-in piece opens editable for admin (no Delete, Save appears once di
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=pieces');
await expect(page.getByText('Default Pieces', { exact: false })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/^(Default Workflows|標準ワークフロー)$/)).toBeVisible({ timeout: 15_000 });
// Open the built-in 'chat' piece from the Default Pieces section.
// Open the built-in 'chat' piece from the default-workflows section.
await page.getByRole('button', { name: /chat/i }).first().click();
// No readonly badge (this session is admin) and no Delete (built-ins are
// never deletable), but the editable footer with Save is present.
const saveBtn = page.getByRole('button', { name: 'Save' });
await expect(saveBtn).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/read-?only/i)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0);
await expect(page.getByText(/read-?only|読み取り専用/i)).toHaveCount(0);
await expect(page.getByRole('button', { name: /^(Delete|削除)$/ })).toHaveCount(0);
await expect(saveBtn).toBeDisabled();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
+71 -17
View File
@@ -1,25 +1,33 @@
import { test, expect, type Page } from '@playwright/test';
import { openSpaceTab } from './helpers/spaceTabs';
// ── Schedules (scheduled task CRUD) E2E ───────────────────────────────────────
// ── Automations (scheduled task CRUD) E2E ─────────────────────────────────────
//
// 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.
//
// The Schedules page (page=schedules) is NOT auth-gated (NAV_ITEMS: schedules
// requiresAuth:false). The cronForm lib is partially unit-covered; this proves
// the create/list/edit UI wiring that has no e2e.
// WHERE the page lives: commit e6733e58 retired the top-level Automations tab
// and moved automations inside each workspace — `?page=schedules` now falls back
// to the spaces view, so the old `page.goto('/ui?page=schedules')` never reached
// the page and both tests in this file had been failing since (issue #1338). The
// screen itself is unchanged: SpaceDetail renders the SAME SchedulesPage
// component under its 自動実行 tab, which lives in the 「その他」 overflow menu
// (helpers/spaceTabs.ts). So this spec drives it there instead, and the
// selectors below still hold. Neither the workspaces view nor the automations
// screen is auth-gated. The cronForm lib is partially unit-covered; this proves
// the create/list/edit UI wiring that has no other e2e.
//
// Selector grounding (read from SchedulesPage.tsx + i18n locales en/ja):
// - List: a "New schedule" / "新しいスケジュール" button (list.new) opens the editor.
// - List: a "New automation" / "新しい自動実行" button (list.new) opens the editor.
// - Editor: a Title input (placeholder "Weekly news roundup" / "週次ニュースまとめ"),
// a Prompt textarea (placeholder "Enter the prompt to run" / "実行するプロンプトを入力"),
// and a submit button "Create"/"作成" (gated on a non-empty prompt body).
// - Each schedule renders as a list <button> showing its title.
// Because these controls are i18n-text driven (no test-ids) and the runner's
// browser locale decides en vs ja, text selectors below use bilingual regexes.
// Backend state is verified through GET /api/scheduled-tasks — the same endpoint
// the page reads.
// Backend state is verified through GET /api/scheduled-tasks?spaceId=… — the
// same endpoint the space-scoped page reads.
const BENIGN_PATHS = new Set([
'/api/auth/me',
@@ -30,6 +38,11 @@ const BENIGN_PATHS = new Set([
'/api/ssh/connections',
]);
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
// The workspace opens on its chat tab before we reach 自動実行; those two probes
// 404 on a workspace with no console session / browser session (same allowlist
// as spaces.spec.ts and calendar.spec.ts).
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
@@ -37,7 +50,7 @@ function trackFatalErrors(page: Page): string[] {
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname) && !BENIGN_PATH_RE.test(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
@@ -45,23 +58,63 @@ function trackFatalErrors(page: Page): string[] {
return fatalErrors;
}
/**
* Create a case workspace and open it. Returns its id so the spec can scope the
* backend reads to it (mirrors the same helper in calendar.spec.ts).
*/
async function createAndOpenCaseSpace(page: Page, label: string): Promise<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, 'created space id').toBeTruthy();
await caseRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
return spaceId as string;
}
/**
* Reopen an existing workspace's 自動実行 tab from a cold load. The open tab is
* component state, not a URL param, so the reload lands on 会話 and the overflow
* menu has to be walked again.
*/
async function reopenSpaceSchedules(page: Page, spaceId: string): Promise<void> {
await page.goto(`/ui?space=${encodeURIComponent(spaceId)}`);
await expect(page.getByTestId('space-detail')).toBeVisible();
await openSpaceTab(page, 'schedules');
}
// Bilingual matchers (the runner's browser locale picks en or ja; the harness
// pins no localStorage language, so navigator decides).
const NEW_SCHEDULE = /New schedule|新しいスケジュール/;
const NEW_AUTOMATION = /New automation|新しい自動実行/;
const SUBMIT_CREATE = /^(Create|作成)$/;
const TITLE_PLACEHOLDER = /Weekly news roundup|週次ニュースまとめ/;
const PROMPT_PLACEHOLDER = /Enter the prompt to run|実行するプロンプトを入力/;
const scheduleTitle = `e2e-schedule-${Date.now()}`;
test('create a scheduled task via the UI, then see it listed and persisted', async ({ page }) => {
test('create an automation via the UI, then see it listed and persisted', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=schedules');
await expect(page).toHaveURL(/[?&]page=schedules(&|$)/);
const spaceId = await createAndOpenCaseSpace(page, 'E2E自動実行');
await openSpaceTab(page, 'schedules');
// Open the editor.
await page.getByRole('button', { name: NEW_SCHEDULE }).first().click();
await page.getByRole('button', { name: NEW_AUTOMATION }).first().click();
// Fill the editor: title + prompt (the submit button is gated on a non-empty
// prompt body). Default scheduleType is 'daily', so no extra cron input needed.
@@ -80,7 +133,7 @@ test('create a scheduled task via the UI, then see it listed and persisted', asy
let createdId = 0;
await expect
.poll(async () => {
const res = await page.request.get('/api/scheduled-tasks');
const res = await page.request.get(`/api/scheduled-tasks?spaceId=${encodeURIComponent(spaceId)}`);
if (!res.ok()) return [];
const data = (await res.json()) as Array<{ id: number; title: string; cronExpression?: string }> | { tasks?: Array<{ id: number; title: string }> };
const list = Array.isArray(data) ? data : (data.tasks ?? []);
@@ -101,7 +154,7 @@ test('create a scheduled task via the UI, then see it listed and persisted', asy
expect(patched.ok(), 'edit schedule title').toBeTruthy();
// The list reflects the edit after a fresh load (refetches /api/scheduled-tasks).
await page.goto('/ui?page=schedules');
await reopenSpaceSchedules(page, spaceId);
await expect(page.getByRole('button', { name: new RegExp(editedTitle) })).toBeVisible({ timeout: 15_000 });
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
@@ -112,8 +165,9 @@ test('create a scheduled task via the UI, then see it listed and persisted', asy
test('the create button stays disabled until a prompt is entered', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=schedules');
await page.getByRole('button', { name: NEW_SCHEDULE }).first().click();
await createAndOpenCaseSpace(page, 'E2E自動実行ガード');
await openSpaceTab(page, 'schedules');
await page.getByRole('button', { name: NEW_AUTOMATION }).first().click();
// With only a title (no prompt body) the submit is disabled.
await page.getByPlaceholder(TITLE_PLACEHOLDER).fill('no-prompt-schedule');
+252
View File
@@ -0,0 +1,252 @@
/**
* 「コマンドの外部通信あり」の印を、幅を振って実測する(PR #1374 レビュー P2-B)。
*
* この印は**絶対に切り落とさない**——設定を開かない閲覧者に、いま外へ出られる状態か
* どうかを見せるためだけに出しているので、「🌐 コマンドの」で千切れたら意味が無い。
*
* それを守っているのが余白の算数だけだった時期がある。狭い画面で切れないのは
* **たまたま余白が足りていたから**で、名札が数十ピクセル伸びるだけで元の不具合が
* 戻った(直す前のコードを日本語・430px で測った値: +50px で 1.000、+60px で 0.935。
* 切れはじめるのはこの 2 点のあいだ。中間の 1 点は測っていないので書かない)。
* いまは組み方で守っている(`SpaceDetail` のタブ群=横に流れる箱、印=`shrink-0`)が、
* jsdom は幅を測れないので、**そこが効いていることは実機でしか確かめられない。**
*
* ここで見るのは 3 つ。
* 1. どの幅でも、上の帯の札が押し込まれない(この PR の存在理由)
* 2. どの幅でも、印は 1 ピクセルも切れない(見えている割合が 1.00)
* 3. 上の帯の札が伸びても切れない(翻訳が長くなる・札が増える、を先取りする)
*
* 1 が要る理由: この PR は「タブの位置が動かないこと」のために書かれている。その位置を
* 守っていた `shrink-0` を、印を守るために外してタブを縮む側へ回した。**守る相手を
* 取り替えたのに網が印しか見ていない**状態だったのを埋める(レビュー P2-3)。
*
* ついでに、選んでいるファイルの札の下地が**本当に塗られている**ことも見る。ここは
* 一度、ビルド後に 1 行も出ないクラス(不透明度つきの CSS 変数)で塗ったつもりに
* なっていた(レビュー P2-A)。透明かどうかは実機の計算値でしか分からない。
*/
import { test, expect, type Page } from '@playwright/test';
/** 印を必ず出す。実際の値は隔離環境の有無で変わるので、応答を差し替えて固定する。 */
async function forceNetworkBadge(page: Page): Promise<void> {
await page.route('**/api/local/spaces/*/tool-policy', async (route) => {
if (route.request().method() !== 'GET') return route.fallback();
const res = await route.fetch();
const body = await res.json();
await route.fulfill({
response: res,
json: {
...body,
sandboxNetwork: {
workspace: true,
global: true,
effective: true,
source: 'workspace',
sandboxMode: 'always',
bwrapAvailable: true,
bashUnrestricted: false,
},
},
});
});
}
/** 案件ワークスペースを 1 つ作って開く。 */
async function createAndOpenCaseSpace(page: Page): 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 = `帯の印-${Date.now()}`;
await titleInput.fill(title);
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const row = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
/**
* 印のうち、帯の中に見えている割合。1 = 全部見えている。
* 帯(ヘッダー行)の外へはみ出した分は、画面では切り取られて読めない。
*/
async function badgeVisibleRatio(page: Page): Promise<{ ratio: number; width: number }> {
return page.evaluate(() => {
const badge = document.querySelector('[data-testid="sandbox-network-badge"]');
if (!badge) throw new Error('印が出ていない');
const row = document.querySelector('[data-testid="space-tab-group"]')!.parentElement!;
const rr = row.getBoundingClientRect();
const br = badge.getBoundingClientRect();
const seen = Math.max(0, Math.min(br.right, rr.right) - Math.max(br.left, rr.left));
return { ratio: Number((seen / br.width).toFixed(3)), width: Number(br.width.toFixed(1)) };
});
}
/**
* 上の帯の札 3 枚の位置と、札を入れた箱がはみ出しているか。
*
* **画面の左端からの座標では見ない。**幅で切り替わる場所が 2 つあって、そこでは
* 座標が動くのが正しい(実測):
* - 640px(`sm`)でファイルの札に文字が出る → 札の幅 34 → 96、その右の
* 「その他」が 62px 右へ動く
* - 768px でチャット一覧の列が出る → 帯ごと右へ寄る(チャットの札 16 → 272)
* どちらも狭くなって削られたのではなく、**別の見た目に切り替わった**だけ。
*
* この PR が守るのは「札が押し込まれて動かないこと」なので、タブ群の左端からの
* 差で見る。差が変わる=札どうしが詰められたか、箱がはみ出して切り取られたか。
* `clipped` は箱がはみ出している状態そのもの(切り取られた分は横になぞらないと出ない)。
*/
async function tabOffsets(page: Page): Promise<Record<string, number | boolean>> {
return page.evaluate(() => {
const find = (testid: string) => {
const el = document.querySelector(`[data-testid="${testid}"]`);
if (!el) throw new Error(`${testid} が出ていない`);
return el;
};
const group = find('space-tab-group') as HTMLElement;
const box = group.firstElementChild as HTMLElement;
const left = (testid: string) => find(testid).getBoundingClientRect().left;
const chat = left('space-tab-chat');
const round = (n: number) => Number(n.toFixed(1));
return {
chat: round(chat - group.getBoundingClientRect().left),
files: round(left('space-tab-files') - chat),
more: round(left('space-tab-more') - chat),
clipped: box.scrollWidth > box.clientWidth,
};
});
}
/** 見た目が切り替わる境目。ここを跨ぐと札の並びが変わるのが正しい。 */
const FILES_LABEL_BREAKPOINT = 640;
/**
* 幅を変えたら、測る前に 1 フレーム置く。変えた直後に測ると、md の境目(768px)を
* 跨いだ瞬間の**崩れた 1 フレーム**を掴むことがある(レビュー担当の掃引で 4 回に 1 回、
* タブ群の幅 0・印の割合 0.535 が観測された。**こちらの実行では未再現**——待つように
* 直す前後で 768px を跨いだ掃引を何度か回したが、いずれも綺麗だった)。
* 利用者に見えるのは窓を跨いでリサイズした 1 フレームだけなので不具合ではないが、
* 待たずに測る網は、いつか CI で理由の分からない赤を出す(レビュー P3-6)。
*/
async function resizeAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 720 });
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
/** 上の帯の札を、翻訳が長くなったのと同じだけ横に伸ばす。0 で元に戻す。 */
async function growTabLabels(page: Page, extraPx: number): Promise<void> {
await page.evaluate((px) => {
document.getElementById('e2e-tab-growth')?.remove();
if (px <= 0) return;
const style = document.createElement('style');
style.id = 'e2e-tab-growth';
// 札 3 つに均等に配る(チャット・ファイル・その他)。
style.textContent =
`[data-testid="space-tab-group"] button { margin-inline-end: ${px / 3}px !important; }`;
document.head.appendChild(style);
}, extraPx);
}
const WIDTHS = [320, 375, 429, 430, 480, 640, 768, 900, 1024, 1440];
test.describe('ワークスペースの帯: 外部通信の印は切れない', () => {
test('どの幅でも、印は 1 ピクセルも切れない', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
await expect(page.getByTestId('sandbox-network-badge')).toBeVisible();
const seen = new Map<string, Record<string, number | boolean>>();
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
const { ratio, width: badgeWidth } = await badgeVisibleRatio(page);
expect(badgeWidth, `${width}px で印の幅が 0`).toBeGreaterThan(0);
expect(ratio, `${width}px で印が切れた`).toBe(1);
// 札の並びは、境目を跨がないかぎりどの幅でも同じでなければならない。
const key = width < FILES_LABEL_BREAKPOINT ? '狭' : '広';
const now = await tabOffsets(page);
expect(now.clipped, `${width}px で札の箱がはみ出した`).toBe(false);
const first = seen.get(key);
if (first) expect(now, `${width}px で上の帯の札が押し込まれた`).toEqual(first);
else seen.set(key, now);
}
});
test('上の帯の札が伸びても、印は切れない(翻訳が長くなる分の先取り)', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
await expect(page.getByTestId('sandbox-network-badge')).toBeVisible();
// 段は直す前のコードの実測に合わせて選ぶ。+50px までは直す前でも 1.000 で、
// 430px で初めて欠けるのは +60px(0.935)。ここを踏まないと網の目が粗すぎて、
// 組み方を書き戻しても +100px まで赤くならない。
for (const extra of [25, 50, 60, 100]) {
await growTabLabels(page, extra);
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
const { ratio } = await badgeVisibleRatio(page);
expect(ratio, `札を +${extra}px 伸ばした ${width}px で印が切れた`).toBe(1);
}
}
await growTabLabels(page, 0);
});
test('印の文字は、切れるのではなく丸ごと消える', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
const badge = page.getByTestId('sandbox-network-badge');
await expect(badge).toBeVisible();
// 430px 以上では文が出る。それより狭いと絵だけになるが、読み上げ名は変わらない。
await resizeAndSettle(page, 430);
const wide = await badge.evaluate((el) => el.getBoundingClientRect().width);
const ariaWide = await badge.getAttribute('aria-label');
await resizeAndSettle(page, 429);
const narrow = await badge.evaluate((el) => el.getBoundingClientRect().width);
expect(narrow).toBeLessThan(wide);
expect(await badge.getAttribute('aria-label')).toBe(ariaWide);
expect(await badge.getAttribute('title')).toBe(ariaWide);
});
});
test.describe('ワークスペースの帯: 選んでいるファイルの札', () => {
test('選んでいるあいだ、下地が本当に塗られている', async ({ page }) => {
await createAndOpenCaseSpace(page);
const files = page.getByTestId('space-tab-files');
await files.click();
await expect(files).toHaveAttribute('aria-current', 'true');
// 札には `transition-colors`(150ms)が掛かっている。選んでいない状態の下地は
// 指定なし=透明なので、印が付いた直後に読むと**移り始めの透明**が返る。それは
// このテストが探している「1 行も CSS が出ていない」状態と計算値で区別できない
// (実測: CI で同じ中身が緑・緑・赤と揺れた。#1407)。落ち着くまで読み直す。
//
// 透明が返るのは一瞬ではない。移り変わりは最初の描画が来るまで始まらないので、
// 手元の実測でおよそ 25 ミリ秒続いた。CI が詰まっていればもっと伸びる。
//
// 移り変わりを消して直さないこと ── 網の都合で利用者に見える動きを変えることになる。
//
// 罫線はここで見ない。選んでいないときの罫線は `border-hairline`(#e4e4e7)で、
// **透明を一度も通らない**。上の理屈が当てはまらないので、待って読み直す意味が無い。
// 「塗られていること」を罫線で確かめる断言も、選んでいない側から不透明なので、
// 塗り分けの有無を切り分けられない。落ちない断言は守れているように見えるだけ。
const TRANSPARENT = /rgba\(0,\s*0,\s*0,\s*0\)|transparent/;
const bgOf = () => files.evaluate((el) => getComputedStyle(el).backgroundColor);
await expect.poll(bgOf, { message: '下地が塗られない' }).not.toMatch(TRANSPARENT);
});
});
+281
View File
@@ -0,0 +1,281 @@
/**
* 狭い画面で、キーボードの焦点が帯の外にある操作子を引き込むこと(issue #1379)。
*
* ワークスペースの帯の右側(名前・鉛筆・顔・ゴミ箱)は横に流れる箱に入っている。
* 指やマウスなら横になぞって出せるが、**焦点を移しただけでは箱が動かず**、削除
* ボタンが帯からはみ出したまま押される画面があった(430 ピクセル前後。はみ出し
* 始める幅は文字の計量で機械ごとに前後する)。
*
* ここで見るのは 3 つ。
* 1. Tab で焦点を移したら、**手で目一杯なぞったときと同じところまで**見える
* 2. 縦は動かない(`block: 'nearest'`。外すとページ全体が縦に飛ぶ)
* 3. **マウスで押したときは箱が動かない**(押した瞬間にずれると押し間違える)
*
* **「割合 1.00」を基準にしない。**流れる箱の内側の幅が削除ボタンより狭い画面が
* あり(実測で箱 27px / ボタン 28px)、そこでは指でどれだけなぞっても 1 にならない。
* 中身の幅はフォントの計量で機械ごとに変わるので、絶対値を焼き込むと別の機械で
* 嘘になる。基準は**利用者が手で到達できるいちばん良い位置**に取る。
*
* jsdom は幅を測れないので、1 と 3 の「本当に見えているか」は実機でしか分からない。
* 配線(帯の行に処理が付いているか)は SpaceDetail.focusScroll.test.tsx が見る。
*/
import { test, expect, type Page } from '@playwright/test';
/** 案件ワークスペースを 1 つ作って開く(削除ボタンは案件ワークスペースにだけ出る)。 */
async function createAndOpenCaseSpace(page: Page): 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 = `帯の焦点-${Date.now()}`;
await titleInput.fill(title);
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const row = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
/** 幅を変えたら 1 フレーム置いてから測る(space-header-badge.spec.ts と同じ理由)。 */
async function resizeAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 720 });
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
interface Measured {
/** 削除ボタンのうち、流れる箱の中に見えている割合。1 = 全部見えている。 */
ratio: number;
/** 横に流れる箱をどれだけなぞった状態か。 */
scrollLeft: number;
/** これ以上なぞれない位置(`scrollWidth - clientWidth`)。 */
maxScrollLeft: number;
/** 帯の上端(縦に飛んでいないかを見る)。 */
rowTop: number;
/** ページ全体の縦位置。 */
pageScrollTop: number;
/** いま焦点があるのが削除ボタンか。 */
deleteFocused: boolean;
/** 削除ボタンの右端(はみ出し量を報告に書くため)。 */
right: number;
}
async function measure(page: Page): Promise<Measured> {
return page.evaluate(() => {
const btn = document.querySelector('[data-testid="space-delete"]');
if (!btn) throw new Error('削除ボタンが出ていない');
const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement;
const row = box.parentElement!;
const rr = row.getBoundingClientRect();
// 切り取られるのは**流れる箱の縁**であって、帯の行の縁ではない(行には右余白が
// あるので、行で測ると数ピクセルぶん甘く出る)。
const cr = box.getBoundingClientRect();
const br = btn.getBoundingClientRect();
const seen = Math.max(0, Math.min(br.right, cr.right) - Math.max(br.left, cr.left));
return {
ratio: Number((seen / br.width).toFixed(3)),
scrollLeft: Number(box.scrollLeft.toFixed(1)),
maxScrollLeft: Number((box.scrollWidth - box.clientWidth).toFixed(1)),
rowTop: Number(rr.top.toFixed(1)),
pageScrollTop: Number(document.scrollingElement!.scrollTop.toFixed(1)),
deleteFocused: document.activeElement === btn,
right: Number(br.right.toFixed(1)),
};
});
}
/** なぞった位置を 0 に戻す(前の幅で引き込んだ分を持ち越さないため)。 */
async function resetScroll(page: Page): Promise<void> {
await page.evaluate(() => {
(document.querySelector('[data-testid="space-header-meta"]') as HTMLElement).scrollLeft = 0;
});
}
/**
* 手で目一杯なぞったときに、削除ボタンがどこまで見えるかを測る。
*
* これが**利用者に到達できるいちばん良い位置**であり、引き込みの合否はここを基準に
* 判定する。絶対値 1.00 は基準にできない(冒頭の説明を参照)。
*/
async function measureManualBest(page: Page): Promise<Measured> {
await page.evaluate(() => {
const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement;
box.scrollLeft = box.scrollWidth;
});
return measure(page);
}
/**
* 本物の Tab で削除ボタンまで進む。
*
* **`locator.focus()` を使わない。**あれは押鍵を発生させないので、
* 「キーボードのときだけ引き込む」判定を通り抜けてしまい、判定が壊れていても
* 緑のままになる。
*/
async function tabToDeleteButton(page: Page): Promise<number> {
for (let i = 1; i <= 40; i++) {
await page.keyboard.press('Tab');
const onDelete = await page.evaluate(
() => document.activeElement?.getAttribute('data-testid') === 'space-delete',
);
if (onDelete) return i;
}
throw new Error('Tab を 40 回押しても削除ボタンに届かなかった');
}
/** 帯の中の、押しても画面が変わらないところへ焦点を戻す(Tab の出発点)。 */
async function resetFocusToTabStrip(page: Page): Promise<void> {
await page.getByTestId('space-tab-chat').click();
}
const WIDTHS = [429, 430, 435, 439, 445];
/**
* 測り方の丸め(`toFixed(3)`)とサブピクセルのぶんの遊び。
* 引き込みが手なぞりと同じ位置に着いても、割合が 0.001 単位でずれることがある。
*/
const RATIO_EPSILON = 0.01;
/**
* 削除ボタンが実際にはみ出す幅を 1 つ選ぶ。
*
* **430 を決め打ちにしない。**はみ出し始める幅は文字の計量で機械ごとに動くので、
* 決め打ちにすると別の機械では「はみ出していないところ」を測って空振りする。
*/
async function findOverflowWidth(page: Page): Promise<number> {
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const m = await measure(page);
if (m.ratio < 1) return width;
}
throw new Error('どの幅でも削除ボタンがはみ出さない(この網の前提が崩れている)');
}
test.describe('ワークスペースの帯: キーボードの焦点は画面外の操作子を引き込む', () => {
test('Tab で削除ボタンへ移ると、手でなぞれるところまで引き込む(縦は動かない)', async ({ page }) => {
await createAndOpenCaseSpace(page);
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const before = await measure(page);
// 手で目一杯なぞった結果を先に測る。これが合否の基準。
const manual = await measureManualBest(page);
await resetScroll(page);
await tabToDeleteButton(page);
const after = await measure(page);
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(
`[#1379] ${width}px 焦点前 割合=${before.ratio} なぞり=${before.scrollLeft} 右端=${before.right}` +
` / 手なぞり最大 割合=${manual.ratio} なぞり=${manual.scrollLeft}(上限=${manual.maxScrollLeft})` +
` / 焦点後 割合=${after.ratio} なぞり=${after.scrollLeft} 右端=${after.right}`,
);
expect(after.deleteFocused, `${width}px で削除ボタンに焦点が来ていない`).toBe(true);
expect(
after.ratio,
`${width}px で、手でなぞれるところまで引き込めていない(手なぞり最大 ${manual.ratio})`,
).toBeGreaterThanOrEqual(Math.min(1, manual.ratio) - RATIO_EPSILON);
// 縦は 1 ピクセルも動かさない。
expect(after.rowTop, `${width}px で帯が縦に動いた`).toBe(before.rowTop);
expect(after.pageScrollTop, `${width}px でページが縦に飛んだ`).toBe(before.pageScrollTop);
// はみ出していた幅では、実際に引き込んでいること(網が空振りしていない証拠)。
if (before.ratio < 1) {
expect(after.scrollLeft, `${width}px ではみ出していたのに箱が動いていない`).toBeGreaterThan(
before.scrollLeft,
);
}
}
});
test('はみ出す幅が実在する(この網が空振りしていないこと)', async ({ page }) => {
// **この網は引き込みの仕組みを判定していない。**焦点を 1 度も動かさないので、
// 引き込みを丸ごと外しても緑のまま。ここで確かめるのは上の網の前提——
// 「焦点を当てなければ実際にはみ出す幅がある」——だけ。
await createAndOpenCaseSpace(page);
const overflowed: number[] = [];
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const before = await measure(page);
if (before.ratio < 1) overflowed.push(width);
}
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(`[#1379] 焦点なしではみ出す幅: ${overflowed.join(', ') || 'なし'}`);
expect(overflowed.length, 'どの幅でもはみ出さないなら、上の網は何も守っていない').toBeGreaterThan(0);
});
test('同じ場面で、キーボードなら動きマウスなら動かない', async ({ page }) => {
await createAndOpenCaseSpace(page);
// はみ出す幅を実測で選ぶ(決め打ちにすると別の機械で空振りする)。
const width = await findOverflowWidth(page);
// --- キーボードの側。ここが無いと「いつでも動かない」と区別が付かない ---
// マウス側だけを見る網は、引き込みを丸ごと外しても緑になる(動かないのが
// 当たり前だから)。同じ幅・同じボタンで「押鍵なら動く」を対にして測る。
await resetFocusToTabStrip(page);
await resetScroll(page);
const beforeKey = await measure(page);
await tabToDeleteButton(page);
const afterKey = await measure(page);
// --- マウスの側 ---
await resetFocusToTabStrip(page);
await resetScroll(page);
const beforeMouse = await measure(page);
// **`locator.click()` を使わない。**Playwright は押す前に要素を画面内へ
// 引き込むので、こちらの処理が何もしなくても箱が動いてしまう。見えている
// ところを座標で押す(=利用者が実際にできること)。
const spot = await page.evaluate(() => {
const btn = document.querySelector('[data-testid="space-delete"]')!.getBoundingClientRect();
const box = document
.querySelector('[data-testid="space-header-meta"]')!
.getBoundingClientRect();
const right = Math.min(btn.right, box.right);
return { x: (btn.left + right) / 2, y: btn.top + btn.height / 2 };
});
await page.mouse.move(spot.x, spot.y);
await page.mouse.down();
const onPress = await measure(page);
await page.mouse.up();
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(
`[#1379] ${width}px キーボード なぞり=${beforeKey.scrollLeft}→${afterKey.scrollLeft}` +
` / マウス なぞり=${beforeMouse.scrollLeft}→${onPress.scrollLeft} 焦点=${onPress.deleteFocused}`,
);
expect(afterKey.deleteFocused, 'Tab で削除ボタンに焦点が来ていない').toBe(true);
expect(
afterKey.scrollLeft,
'キーボードでも箱が動いていない(引き込みが効いていない)',
).toBeGreaterThan(beforeKey.scrollLeft);
expect(onPress.deleteFocused, 'マウスで押しても焦点が来ていない(この網は空振り)').toBe(true);
expect(onPress.scrollLeft, 'マウスで押した瞬間に箱が動いた').toBe(beforeMouse.scrollLeft);
// 押せていること(見えている端を押しただけで確認の段に進む)。
await expect(page.getByTestId('space-delete-confirm')).toBeVisible();
});
});
+466 -236
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,5 @@
import { test, expect } from '@playwright/test';
import Database from 'better-sqlite3';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,9 +7,11 @@ import { fileURLToPath } from 'node:url';
// agent must call RequestTool). In this LLM-less E2E we seed the engine state
// directly into the throwaway DB: a job parked with wait_reason='tool_request'
// + a pending tool_request, then drive the inline approval card in the chat.
// The DB path is handed off by playwright.config.ts via ui/.e2e-db-path.
// The run-scoped DB path is inherited from playwright.config.ts via E2E_TMP.
const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim();
const e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const dbPath = resolve(e2eTmp, 'e2e.db');
test('inline tool-approval: the card renders and Approve grants + resolves the request', async ({ page, request }) => {
// 1. Create a task (spawns a job).