This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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¬ificationTab=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);
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user