Files
maestro/ui/e2e/calendar.spec.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

212 lines
9.8 KiB
TypeScript

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).
//
// Drives the real orchestrator with auth off → synthetic 'local' admin, who can
// see every space and edit events. We create a case space, add an event for
// today via the per-space calendar (reusing its tested flow), then open the
// top-bar Calendar tab and assert the cross view shows that space's dot on
// today and groups the day panel by space.
//
// Benign-path allowlist mirrors spaces.spec.ts: with auth disabled the UI's
// /api/auth/me probe 404s on purpose; org/MCP/ssh probes 401/404. All benign.
const BENIGN_PATHS = new Set([
'/api/auth/me',
'/api/users/me/orgs',
'/api/mcp/connections',
'/api/mcp/servers',
'/api/mcp/user-servers',
'/api/ssh/connections',
]);
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
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[] = [];
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname) && !BENIGN_PATH_RE.test(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
/** Viewer-local 'YYYY-MM-DD' today, matching the UI's localToday(). */
function e2eLocalToday(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
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();
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).toBeTruthy();
await caseRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
return { caseTitle, spaceId: spaceId as string };
}
/** Add an event for today in the currently-open space's calendar tab. */
async function addTodayEvent(page: Page, title: string) {
await openSpaceTab(page, 'calendar');
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
const today = e2eLocalToday();
await page.getByTestId(`space-cal-day-${today}`).click();
const panel = page.getByTestId('space-cal-day-panel');
await expect(panel).toBeVisible();
await panel.getByTestId('space-cal-add-event-btn').click();
const form = page.getByTestId('space-cal-add-event');
await expect(form).toBeVisible();
await form.getByTestId('space-cal-event-title').fill(title);
await form.getByTestId('space-cal-event-time').fill('11:00');
await form.getByTestId('space-cal-event-save').click();
await expect(panel.getByText(title, { exact: true })).toBeVisible({ timeout: 10_000 });
}
test('cross calendar: top-bar tab opens, grid renders, month nav works', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui');
await page.getByTestId('nav-calendar').click();
await expect(page.getByTestId('cross-calendar')).toBeVisible();
await expect(page.getByTestId('cross-cal-grid')).toBeVisible();
await expect(page).toHaveURL(/[?&]page=calendar(&|$)/);
// today's cell exists
await expect(page.getByTestId(`cross-cal-day-${e2eLocalToday()}`)).toBeVisible();
// month nav flips the YYYY年M月 header and returns
const title = page.getByTestId('cross-cal-title');
const initial = (await title.textContent())?.trim();
expect(initial).toMatch(/\d{4}年\d{1,2}月/);
await page.getByTestId('cross-cal-next').click();
await expect(title).not.toHaveText(initial!);
await page.getByTestId('cross-cal-prev').click();
await expect(title).toHaveText(initial!);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
test('cross calendar: a space with activity shows its dot + day panel groups by space', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
const { caseTitle, spaceId } = await createAndOpenCaseSpace(page, 'E2E横断カレンダー');
// The cross-cal dot reflects a space's activity for the day — task or
// event (see issue #804: it used to be task-only, leaving single-day
// events with no cross-calendar indicator). This space has an event and
// no task, so the dot must come from the event alone.
const eventTitle = `横断予定-${Date.now()}`;
await addTodayEvent(page, eventTitle);
// Open the top-bar cross calendar.
await page.getByTestId('nav-calendar').click();
await expect(page.getByTestId('cross-cal-grid')).toBeVisible();
const today = e2eLocalToday();
const todayCell = page.getByTestId(`cross-cal-day-${today}`);
// Today's cell carries the activity dot for our space (event-gated only).
await expect(todayCell.getByTestId(`cross-cal-dot-${spaceId}`)).toBeVisible({ timeout: 10_000 });
// Click the day → panel grouped by space, with our space heading and its
// event (a space qualifies for the panel with either task or event
// activity — see CrossSpaceCalendar.tsx's activeSpaces filter).
await todayCell.click();
const panel = page.getByTestId('cross-cal-day-panel');
await expect(panel).toBeVisible();
const spaceGroup = panel.getByTestId(`cross-cal-space-${spaceId}`);
await expect(spaceGroup).toBeVisible();
await expect(spaceGroup.getByText(caseTitle, { exact: true })).toBeVisible();
await expect(spaceGroup.getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
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([]);
});