Files
deepseek-harness/apps/web/tests/schedule-after.e2e.ts
T

1346 lines
71 KiB
TypeScript

/** Keyless assembled-Web evidence for conversational Schedule delivery. */
import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { dump } from 'js-yaml'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { bundlePatchPaths, composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
import { MessageId, ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { appendDelivery } from '../../../packages/schedule/schedule/src/delivery-history.ts'
import type { ScheduleTask } from '@deepseek-ai/dsh-schedule'
import type { ContextFormed, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import {
ScheduleId,
createAfterScheduleRecord,
createEveryScheduleRecord,
foldScheduleEvents,
type EveryScheduleRecord,
} from '@deepseek-ai/dsh-schedule'
import {
assertFixtureInventory,
captureStableAria,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import {
REPO_ROOT,
connectFreshWorkspace,
conversationContextKey,
saveFailureShot,
} from './support.ts'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'schedule-web-e2e': { kind: 'schedule-web-e2e' } & ContextFormed
}
}
const MODE = webSnapshotMode()
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/schedule-after', import.meta.url))
const AFTER_EXPECTED = join(SNAPSHOT_DIR, 'conversation.expected.md')
const AT_EXPECTED = join(SNAPSHOT_DIR, 'at-conversation.expected.md')
const EVERY_EXPECTED = join(SNAPSHOT_DIR, 'every-conversation.expected.md')
const AFTER_PROVIDER = 'schedule-after-web-test'
const AT_PROVIDER = 'schedule-at-web-test'
const EVERY_PROVIDER = 'schedule-every-web-test'
const MODEL = 'reply'
const AFTER_PROMPT = 'Check the deployment log'
const AFTER_REPLY = 'Reminder: Check the deployment log.'
const AT_BROWSER_ZONE = 'Asia/Shanghai'
const AT_USER_PROMPT = 'Remind me to review the release window in a few seconds in my local time.'
const AT_PROMPT = 'Review the release window'
const AT_READY = 'Ready for a browser-local reminder request.'
const AT_ACK = 'Scheduled in your browser time zone.'
const AT_REPLY = 'Reminder: Review the release window.'
const EVERY_PROMPTS = ['Check primary metrics', 'Check secondary metrics'] as const
const EVERY_REPLY = 'Reminders: Check primary metrics; Check secondary metrics.'
const EVERY_INTERVAL_SECONDS = 60 * 60
const EVERY_FIXTURE_AGE_MS = 90 * 60 * 1_000
const CATALOG_SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/schedule-catalog', import.meta.url))
const CATALOG_FIXTURE = join(CATALOG_SNAPSHOT_DIR, 'session.v3.jsonl')
const CATALOG_EXPECTED = join(CATALOG_SNAPSHOT_DIR, 'catalog.expected.md')
const BASE_PATCH = fileURLToPath(new URL('../../../packages/bundle/base/cordis.patch.yml', import.meta.url))
const WEB_BUNDLE = fileURLToPath(new URL('../../../packages/bundle/web-app/', import.meta.url))
const WEB_PATCHES = bundlePatchPaths(WEB_BUNDLE, (JSON.parse(readFileSync(join(WEB_BUNDLE, 'package.json'), 'utf8')) as { dsh: { bundle: { patch: string[] } } }).dsh.bundle)
/**
* Write the overlay that makes every step carry a fresh clock reading.
*
* The shipped Web composition carries no `time-context` row and the `standard`
* preset owns the clock, so an overlay naming a Host row reaches nothing. The
* overlay therefore restates the shipped `preset-standard` row with its
* `time-context` plugin configured to re-read on every step.
* @param dir - Directory that receives the overlay file.
* @returns Absolute path of the written overlay.
*/
async function writeEveryStepOverlay(dir: string): Promise<string> {
const layers = [
loadOverlayPatches('Schedule Web every-step overlay', BASE_PATCH),
...WEB_PATCHES.map(file => loadOverlayPatches('Schedule Web every-step overlay', file)),
]
const row = composeEntries(layers).find(entry => entry.id === 'preset-standard')
if (row === undefined) throw new Error('the shipped Web surface declares no preset-standard row')
const config = row.config as { plugins: Array<{ id?: string; config?: unknown }> }
const plugins = config.plugins.map(plugin => plugin.id === 'time-context'
? { ...plugin, config: { refreshIntervalMs: 0 } }
: plugin)
const path = join(dir, 'time-context-every-step.patch.yml')
await writeFile(path, dump([
{ id: 'preset-standard', config: { ...config, plugins } },
], { schema: entryListSchema, noRefs: true, lineWidth: -1 }))
return path
}
const CATALOG_NOW = Date.parse('2099-08-25T12:00:00.000Z')
const CATALOG_SESSION_ID = SessionId('schedule-catalog-web-e2e')
const CATALOG_TITLE = 'Active schedule catalog'
/**
* Task name seeded for each recorded catalog task.
*
* The recorded `schedule/change` payloads predate titles, so the Host task rows
* this lane asserts are seeded from these names rather than from the fixture.
*/
const CATALOG_TITLES: Readonly<Record<string, string>> = {
'catalog-after': 'Review overdue deployment',
'catalog-at': 'Join release review',
'catalog-every': 'Check exact cadence',
}
/** Emit one complete assistant text response. */
function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
/**
* Wait until the Session header carries no reminder entry. The entry stays
* mounted while a read is loading or failed, so its absence proves a successful
* read that found no active reminder for this Session.
*/
async function expectNoReminderEntry(page: Page): Promise<void> {
await expect.poll(() => page.locator('[data-schedule-reminder-entry]').count(), { timeout: 15_000 }).toBe(0)
}
/** Deterministic model seam that turns one due reminder into ordinary assistant prose. */
class ReminderAdapter extends LlmAdapter {
override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] }
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield * textResponse(AFTER_REPLY)
}
}
/** Deterministic model seam for one multi-record fixed-rate batch. */
class EveryReminderAdapter extends LlmAdapter {
override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] }
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield * textResponse(EVERY_REPLY)
}
}
interface LocalAt {
readonly date: string
readonly time: string
readonly time_zone: string
}
/** Render one future epoch as exact local calendar fields in an explicit zone. */
function localAt(epoch: number, timeZone: string): LocalAt {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(epoch).map(part => [part.type, part.value])) as Record<string, string>
return {
date: `${parts['year']}-${parts['month']}-${parts['day']}`,
time: `${parts['hour']}:${parts['minute']}:${parts['second']}`,
time_zone: timeZone,
}
}
/** Dynamic model seam proving request-local browser context becomes an explicit At selector. */
class BrowserZoneAtAdapter extends LlmAdapter {
override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] }
readonly requests: GenerateOptions[] = []
selectedAt: LocalAt | undefined
scheduledAt: string | undefined
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
if (this.requests.length === 1) {
yield * textResponse(AT_READY)
return
}
if (this.requests.length === 2) {
const target = Math.ceil((Date.now() + 5_000) / 1_000) * 1_000
this.selectedAt = localAt(target, AT_BROWSER_ZONE)
this.scheduledAt = new Date(target).toISOString()
const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, title: AT_PROMPT, at: this.selectedAt })
const callId = ToolCallId('schedule-at-browser-zone')
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
yield {
type: 'tool-call-delta',
index: 0,
id: callId,
name: 'schedule_create',
argumentsDelta: argumentsJson,
}
yield {
type: 'block-end',
index: 0,
block: {
type: 'tool-call',
id: callId,
name: 'schedule_create',
arguments: argumentsJson,
},
}
yield { type: 'finish', reason: { kind: 'tool-calls' } }
return
}
yield * textResponse(this.requests.length === 3 ? AT_ACK : AT_REPLY)
}
}
/** Extract text from one durable assistant message. */
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
return event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
/** Extract all model-visible text from one assembled request. */
function requestText(options: GenerateOptions): string {
return options.messages
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
.join('\n')
}
/** Require one assembled request to frame the reminder as a scheduled user message. */
function expectReminderFraming(options: GenerateOptions): void {
const reminder = options.messages.find(message => (
message.role === 'user' && message.source?.kind === 'schedule'
))
expect(reminder?.role).toBe('user')
const text = reminder?.content.find(block => block.type === 'text')?.text
expect(text).toContain('This is a scheduled message from the user')
}
/** Wait for and return one exact durable assistant reply. */
async function waitForReply(
handle: AgentHandle,
text: string,
timeoutMs: number,
): Promise<SessionEvent<'assistant/message'>> {
const deadline = Date.now() + timeoutMs
while (true) {
const event = handle.agent.session.snapshotEvents().find((candidate): candidate is SessionEvent<'assistant/message'> => (
candidate.type === 'assistant/message' && assistantText(candidate) === text
))
if (event !== undefined) return event
if (Date.now() >= deadline) throw new Error(`assistant reply did not arrive within ${timeoutMs}ms: ${text}`)
await new Promise<void>(resolve => setTimeout(resolve, 20))
}
}
/** Resolve the semantic assistant-step key owned by the conversation assembler. */
function assistantKey(event: SessionEvent<'assistant/message'>): string {
return conversationContextKey('assistant-step', `${String(event.data.turn)}:${String(event.data.step)}`)
}
/** Wait until opening a persisted Session publishes its live Agent. */
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
const deadline = Date.now() + 30_000
for (;;) {
const found = scaffold.ctx.agents.get(sessionId)
if (found !== undefined) return found
if (Date.now() >= deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
await new Promise<void>(resolve => setTimeout(resolve, 100))
}
}
describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
let scaffold: WebScaffold
let afterHandle: AgentHandle
let atHandle: AgentHandle
let everyHandle: AgentHandle
let browser: Browser
let page: Page
let afterAssistantReply: SessionEvent<'assistant/message'> | undefined
let atAssistantReply: SessionEvent<'assistant/message'> | undefined
let everyAssistantReply: SessionEvent<'assistant/message'> | undefined
let everyRecords: readonly [EveryScheduleRecord, EveryScheduleRecord]
let tripwire: ReturnType<typeof watchConsole>
let overlayRoot: string | undefined
const afterAdapter = new ReminderAdapter()
const atAdapter = new BrowserZoneAtAdapter()
const everyAdapter = new EveryReminderAdapter()
beforeAll(async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-schedule-every-step-'))
overlayRoot = root
scaffold = await launchWebScaffold({
extraOverlayPath: [await writeEveryStepOverlay(root)],
})
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([AFTER_PROVIDER], afterAdapter),
'Schedule Web After adapter',
)
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([AT_PROVIDER], atAdapter),
'Schedule Web At adapter',
)
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([EVERY_PROVIDER], everyAdapter),
'Schedule Web Every adapter',
)
browser = await chromium.launch()
page = await browser.newPage({
viewport: { width: 1680, height: 1000 },
locale: 'en-US',
timezoneId: AT_BROWSER_ZONE,
})
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
expect(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone))
.toBe(AT_BROWSER_ZONE)
const cwd = join(scaffold.workspaceCwd, 'workspace')
const workspace = await scaffold.ctx.workspaceRegistry.resolveByPath(cwd)
if (workspace === undefined) throw new Error('connected Web workspace was not registered')
afterHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-after-web-e2e'),
meta: { cwd },
agentOptions: { provider: AFTER_PROVIDER, model: MODEL },
// The reminder tools are preset-level Consumers, so a directly created Agent
// needs the default preset the product mounts.
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
afterHandle.agent.session.append('session/title', {
title: 'Scheduled After follow-up',
messageSeqs: [],
source: { kind: 'user' },
})
await workspace.attachSession(afterHandle.agent.id)
const afterCreated = await executeToolFixture(scaffold.ctx, {
signal: AbortSignal.timeout(10_000),
callId: ToolCallId('schedule-after-create'),
name: 'schedule_create',
arguments: { prompt: AFTER_PROMPT, title: AFTER_PROMPT, after_seconds: 1 },
agent: afterHandle.agent,
})
if (afterCreated.isError) {
throw new Error(`Schedule After create failed: ${JSON.stringify(afterCreated.value)}`)
}
const createdView: unknown = afterCreated.value
if (typeof createdView !== 'object' || createdView === null || !('id' in createdView) || typeof createdView.id !== 'string') {
throw new Error(`Schedule After create returned no task id: ${JSON.stringify(createdView)}`)
}
expect(createdView.id).toMatch(/^schedule-/)
expect(createdView).toMatchObject({
kind: 'after',
prompt: AFTER_PROMPT,
afterSeconds: 1,
state: 'scheduled',
deliveryMode: 'host',
})
afterAssistantReply = await waitForReply(afterHandle, AFTER_REPLY, 15_000)
await afterHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(afterHandle.agent.session)).resolves.toBe(true)
everyHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-every-web-e2e'),
meta: { cwd },
agentOptions: { provider: EVERY_PROVIDER, model: MODEL },
// The reminder tools are preset-level Consumers, so a directly created Agent
// needs the default preset the product mounts.
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
everyHandle.agent.session.append('session/title', {
title: 'Fixed-rate reminder batch',
messageSeqs: [],
source: { kind: 'user' },
})
const seededAt = Date.now()
everyRecords = [
createEveryScheduleRecord(
ScheduleId('schedule-every-primary'),
EVERY_PROMPTS[0],
EVERY_INTERVAL_SECONDS,
seededAt - EVERY_FIXTURE_AGE_MS,
'Every primary',
),
createEveryScheduleRecord(
ScheduleId('schedule-every-secondary'),
EVERY_PROMPTS[1],
EVERY_INTERVAL_SECONDS,
seededAt - EVERY_FIXTURE_AGE_MS,
'Every secondary',
),
]
const domain = scaffold.ctx.storageDomain.get('schedule')
if (domain === undefined) throw new Error('Schedule domain was not opened')
for (const record of everyRecords) {
await domain.table('tasks').put(record.id, { sessionId: everyHandle.agent.id, record, status: 'active' })
}
const wake = await scaffold.ctx.schedule.create(everyHandle.agent.id, {
prompt: 'Unused future scheduling wake', title: 'Future wake', after_seconds: 86_400,
})
await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id })
await workspace.attachSession(everyHandle.agent.id)
const everyListed = await executeToolFixture(scaffold.ctx, {
signal: AbortSignal.timeout(10_000),
callId: ToolCallId('schedule-every-list'),
name: 'schedule_list',
arguments: {},
agent: everyHandle.agent,
})
expect(everyListed.isError).toBe(false)
everyAssistantReply = await waitForReply(everyHandle, EVERY_REPLY, 15_000)
await everyHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(everyHandle.agent.session)).resolves.toBe(true)
atHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('schedule-at-web-e2e'),
meta: { cwd },
agentOptions: { provider: AT_PROVIDER, model: MODEL },
// The reminder tools are preset-level Consumers, so a directly created Agent
// needs the default preset the product mounts.
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
atHandle.agent.session.append('session/title', {
title: 'Explicit local-time reminder',
messageSeqs: [],
source: { kind: 'user' },
})
atHandle.agent.followup(createUserMessage({
content: [{ type: 'text', text: 'Prepare the reminder test session.' }],
source: { kind: 'schedule-web-e2e' },
}))
await atHandle.agent.whenIdle()
expect(atAdapter.requests).toHaveLength(1)
await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true)
await workspace.attachSession(atHandle.agent.id)
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const workspaceItem = page.locator('[role="treeitem"]').first()
await workspaceItem.waitFor({ timeout: 15_000 })
const expansionDeadline = Date.now() + 5_000
while (await workspaceItem.getAttribute('aria-expanded') !== 'true') {
if (Date.now() >= expansionDeadline) throw new Error('workspace item did not expand')
if (await workspaceItem.getAttribute('aria-expanded') !== 'true') {
await workspaceItem.click()
}
await new Promise<void>(resolve => setTimeout(resolve, 50))
}
const atSession = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
await atSession.waitFor({ timeout: 15_000 })
await atSession.click()
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
await composer.fill(AT_USER_PROMPT)
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
expect(await settled).toBe(atHandle.agent.id)
await page.getByText(AT_ACK, { exact: true }).waitFor({ timeout: 15_000 })
atAssistantReply = await waitForReply(atHandle, AT_REPLY, 20_000)
await atHandle.agent.whenIdle()
await expect(scaffold.ctx.sessions.flush(atHandle.agent.session)).resolves.toBe(true)
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await atHandle?.dispose().catch((error: unknown) => failures.push(error))
await everyHandle?.dispose().catch((error: unknown) => failures.push(error))
await afterHandle?.dispose().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (overlayRoot !== undefined) {
await rm(overlayRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'Schedule Web evidence teardown failed')
})
it('renders After as an ordinary assistant follow-up', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-after'))
const reminderRequest = afterAdapter.requests[0]
if (reminderRequest === undefined) throw new Error('model did not receive the After reminder')
expectReminderFraming(reminderRequest)
const session = page.getByRole('treeitem', { name: /Scheduled After follow-up/ })
await session.click()
if (afterAssistantReply === undefined) throw new Error('After assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(afterAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(AFTER_REPLY)
const triggerTitle = '[data-turn-trigger] > button > span:nth-child(2)'
await page.locator(triggerTitle).waitFor()
expect(await page.locator(triggerTitle).textContent()).toBe('Automation task')
await compareOrRefreshGolden(
AFTER_EXPECTED,
`${await captureStableAria(page, triggerTitle, scaffold.workspaceCwd)}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`,
MODE,
)
await expectNoReminderEntry(page)
await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).some(task => (
task.sessionId === afterHandle.agent.id && task.status === 'inactive' && task.lastDelivery !== undefined
))).toBe(true)
const delivered = (await scaffold.ctx.schedule.catalog()).find(task => task.sessionId === afterHandle.agent.id)
if (delivered?.lastDelivery === undefined) throw new Error('delivered one-shot receipt was not retained')
expect(await scaffold.ctx.schedule.list({ sessionId: afterHandle.agent.id })).toEqual([])
// Archiving the Session shown in the main pane returns the app to its
// workspace-selection view and unmounts the Tasks page with it, so the
// linked-Session assertions run with another Session in the main pane.
await page.getByRole('treeitem', { name: /Explicit local-time reminder/ }).click()
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('button', { name: 'Inactive', exact: true }).click()
await manager.getByRole('list', { name: 'Task catalog' })
.getByRole('button', { name: AFTER_PROMPT, exact: true }).click()
const detail = manager.getByRole('complementary', { name: 'Task details' })
expect(await detail.getByRole('tab', { name: 'Rules', exact: true }).getAttribute('aria-selected')).toBe('true')
expect(await detail.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0)
await detail.getByRole('tab', { name: 'Records', exact: true }).click()
await detail.getByRole('tabpanel', { name: 'Records', exact: true }).waitFor()
const receipt = detail.getByRole('region', { name: 'Saved delivery record' })
await receipt.waitFor()
expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0)
// The row identifies its occurrence by the scheduled instant it renders.
expect(await receipt.locator(`time[datetime="${delivered.lastDelivery.scheduledAt}"]`).count()).toBe(1)
expect(await receipt.textContent()).toContain(AFTER_PROMPT)
expect(await detail.getByText('Next scheduled time', { exact: true }).count()).toBe(0)
// The linked-session row renders on the Rules view only.
await detail.getByRole('tab', { name: 'Rules', exact: true }).click()
const linkedSession = detail.getByRole('button', { name: /^Linked session/ })
await scaffold.ctx.workspaceRegistry.archiveSession(afterHandle.agent.id)
try {
await expect.poll(() => linkedSession.isDisabled(), { timeout: 15_000 }).toBe(true)
expect(await detail.getByText('The original session is archived.', { exact: true }).count()).toBe(1)
expect(await detail.getByText('Scheduled After follow-up', { exact: true }).count()).toBe(1)
const moreActions = detail.getByRole('button', { name: 'Automation task actions', exact: true })
await moreActions.click()
const deleteAction = page.getByRole('menuitem', { name: 'Delete task', exact: true })
await deleteAction.waitFor({ timeout: 15_000 })
expect(await deleteAction.isDisabled()).toBe(false)
await page.keyboard.press('Escape')
// Dismissing the menu leaves the detail it was opened from: the Escape
// must not reach the page's own handler and close the whole panel.
await expect.poll(() => page.getByRole('menu').count(), { timeout: 5_000 }).toBe(0)
await detail.waitFor({ timeout: 5_000 })
expect((await scaffold.ctx.schedule.catalog()).find(task => task.id === delivered.id)).toEqual(delivered)
} finally {
await scaffold.ctx.workspaceRegistry.unarchiveSession(afterHandle.agent.id)
}
await linkedSession.waitFor({ timeout: 15_000 })
await expect.poll(() => linkedSession.isDisabled(), { timeout: 15_000 }).toBe(false)
await linkedSession.click()
await expectNoReminderEntry(page)
}, 60_000)
it('batches one latest occurrence per overdue Every record and advances both schedules', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every'))
const messages = everyHandle.agent.session.snapshotEvents().filter(event => (
event.type === 'user/message'
&& event.data.source.kind === 'schedule'
))
expect(messages).toHaveLength(1)
const texts = messages.map((event) => {
if (event.type !== 'user/message') throw new Error('expected reminder message')
return event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
})
for (const record of everyRecords) {
expect(texts.filter(text => text.includes(record.id))).toHaveLength(1)
expect(texts.some(text => text.includes(record.prompt))).toBe(true)
}
expect(everyAdapter.requests).toHaveLength(1)
for (const request of everyAdapter.requests) expectReminderFraming(request)
const active = await scaffold.ctx.schedule.list({ sessionId: everyHandle.agent.id })
expect(active).toHaveLength(2)
for (const record of active) {
const original = everyRecords.find(candidate => candidate.id === record.id)
if (original === undefined) throw new Error('unexpected recurring record')
expect(Date.parse(record.scheduledAt) - Date.parse(original.scheduledAt))
.toBe(EVERY_INTERVAL_SECONDS * 1_000)
}
const session = page.getByRole('treeitem', { name: /Fixed-rate reminder batch/ })
await session.click()
if (everyAssistantReply === undefined) throw new Error('Every assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(everyAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(EVERY_REPLY)
await compareOrRefreshGolden(
EVERY_EXPECTED,
await captureStableAria(page, selector, scaffold.workspaceCwd),
MODE,
)
await page.getByRole('button', { name: '2 reminders', exact: true }).waitFor({ timeout: 15_000 })
expect(await page.getByRole('list', { name: 'Active reminders' }).count()).toBe(0)
const original = everyRecords[0]
const first = await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })
if ('code' in first || first.records.length !== 1) throw new Error('expected one actual recurring receipt')
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: original.title, exact: true }).click()
const detail = manager.getByRole('complementary', { name: 'Task details' })
await detail.getByRole('tab', { name: 'Records', exact: true }).click()
const receipts = detail.getByRole('region', { name: 'Saved delivery record' })
const recordsPanel = detail.getByRole('tabpanel', { name: 'Records', exact: true })
await expect.poll(() => receipts.count()).toBe(1)
expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0)
const domain = scaffold.ctx.storageDomain.get('schedule')
if (domain === undefined) throw new Error('Schedule domain was not opened')
const latest = first.records[0]!
// Make the next occurrence due through the fixture's existing stored-time input.
await domain.table('tasks').put(original.id, {
sessionId: everyHandle.agent.id,
record: { ...original, scheduledAt: new Date(Date.now() - 1_000).toISOString() },
status: 'active',
lastDelivery: { scheduledAt: latest.scheduledAt, deliveredAt: latest.deliveredAt, messageId: latest.messageId },
deliveryHistory: { records: [...first.records].reverse(), earlierRecordsUnavailable: first.earlierRecordsUnavailable },
})
const wake = await scaffold.ctx.schedule.create(everyHandle.agent.id, { prompt: 'Future history refresh wake', title: 'History refresh wake', after_seconds: 86_400 })
await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id })
await expect.poll(() => receipts.count(), { timeout: 15_000 }).toBe(2)
const saved = await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })
if ('code' in saved) throw new Error('recurring delivery history was unavailable')
expect(saved.records).toHaveLength(2)
expect(saved.records[1]).toEqual(latest)
expect(saved.records[0]?.messageId).not.toBe(latest.messageId)
for (const record of saved.records) {
// Each saved occurrence renders its own scheduled instant.
expect(await recordsPanel.locator(`time[datetime="${record.scheduledAt}"]`).count()).toBe(1)
}
expect(await detail.getByRole('tab', { name: 'Records', exact: true }).getAttribute('aria-selected')).toBe('true')
await detail.getByRole('tab', { name: 'Rules', exact: true }).click()
// The interval row states its quantity in the friendly unit the stored
// seconds select: 3600 seconds is one whole hour, so the row shows 1 and
// doubling the interval is two of those hours.
const interval = detail.getByLabel('Repeat every', { exact: true })
expect(await interval.inputValue()).toBe('1')
await interval.fill('2')
// Run-time rows edit a local draft; the save bar is the only commit path.
await detail.getByText('Unsaved changes', { exact: true }).waitFor({ timeout: 15_000 })
await detail.getByRole('button', { name: 'Save changes', exact: true }).click()
await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).find(task => task.id === original.id), { timeout: 15_000 })
.toMatchObject({
id: original.id, sessionId: everyHandle.agent.id, prompt: original.prompt,
kind: 'every', everySeconds: EVERY_INTERVAL_SECONDS * 2, status: 'active',
})
expect(await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })).toEqual(saved)
await detail.getByRole('tab', { name: 'Records', exact: true }).click()
await expect.poll(() => receipts.count()).toBe(2)
// The linked-session row renders on the Rules view only.
await detail.getByRole('tab', { name: 'Rules', exact: true }).click()
await detail.getByRole('button', { name: 'Linked session Fixed-rate reminder batch: open original session', exact: true }).click()
await page.getByRole('button', { name: '2 reminders', exact: true }).waitFor({ timeout: 15_000 })
}, 60_000)
it('uses request-local browser context to create an explicit local At reminder', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at'))
const user = atHandle.agent.session.snapshotEvents().find(event => (
event.type === 'user/message'
&& event.data.source.kind === 'user'
&& event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT)
))
if (user?.type !== 'user/message' || user.data.source.kind !== 'user') {
throw new Error('missing browser user-rpc message')
}
expect(user.data.source).toMatchObject({ kind: 'user', clientTimeZone: AT_BROWSER_ZONE })
expect(typeof (user.data.source as { rpcId?: unknown }).rpcId).toBe('string')
const firstRequest = atAdapter.requests[1]
if (firstRequest === undefined) throw new Error('model did not receive the browser prompt')
expect(requestText(firstRequest)).toContain(
`Browser time zone for this request: ${AT_BROWSER_ZONE}. `
+ 'Interpret otherwise-unqualified dates and times in this zone.',
)
expect(firstRequest.tools?.some(tool => tool.name === 'schedule_create')).toBe(true)
const selectedAt = atAdapter.selectedAt
const scheduledAt = atAdapter.scheduledAt
if (selectedAt === undefined || scheduledAt === undefined) {
throw new Error('model did not choose an explicit local At target')
}
expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE)
const toolCall = atHandle.agent.session.snapshotEvents().find(event => (
event.type === 'tool/call' && event.data.name === 'schedule_create'
))
if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call')
expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, title: AT_PROMPT, at: selectedAt })
const result = atHandle.agent.session.snapshotEvents().find(event => (
event.type === 'tool/result' && event.data.message.toolCallId === toolCall.data.callId
))
if (result?.type !== 'tool/result') throw new Error('missing schedule_create result')
expect(result.data.message.isError).not.toBe(true)
const resultText = result.data.message.content.find(block => block.type === 'text')
if (resultText?.type !== 'text') throw new Error('missing schedule_create response fields')
expect(JSON.parse(resultText.text)).toMatchObject({ kind: 'at', prompt: AT_PROMPT, scheduledAt })
const reminders = atHandle.agent.session.snapshotEvents().filter(event => (
event.type === 'user/message'
&& event.data.source.kind === 'schedule'
))
expect(reminders).toHaveLength(1)
expect(await scaffold.ctx.schedule.list({ sessionId: atHandle.agent.id })).toEqual([])
expect(atAdapter.requests).toHaveLength(4)
const reminderRequest = atAdapter.requests[3]
if (reminderRequest === undefined) throw new Error('model did not receive the At reminder')
expectReminderFraming(reminderRequest)
const session = page.getByRole('treeitem', { name: /Explicit local-time reminder/ })
await session.click()
if (atAssistantReply === undefined) throw new Error('At assistant reply was not captured')
const selector = `[data-chat-anchor-key="${assistantKey(atAssistantReply)}"]`
const row = page.locator(selector)
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(AT_REPLY)
await compareOrRefreshGolden(
AT_EXPECTED,
await captureStableAria(page, selector, scaffold.workspaceCwd),
MODE,
)
await expectNoReminderEntry(page)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'at-conversation.expected.md',
'conversation.expected.md',
'every-conversation.expected.md',
])
})
})
describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let overlayRoot: string | undefined
beforeAll(async () => {
const fixture = await readFile(CATALOG_FIXTURE, 'utf8')
const root = await mkdtemp(join(tmpdir(), 'dsh-schedule-catalog-'))
overlayRoot = root
scaffold = await launchWebScaffold({
extraOverlayPath: [await writeEveryStepOverlay(root)],
})
await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard')
const records = foldScheduleEvents(fixture.trim().split('\n').slice(1).map(line => JSON.parse(line) as SessionEvent)).active
const domain = scaffold.ctx.storageDomain.get('schedule')
if (domain === undefined) throw new Error('Schedule domain was not opened')
// The fixture holds the bytes its writer produced before titles existed, so
// each recorded task is seeded under the name this catalog lane asserts.
const seeded = records.map(record => ({ ...record, title: CATALOG_TITLES[record.id] }))
for (const record of seeded) {
await domain.table('tasks').put(record.id, { sessionId: CATALOG_SESSION_ID, record, status: 'active' })
}
expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual(seeded)
expect(scaffold.ctx.agents.get(CATALOG_SESSION_ID)).toBeUndefined()
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
await workspace.attachSession(CATALOG_SESSION_ID)
browser = await chromium.launch()
page = await browser.newPage({
viewport: { width: 900, height: 900 },
locale: 'en-US',
timezoneId: AT_BROWSER_ZONE,
})
await page.clock.setFixedTime(new Date(CATALOG_NOW))
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
const openSidebar = page.getByRole('button', { name: 'Open sidebar' })
if (await openSidebar.isVisible()) {
await openSidebar.click()
await page.getByRole('button', { name: 'Collapse sidebar' }).waitFor({ timeout: 10_000 })
}
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (overlayRoot !== undefined) {
await rm(overlayRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'Schedule catalog teardown failed')
})
it('manages cross-Session tasks from the sidebar without activating their Sessions', async () => {
const otherSessionId = SessionId('schedule-manager-other-session')
await seedSession(scaffold, await readFile(CATALOG_FIXTURE, 'utf8'), otherSessionId, 'standard')
const other = await scaffold.ctx.schedule.create(otherSessionId, {
prompt: 'Inspect the second workspace deployment', title: 'Second workspace deployment', at: '2099-08-25T12:08:00.000Z',
})
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
const tasks = manager.getByRole('list', { name: 'Task catalog' })
await expect.poll(() => tasks.getByRole('listitem').count()).toBe(4)
expect(scaffold.ctx.agents.get(CATALOG_SESSION_ID)).toBeUndefined()
expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined()
// The filter row carries the status group alone, whose first chip is the
// unrestricted state; the repeating task is reached by name instead of a
// retired type chip.
const statuses = manager.getByRole('group', { name: 'Task status' })
expect(await statuses.getByRole('button').count()).toBe(3)
await manager.getByRole('button', { name: 'All', exact: true }).click()
await expect.poll(() => tasks.getByRole('listitem').count()).toBe(4)
expect(await tasks.textContent()).toContain('Check exact cadence')
await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('second workspace')
await expect.poll(() => tasks.getByRole('listitem').count()).toBe(1)
await tasks.getByRole('button', { name: other.title, exact: true }).click()
const details = manager.getByRole('complementary', { name: 'Task details' })
expect(await details.textContent()).toContain(otherSessionId)
expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined()
await details.getByRole('tab', { name: 'Records', exact: true }).click()
await details.getByText('No delivery record available', { exact: true }).waitFor()
expect(await details.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0)
expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined()
await details.getByRole('tab', { name: 'Rules', exact: true }).click()
await page.setViewportSize({ width: 600, height: 340 })
// The linked Session is a seat in the detail's tab strip, above the rule's
// scroll region, so it stays fully visible however far that region scrolls.
const contextRow = details.locator('[class*="detailContext"]')
const linkedSession = contextRow.getByRole('button', { name: 'Linked session: open original session', exact: true })
expect(await contextRow.getByRole('button').count()).toBe(1)
expect(await linkedSession.textContent()).toContain(otherSessionId)
expect(await contextRow.evaluate(element => element.closest('[class*="detailScroll"]') === null)).toBe(true)
await details.locator('[class*="detailScroll"]').evaluate((element) => { element.scrollTop = element.scrollHeight })
const linkedBounds = await linkedSession.boundingBox()
expect(linkedBounds).not.toBeNull()
expect(linkedBounds!.y).toBeGreaterThanOrEqual(0)
expect(linkedBounds!.y + linkedBounds!.height).toBeLessThanOrEqual(340)
expect(await details.getByRole('button', { name: 'Automation task actions', exact: true }).isVisible()).toBe(true)
await page.setViewportSize({ width: 900, height: 900 })
const moreActions = details.getByRole('button', { name: 'Automation task actions', exact: true })
await moreActions.click()
await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click()
const confirmation = page.getByRole('dialog', { name: 'Delete this task?' })
await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click()
expect((await scaffold.ctx.schedule.catalog()).some(task => task.id === other.id)).toBe(true)
await moreActions.click()
await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click()
await confirmation.getByRole('button', { name: 'Confirm deletion', exact: true }).click()
await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).some(task => task.id === other.id))
.toBe(false)
// A confirmed deletion closes the surface that asked for it and reports the
// outcome through the app-wide toast, so the notice outlives the panel that
// raised it. The Host removed the row with its saved records, so neither the
// rule view nor the records view can read the task again.
await expect.poll(() => details.count()).toBe(0)
await page.getByText('Task deleted.', { exact: true }).waitFor({ timeout: 15_000 })
expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined()
await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('Check exact cadence')
await tasks.getByRole('button', { name: 'Check exact cadence', exact: true }).click()
// This Session's catalog row carries no title before the Session is opened,
// so its link shows the Session id and is named without one.
await manager.getByRole('button', { name: 'Linked session: open original session', exact: true }).click()
await liveAgent(scaffold, CATALOG_SESSION_ID)
await page.getByRole('button', { name: '3 reminders', exact: true }).waitFor()
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('reads stored tasks without a live Session and deletes them through the catalog', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog'))
const layers = [
loadOverlayPatches('Schedule catalog shipped roster', BASE_PATCH),
...WEB_PATCHES.map(file => loadOverlayPatches('Schedule catalog shipped roster', file)),
]
const shipped = composeEntries(layers)
// The delivered composition carries the Schedule service and its task page;
// the clock stays preset-level, so no Host row declares it.
for (const row of [
{ id: 'schedule', name: '@deepseek-ai/dsh-schedule' },
{ id: 'ui-schedule', name: '@deepseek-ai/dsh-client-ui-schedule' },
]) {
expect(shipped.filter(entry => entry.id === row.id && entry.name === row.name && entry.disabled !== true))
.toHaveLength(1)
}
expect(shipped.some(entry => entry.id === 'time-context')).toBe(false)
// Each preset that offers reminders declares the clock and the tool package
// in its own scope and ships them enabled; `minimal` declares neither.
const presetPlugins = (id: string): Array<{ id?: string; name?: string; disabled?: boolean }> => {
const row = shipped.find(entry => entry.id === id)
if (row === undefined) throw new Error(`missing shipped preset row ${id}`)
return (row.config as { plugins: Array<{ id?: string; name?: string; disabled?: boolean }> }).plugins
}
for (const id of ['preset-standard', 'preset-ptc', 'preset-cordis']) {
const plugins = presetPlugins(id)
expect(plugins.filter(row => row.id === 'time-context'
&& row.name === '@deepseek-ai/dsh-time-context')).toHaveLength(1)
expect(plugins.filter(row => row.id === 'tool-schedule'
&& row.name === '@deepseek-ai/dsh-tool-schedule')).toHaveLength(1)
for (const name of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-tool-schedule']) {
const matches = plugins.filter(row => row.name === name)
expect(matches).toHaveLength(1)
expect(matches[0]?.disabled).not.toBe(true)
}
}
expect(presetPlugins('preset-minimal').some(row => row.name === '@deepseek-ai/dsh-time-context'
|| row.name === '@deepseek-ai/dsh-tool-schedule')).toBe(false)
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('button', { name: 'Check exact cadence', exact: true }).click()
await manager.getByRole('button', { name: `Linked session ${CATALOG_TITLE}: open original session`, exact: true }).click()
await liveAgent(scaffold, CATALOG_SESSION_ID)
await page.getByRole('navigation', { name: 'Session hierarchy' })
.getByText(CATALOG_TITLE, { exact: true }).waitFor({ timeout: 15_000 })
const trigger = page.getByRole('button', { name: '3 reminders' })
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
const catalog = page.getByRole('list', { name: 'Active reminders' })
await catalog.waitFor({ timeout: 10_000 })
expect(await catalog.getByRole('listitem').count()).toBe(3)
const lightLayout = await page.evaluate(() => {
const triggerElement = document.querySelector('button[aria-label="3 reminders"]')
const catalogElement = document.querySelector('[aria-label="Active reminders"]')
if (!(triggerElement instanceof HTMLElement) || !(catalogElement instanceof HTMLElement)) {
throw new Error('active reminder trigger or catalog is not mounted')
}
const triggerBox = triggerElement.getBoundingClientRect()
const catalogBox = catalogElement.getBoundingClientRect()
const viewport = window.innerWidth
return {
bodyPortal: catalogElement.parentElement === document.body,
position: getComputedStyle(catalogElement).position,
triggerLeft: triggerBox.left,
catalogLeft: catalogBox.left,
catalogRight: catalogBox.right,
width: catalogBox.width,
viewport,
expectedLeft: Math.min(Math.max(16, triggerBox.left), viewport - catalogBox.width - 16),
scrollWidth: document.documentElement.scrollWidth,
background: getComputedStyle(catalogElement).backgroundColor,
}
})
expect(lightLayout.bodyPortal).toBe(true)
expect(lightLayout.position).toBe('fixed')
expect(lightLayout.width).toBe(336)
expect(lightLayout.catalogLeft).toBe(lightLayout.expectedLeft)
expect(lightLayout.catalogLeft).toBeLessThan(lightLayout.triggerLeft)
expect(lightLayout.catalogRight).toBeLessThanOrEqual(lightLayout.viewport - 16)
expect(lightLayout.scrollWidth).toBeLessThanOrEqual(lightLayout.viewport)
expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)')
const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' })
const cadenceRow = catalog.getByRole('listitem').filter({ hasText: 'Check exact cadence' })
// The row's opener is a block box, so its own width is the one a stored name
// can widen: this lane's 19-character seeded name renders inside it without
// pushing the popover past its own width, and a longer stored name wraps
// through the same `overflow-wrap: anywhere` declaration the stylesheet
// states for the title.
const longPrompt = longRow.getByRole('button', { name: 'Open reminder details: Join release review' })
const promptLayout = await longPrompt.evaluate(element => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}))
expect(promptLayout.scrollWidth).toBeLessThanOrEqual(promptLayout.clientWidth)
const [longRowLayout, cadenceRowLayout] = await Promise.all([
longRow.evaluate((element) => {
const box = element.getBoundingClientRect()
return {
bottom: box.bottom,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
childBottoms: [...element.children].map(child => child.getBoundingClientRect().bottom),
}
}),
cadenceRow.evaluate((element) => {
const box = element.getBoundingClientRect()
return { top: box.top, bottom: box.bottom }
}),
])
expect(longRowLayout.scrollHeight).toBeLessThanOrEqual(longRowLayout.clientHeight)
expect(longRowLayout.childBottoms).not.toHaveLength(0)
for (const childBottom of longRowLayout.childBottoms) {
expect(childBottom).toBeLessThanOrEqual(longRowLayout.bottom)
}
expect(longRowLayout.bottom).toBeLessThanOrEqual(cadenceRowLayout.top)
expect(cadenceRowLayout.bottom).toBeGreaterThan(cadenceRowLayout.top)
// The popover caps its height at min(420px, calc(100vh - 140px))
// (ScheduleCatalogAction.module.css). Each of the three rows keeps its 48px
// min-height plus the 6px list padding and two 1px gaps, so the list needs
// at least 152px; a 260px viewport leaves a 120px budget, which must clamp
// the list and make the clip scroll.
const clampedViewportHeight = 260
const clampedHeightCap = Math.min(420, clampedViewportHeight - 140)
const naturalHeight = await catalog.evaluate(element => element.clientHeight)
expect(naturalHeight).toBeGreaterThan(clampedHeightCap)
await page.setViewportSize({ width: 900, height: clampedViewportHeight })
const scrollLayout = await catalog.evaluate(element => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))
expect(scrollLayout.clientHeight).toBeLessThan(naturalHeight)
expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight)
await page.setViewportSize({ width: 900, height: 900 })
const evidenceDir = join(REPO_ROOT, '.artifacts')
await mkdir(evidenceDir, { recursive: true })
await writeFile(
join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.json'),
`${JSON.stringify(lightLayout, null, 2)}\n`,
)
await page.screenshot({
path: join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.png'),
fullPage: true,
})
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkBackground = await catalog.evaluate(element => getComputedStyle(element).backgroundColor)
expect(darkBackground).not.toBe('rgba(0, 0, 0, 0)')
expect(darkBackground).not.toBe(lightLayout.background)
await compareOrRefreshGolden(
CATALOG_EXPECTED,
await captureStableAria(page, '[aria-label="Active reminders"]', scaffold.workspaceCwd),
MODE,
)
// The next-run line states the target as the reader's local clock stamp
// followed by the distance to it in parentheses: the stamp is the instant in
// this device's zone, and only the parenthesized distance ticks.
const nextRunLine = catalog.getByRole('listitem').filter({ hasText: 'Join release review' })
.locator('[class*="nextRun"]')
await expect.poll(() => nextRunLine.count(), { timeout: 15_000 }).toBeGreaterThan(0)
const metadataText = await nextRunLine.first().textContent() ?? ''
const nextRun = await nextRunLine.first().locator('time').getAttribute('dateTime')
if (nextRun === null) throw new Error('the next-run line names no instant')
const nextRunAbsolute = new Intl.DateTimeFormat('en', {
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit',
hour12: false, timeZone: AT_BROWSER_ZONE,
}).format(new Date(nextRun))
expect(metadataText).toContain(`Next run ${nextRunAbsolute}`)
expect(metadataText).toMatch(/Next run [^)]+\((?:in \d+ \w+|Due now|\d+ .*overdue)\)/)
// The stamp is a local clock time, not a bare duration.
expect(metadataText).toMatch(/\d{1,2}:\d{2}/)
// The delete glyph is an aria-hidden SVG, so the ARIA golden cannot prove it
// still renders; the DOM has to.
const deleteButton = catalog.getByRole('button', { name: /^Delete reminder:/ }).first()
await expect.poll(() => deleteButton.locator('svg').count(), { timeout: 15_000 }).toBe(1)
for (let remaining = 3; remaining > 0; remaining--) {
await catalog.getByRole('button', { name: /^Delete reminder:/ }).first().click()
await expect.poll(() => catalog.getByRole('button', { name: /^Delete reminder:/ }).count(), { timeout: 15_000 })
.toBe(remaining - 1)
}
await expectNoReminderEntry(page)
expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual([])
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expectNoReminderEntry(page)
// This authored pin owns its sidecars through the snapshot manifest's
// `header.pin: true` contract, which the session-snapshot corpus enforces
// (`scripts/session-snapshot-corpus.corpus.ts`). The sidecars have no replay
// comparison here: this scenario seeds its fixture and then runs further
// turns, so `assertReplaySession` (enabled only by `replayFixture`) has no
// matching recording to compare against.
await assertFixtureInventory(CATALOG_SNAPSHOT_DIR, [
'catalog.expected.md',
'session.v3.jsonl',
'system-prompt.expected.md',
'tool-schemas.expected.json',
])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('shows the pruning footer only for confirmed cleanup after the final saved page', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-retention'))
const record = await scaffold.ctx.schedule.create(CATALOG_SESSION_ID, {
prompt: 'Review retained records', title: 'History retention review', at: '2099-09-01T12:00:00.000Z',
})
try {
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('button', { name: 'All', exact: true }).click()
await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(record.title)
await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: record.title, exact: true }).click()
const detail = manager.getByRole('complementary', { name: 'Task details' })
const recordsTab = detail.getByRole('tab', { name: 'Records', exact: true })
const rulesTab = detail.getByRole('tab', { name: 'Rules', exact: true })
const notice = detail.getByText('Earlier delivery records have been cleared', { exact: true })
await recordsTab.click()
await detail.getByText('No delivery record available', { exact: true }).waitFor()
expect(await notice.count()).toBe(0)
const domain = scaffold.ctx.storageDomain.get('schedule')
if (domain === undefined) throw new Error('Schedule domain was not opened')
const instant = new Date().toISOString()
const receipt = { scheduledAt: instant, deliveredAt: instant, messageId: MessageId('retention-legacy') }
await rulesTab.click()
await domain.table('tasks').put(record.id, {
sessionId: CATALOG_SESSION_ID, record, status: 'inactive', lastDelivery: receipt,
deliveryHistory: { records: [receipt], earlierRecordsUnavailable: true },
})
await recordsTab.click()
const saved = detail.getByRole('region', { name: 'Saved delivery record' })
await expect.poll(() => saved.count()).toBe(1)
expect(await notice.count()).toBe(0)
await rulesTab.click()
let task: ScheduleTask = {
sessionId: CATALOG_SESSION_ID, record, status: 'inactive',
deliveryHistory: { records: [], earlierRecordsUnavailable: false },
}
for (let index = 0; index < 23; index++) {
const time = new Date(Date.now() - (index === 0 ? 40 * 86_400_000 : (23 - index) * 3_600_000)).toISOString()
task = { ...task, ...appendDelivery(task, {
scheduledAt: time, deliveredAt: time, messageId: MessageId(`retention-${index}`),
}, { days: 30, records: 200 }) }
}
expect(task.deliveryHistory?.records).toHaveLength(22)
expect(task.deliveryHistory?.earlierRecordsPruned).toBe(true)
await domain.table('tasks').put(record.id, task)
await recordsTab.click()
await expect.poll(() => saved.count()).toBe(20)
expect(await notice.count()).toBe(0)
await detail.getByRole('button', { name: 'Load more', exact: true }).click()
await notice.waitFor()
expect(await saved.count()).toBe(22)
const info = detail.getByRole('button', { name: 'Retention rules', exact: true })
await info.click()
const bounds = detail.getByText('Each task keeps up to 200 delivery records from the last 30 days.', { exact: true })
await bounds.waitFor()
expect(await detail.locator('footer').innerText()).toMatchInlineSnapshot(`
"Earlier delivery records have been cleared
Each task keeps up to 200 delivery records from the last 30 days.
When a new record is saved, older records outside these limits are cleared automatically. Clearing records does not stop the task."
`)
await info.click()
expect(await bounds.count()).toBe(0)
const panelBox = await detail.boundingBox()
const footerBox = await detail.locator('footer').boundingBox()
expect(panelBox).not.toBeNull()
expect(footerBox).not.toBeNull()
expect(Math.abs(footerBox!.x - panelBox!.x)).toBeLessThan(2)
expect(Math.abs(footerBox!.y + footerBox!.height - panelBox!.y - panelBox!.height)).toBeLessThan(2)
expect(await detail.locator('footer').evaluate(element => getComputedStyle(element).borderTopWidth)).toBe('0px')
expect(tripwire.pageErrors).toEqual([])
} finally {
await scaffold.ctx.schedule.delete({ sessionId: CATALOG_SESSION_ID, id: record.id })
}
}, 60_000)
it('shows a tool-created daily rule as repeating with its original time zone', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-daily'))
const agent = await liveAgent(scaffold, CATALOG_SESSION_ID)
const timeZone = 'America/New_York'
const clock = new Intl.DateTimeFormat('en-GB-u-nu-latn', {
timeZone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
})
const time = clock.format(new Date(Date.now() + 3_600_000))
const prompt = 'Inspect the daily calendar rule'
const result = await executeToolFixture(scaffold.ctx, {
signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-daily-catalog-create'),
name: 'schedule_create', arguments: { prompt, title: prompt, daily: { time, time_zone: timeZone } }, agent,
})
const created = (await scaffold.ctx.schedule.catalog()).find(task => (
task.sessionId === CATALOG_SESSION_ID && task.prompt === prompt
))
if (created === undefined) throw new Error('daily tool did not create a stored task')
try {
expect(result.isError).toBe(false)
expect(result.value).toMatchObject({ kind: 'daily', time: `${time}.000`, timeZone })
expect(created).toMatchObject({ kind: 'daily', time: `${time}.000`, timeZone, status: 'active' })
expect(clock.format(new Date(created.scheduledAt))).toBe(time)
await page.clock.setFixedTime(new Date())
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('button', { name: 'All', exact: true }).click()
await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(prompt)
const tasks = manager.getByRole('list', { name: 'Task catalog' })
const row = tasks.getByRole('button', { name: prompt, exact: true })
await row.waitFor()
// The frequency line names the stored zone by its localized label: the
// current UTC offset and the ICU generic name of that zone. Both parts
// come from the same ICU data the app reads, so the assertion follows the
// runtime's own zone names.
const zoneName = new Intl.DateTimeFormat('en', { timeZone, timeZoneName: 'longGeneric' })
.formatToParts(new Date()).find(part => part.type === 'timeZoneName')?.value
const zoneOffset = new Intl.DateTimeFormat('en', { timeZone, timeZoneName: 'longOffset' })
.formatToParts(new Date()).find(part => part.type === 'timeZoneName')?.value?.replace(/^GMT/, 'UTC')
const frequency = `Daily at ${time.replace(/:00$/, '')} (${zoneOffset} · ${zoneName})`
expect(await row.textContent()).toContain(frequency)
await row.click()
const detail = manager.getByRole('complementary', { name: 'Task details' })
expect(await detail.getByText(frequency, { exact: true }).count()).toBe(1)
expect(await detail.getByText('Once', { exact: true }).count()).toBe(0)
const runTime = detail.getByRole('region', { name: 'Run time', exact: true })
const timeField = runTime.getByRole('button', { name: 'Time', exact: true })
// The Time zone row is a menu trigger button; the zone is chosen, never typed.
const zoneField = runTime.getByRole('button', { name: /Time zone/ })
await zoneField.click()
// Zone rows lead with the current offset and the localized zone name, so
// the browser's own zone is the row marked as the system one.
await page.getByRole('menuitem', { name: /China Standard Time \(system\)$/ }).click()
// The Time row opens three clock columns; a pick stages the clock itself,
// and the stored rule keeps the millisecond precision the picker writes.
await timeField.click()
expect(await timeField.getAttribute('aria-expanded')).toBe('true')
await page.getByRole('listbox', { name: 'Hour', exact: true }).getByRole('option', { name: '12', exact: true }).click()
await page.getByRole('listbox', { name: 'Minute', exact: true }).getByRole('option', { name: '34', exact: true }).click()
await page.getByRole('listbox', { name: 'Second', exact: true }).getByRole('option', { name: '56', exact: true }).click()
await page.keyboard.press('Escape')
expect(await timeField.textContent()).toBe('12:34:56')
await expect.poll(() => detail.getByText('Unsaved changes', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// The staged draft reaches the Host only through the save bar's Save action.
await detail.getByRole('button', { name: 'Save changes', exact: true }).click()
await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).find(task => task.id === created.id), { timeout: 15_000 })
.toMatchObject({ time: '12:34:56.000', timeZone: 'Asia/Shanghai' })
const saved = (await scaffold.ctx.schedule.catalog()).find(task => task.id === created.id)
expect(saved).toMatchObject({
id: created.id, sessionId: CATALOG_SESSION_ID, prompt, status: 'active',
kind: 'daily', time: '12:34:56.000', timeZone: 'Asia/Shanghai',
})
// A stored zone equal to the browser zone is omitted from the frequency line.
await expect.poll(() => detail.getByText('Daily at 12:34:56', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect(await scaffold.ctx.schedule.history({ sessionId: CATALOG_SESSION_ID, id: created.id, limit: 20 }))
.toEqual({ id: created.id, records: [], earlierRecordsUnavailable: false,
earlierRecordsPruned: false, retention: { days: 30, records: 200 } })
// The created rule stays active, so the ended-status filter hides every
// row; the retired type chips no longer exist.
await manager.getByRole('button', { name: 'Inactive', exact: true }).click()
await expect.poll(() => tasks.getByRole('listitem').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
} finally {
await scaffold.ctx.schedule.delete({ sessionId: CATALOG_SESSION_ID, id: created.id })
await page.clock.setFixedTime(new Date(CATALOG_NOW))
}
}, 60_000)
it('removes every ended task from the page and keeps the active ones', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-clear-ended'))
const endedSession = SessionId('schedule-manager-ended-clear')
const domain = scaffold.ctx.storageDomain.get('schedule')
if (domain === undefined) throw new Error('Schedule domain was not opened')
const endedIds = [ScheduleId('schedule-ended-first'), ScheduleId('schedule-ended-second')]
const endedTitles = ['Ended release check', 'Ended metrics check'] as const
// An earlier scenario may leave an ended row behind; the confirmation states
// the whole ended set, so the lane starts from an empty one.
await scaffold.ctx.schedule.deleteInactive()
for (const [index, id] of endedIds.entries()) {
const title = endedTitles[index]!
const record = createAfterScheduleRecord(id, title, 60, Date.now(), title)
const receipt = {
scheduledAt: record.scheduledAt,
deliveredAt: new Date(Date.parse(record.scheduledAt) + 1_000).toISOString(),
messageId: MessageId(`${id}-delivery`),
}
await domain.table('tasks').put(record.id, {
sessionId: endedSession, record, status: 'inactive', lastDelivery: receipt,
deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false },
})
}
// The running task is what the cleanup must leave alone. Creating it also
// announces a committed Host change, so the open page reads the rows seeded
// above without waiting for its own first catalog read.
const kept = await scaffold.ctx.schedule.create(endedSession, {
prompt: 'Keep this running task', title: 'Keep this running task', at: '2099-08-25T12:08:00.000Z',
})
try {
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
// Earlier scenarios leave their own search text and status filter on this
// shared page; this scenario asserts the complete list.
await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('')
await manager.getByRole('button', { name: 'All', exact: true }).click()
const more = manager.getByRole('button', { name: 'More task actions', exact: true })
await more.waitFor({ timeout: 15_000 })
// The confirmation states the Host-side target and removes nothing until it
// is confirmed.
await more.click()
await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click()
const confirmation = page.getByRole('dialog', { name: 'Clear inactive tasks (2)?' })
await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click()
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2)
await more.click()
await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click()
await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click()
await expect.poll(
async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive'),
{ timeout: 15_000 },
).toEqual([])
const remaining = await scaffold.ctx.schedule.catalog()
expect(remaining.some(task => endedIds.includes(task.id))).toBe(false)
expect(remaining.map(task => task.title)).toContain('Keep this running task')
await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 })
// The trigger left with the rows it removed, so the page heading owns the
// focus after the confirmation closes; this confirms the heading fallback
// in a real browser.
await expect.poll(() => page.evaluate(() => document.activeElement?.tagName), { timeout: 15_000 })
.toBe('H1')
// The page follows the acknowledged removal: no ended row or cleanup action
// remains, and the running task is still listed.
await expect.poll(() => manager.getByRole('button', { name: 'More task actions', exact: true }).count(), { timeout: 15_000 })
.toBe(0)
await manager.getByRole('button', { name: 'All', exact: true }).click()
await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' })
.getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([])
expect(tripwire.pageErrors).toEqual([])
} finally {
// The shared page and Host outlive a failed assertion: remove every row
// this scenario introduced so the next one starts from the same state. A
// teardown rejection must not mask the assertion that failed first.
await Promise.allSettled(
[kept.id, ...endedIds].map(id => scaffold.ctx.schedule.delete({ sessionId: endedSession, id })),
)
}
}, 60_000)
})