test(schedule): close coverage gaps
This commit is contained in:
@@ -9,6 +9,66 @@ import { zh } from '../src/client/locales.ts'
|
|||||||
|
|
||||||
const t: ReminderRowProps['t'] = makeTranslate(zh)
|
const t: ReminderRowProps['t'] = makeTranslate(zh)
|
||||||
|
|
||||||
|
const invalidSidecars: ReadonlyArray<{ name: string; view: unknown }> = [
|
||||||
|
{ name: 'non-object', view: undefined },
|
||||||
|
{ name: 'null', view: null },
|
||||||
|
{ name: 'array', view: [] },
|
||||||
|
{
|
||||||
|
name: 'missing schedule id',
|
||||||
|
view: {
|
||||||
|
scheduleId: null,
|
||||||
|
prompt: 'not trusted',
|
||||||
|
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||||
|
deliveryMode: 'session-local',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'empty schedule id',
|
||||||
|
view: {
|
||||||
|
scheduleId: '',
|
||||||
|
prompt: 'not trusted',
|
||||||
|
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||||
|
deliveryMode: 'session-local',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-string prompt',
|
||||||
|
view: {
|
||||||
|
scheduleId: 'schedule-7',
|
||||||
|
prompt: 7,
|
||||||
|
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||||
|
deliveryMode: 'session-local',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-string occurrence',
|
||||||
|
view: {
|
||||||
|
scheduleId: 'schedule-7',
|
||||||
|
prompt: 'not trusted',
|
||||||
|
occurrenceAt: 7,
|
||||||
|
deliveryMode: 'session-local',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'empty occurrence',
|
||||||
|
view: {
|
||||||
|
scheduleId: 'schedule-7',
|
||||||
|
prompt: 'not trusted',
|
||||||
|
occurrenceAt: '',
|
||||||
|
deliveryMode: 'session-local',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unsupported delivery mode',
|
||||||
|
view: {
|
||||||
|
scheduleId: 'schedule-7',
|
||||||
|
prompt: 'not trusted',
|
||||||
|
occurrenceAt: '2026-08-05T08:00:00.000Z',
|
||||||
|
deliveryMode: 'external',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
function props(view: unknown): ReminderRowProps {
|
function props(view: unknown): ReminderRowProps {
|
||||||
@@ -40,13 +100,8 @@ describe('ReminderRow', () => {
|
|||||||
expect(time.getAttribute('datetime')).toBe('2026-08-05T08:00:00.000Z')
|
expect(time.getAttribute('datetime')).toBe('2026-08-05T08:00:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('contains an incompatible sidecar as a visible unavailable receipt', () => {
|
it.each(invalidSidecars)('contains an incompatible $name sidecar as an unavailable receipt', ({ view }) => {
|
||||||
render(<ReminderRow {...props({
|
render(<ReminderRow {...props(view)} />)
|
||||||
scheduleId: '',
|
|
||||||
prompt: 'not trusted',
|
|
||||||
occurrenceAt: 123,
|
|
||||||
deliveryMode: 'external',
|
|
||||||
})} />)
|
|
||||||
|
|
||||||
expect(screen.getByText('提醒回执不可用 · schedule/change')).toBeTruthy()
|
expect(screen.getByText('提醒回执不可用 · schedule/change')).toBeTruthy()
|
||||||
expect(screen.queryByText('not trusted')).toBeNull()
|
expect(screen.queryByText('not trusted')).toBeNull()
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
|||||||
const seed = session.events.slice(0, live.seedEnd)
|
const seed = session.events.slice(0, live.seedEnd)
|
||||||
await this.onCreated(session, seed)
|
await this.onCreated(session, seed)
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
if (live.init === init) live.init = undefined
|
live.init = undefined
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
live.init = init
|
live.init = init
|
||||||
@@ -1254,7 +1254,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
|||||||
if (tracked.materialized || tracked.cursor !== 0) {
|
if (tracked.materialized || tracked.cursor !== 0) {
|
||||||
throw new Error(`session "${session.header.id}" lost its persisted artifact during live initialization`)
|
throw new Error(`session "${session.header.id}" lost its persisted artifact during live initialization`)
|
||||||
}
|
}
|
||||||
if (seed.length > 0) await this.appendCore(session.header.id, seed)
|
await this.appendCore(session.header.id, seed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await this.adoptLivePrefix(session, seed, stored, tracked)
|
await this.adoptLivePrefix(session, seed, stored, tracked)
|
||||||
|
|||||||
@@ -467,6 +467,87 @@ describe('PersistenceCoordinator retryable live initialization', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('retries a fork seed when initialization rejects before materialization', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SessionStore)
|
||||||
|
const backend = new ControlledBackend()
|
||||||
|
const appendGate = Promise.withResolvers<undefined>()
|
||||||
|
backend.beforeAppend = async (attempt) => {
|
||||||
|
if (attempt === 1) {
|
||||||
|
await appendGate.promise
|
||||||
|
throw new Error('pre-commit init write failure')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
|
new PersistenceCoordinator(inner, backend)
|
||||||
|
}, { inject: ['sessions'] }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seed = oneTurnLog()
|
||||||
|
const session = ctx.sessions.create(SessionId('retry-unmaterialized-fork-seed'), {
|
||||||
|
seed,
|
||||||
|
meta: { cwd: '/w', seedLength: seed.length },
|
||||||
|
})
|
||||||
|
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||||
|
const first = ctx.sessions.flush(session)
|
||||||
|
appendGate.resolve(undefined)
|
||||||
|
await expect(first).rejects.toThrow('pre-commit init write failure')
|
||||||
|
await expect(ctx.sessions.flush(session)).resolves.toBe(true)
|
||||||
|
|
||||||
|
expect(backend.appendAttempts).toBe(2)
|
||||||
|
expect(backend.store.get(session.id)?.events.map(event => event.seq))
|
||||||
|
.toEqual([0, 1, 2, 3, 4, 5, 6])
|
||||||
|
} finally {
|
||||||
|
appendGate.resolve(undefined)
|
||||||
|
await fiber.dispose()
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a retry when its adopted durable prefix disappears', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SessionStore)
|
||||||
|
const backend = new ControlledBackend()
|
||||||
|
const id = SessionId('retry-missing-adopted-prefix')
|
||||||
|
const seed = oneTurnLog()
|
||||||
|
const stored = seed.slice(0, 1)
|
||||||
|
const storedMeta = meta(id, '/w')
|
||||||
|
backend.store.set(id, { meta: storedMeta, events: structuredClone(stored) })
|
||||||
|
const appendGate = Promise.withResolvers<undefined>()
|
||||||
|
backend.beforeAppend = async (attempt) => {
|
||||||
|
if (attempt === 1) {
|
||||||
|
await appendGate.promise
|
||||||
|
throw new Error('pre-commit adoption write failure')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||||
|
new PersistenceCoordinator(inner, backend)
|
||||||
|
}, { inject: ['sessions'] }))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = ctx.sessions.create(id, {
|
||||||
|
seed,
|
||||||
|
meta: { cwd: '/w', seedLength: seed.length },
|
||||||
|
})
|
||||||
|
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||||
|
const first = ctx.sessions.flush(session)
|
||||||
|
appendGate.resolve(undefined)
|
||||||
|
await expect(first).rejects.toThrow('pre-commit adoption write failure')
|
||||||
|
|
||||||
|
backend.store.delete(id)
|
||||||
|
await expect(ctx.sessions.flush(session))
|
||||||
|
.rejects.toThrow('lost its persisted artifact during live initialization')
|
||||||
|
expect(backend.appendAttempts).toBe(1)
|
||||||
|
} finally {
|
||||||
|
appendGate.resolve(undefined)
|
||||||
|
if (!backend.store.has(id)) {
|
||||||
|
backend.store.set(id, { meta: storedMeta, events: structuredClone(stored) })
|
||||||
|
}
|
||||||
|
await fiber.dispose()
|
||||||
|
await ctx.fiber.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('retries only a missing suffix after stored-session adoption rejects', async () => {
|
it('retries only a missing suffix after stored-session adoption rejects', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
|
|||||||
Reference in New Issue
Block a user