mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
1346 lines
71 KiB
TypeScript
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)
|
|
})
|