Files
website/client/test/eventAuthoring.test.js
wtclaude 7b570c8ea1
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 5m26s
PR Checks / client-build (pull_request) Successful in 8m30s
feat(events): the minimal admin surface (Phase 3)
Three screens, an Events nav group and the six live run controls Phase 1 left
absent on purpose because nothing was in flight. An admin can now author,
publish, start and watch an event that announces things and cues a human; a
moderator can stop one that is going wrong.

Six controls, not eight. `advance` is absent because a phase today advances when
its steps go terminal — the per-step skip already does that — and Phase 5 is what
gives a phase an advance condition. Cancel takes `{ reason }`, not `{ cleanup }`,
until Phase 8's ledger exists. Every control is a compare-and-set on the status it
may act from, so a console rendered thirty seconds ago cannot act on a run that
has moved.

Fixes a defect in the Phase 2 runner: `advanceRun` drained up to
EVENT_STEPS_PER_TICK steps while only checking the run's status at the top of the
tick, so a pause pressed mid-batch did nothing for up to 24 more steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 08:39:35 -05:00

311 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
runControlsFor,
stepControlsFor,
isParked,
lastStartedSeqOf,
formFromDefinition,
payloadFromForm,
parseParams,
blankStep,
blankPhase,
describeLogLine,
runStatusWord,
} from '../src/lib/eventAuthoring.js'
// lib/eventAuthoring.js — what the three Events screens say and what they let
// staff press (EVENTS.md §I, Phase 3).
//
// None of this is a boundary: `events/spec.js` decides what may be saved and the
// six control statements decide what may happen to a run, each of them a
// compare-and-set that re-checks the status this file only predicted.
//
// **The controls get most of the tests, and the reason is worth stating.** A
// button offered that the server refuses is not a wrong write — but it is the
// failure an operator meets at 2am, on the screen they opened because something
// is already going wrong, about the run they are trying to stop. So the guards
// are deliberately written twice and this is where the copy is checked against
// the original.
const run = (over = {}) => ({ id: 1, status: 'running', currentPhase: 'main', ...over })
const step = (over = {}) => ({
id: 10,
phase: 'main',
seq: 0,
status: 'pending',
parked: false,
...over,
})
// ── The run controls ───────────────────────────────────────────────────────
test('pause is offered only for a run in flight', () => {
assert.equal(runControlsFor(run({ status: 'running' })).pause, true)
assert.equal(runControlsFor(run({ status: 'starting' })).pause, true)
// A scheduled occurrence that should not happen is cancelled, not paused:
// resuming one after its grace window would produce a `missed` from a button
// labelled resume.
assert.equal(runControlsFor(run({ status: 'scheduled' })).pause, false)
assert.equal(runControlsFor(run({ status: 'paused' })).pause, false)
})
test('cancel is offered right up to the moment a run goes terminal, and never after', () => {
for (const status of ['scheduled', 'starting', 'running', 'paused', 'ending']) {
assert.equal(runControlsFor(run({ status })).cancel, true, `${status} should be cancellable`)
}
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
assert.equal(runControlsFor(run({ status })).cancel, false, `${status} should not be`)
}
})
test('resume is offered for exactly one status', () => {
assert.equal(runControlsFor(run({ status: 'paused' })).resume, true)
assert.equal(runControlsFor(run({ status: 'running' })).resume, false)
})
// ── The step controls ──────────────────────────────────────────────────────
test('a parked step is running with nothing holding it, and only that', () => {
assert.equal(isParked(step({ status: 'running', parked: true })), true)
assert.equal(isParked(step({ status: 'running', parked: false })), false, 'a live lease is a dispatch')
assert.equal(isParked(step({ status: 'pending', parked: true })), false)
})
test('confirm is offered for a parked cue and for nothing else', () => {
const r = run()
const parked = step({ status: 'running', parked: true })
assert.equal(stepControlsFor(r, parked, [parked]).confirm, true)
const dispatching = step({ status: 'running', parked: false })
assert.equal(stepControlsFor(r, dispatching, [dispatching]).confirm, false)
const pending = step()
assert.equal(stepControlsFor(r, pending, [pending]).confirm, false)
})
test('skip is offered for a pending step and a parked cue', () => {
const r = run()
const pending = step()
const parked = step({ id: 11, seq: 1, status: 'running', parked: true })
const dispatching = step({ id: 12, seq: 2, status: 'running', parked: false })
const failed = step({ id: 13, seq: 3, status: 'failed' })
const steps = [pending, parked, dispatching, failed]
assert.equal(stepControlsFor(r, pending, steps).skip, true)
assert.equal(stepControlsFor(r, parked, steps).skip, true)
assert.equal(stepControlsFor(r, dispatching, steps).skip, false)
// A failed step does not need skipping: the runner already steps over it, so
// resuming the run carries the phase past it.
assert.equal(stepControlsFor(r, failed, steps).skip, false)
})
test('retry is offered for the failed step a paused run is stopped at', () => {
const r = run({ status: 'paused' })
const done = step({ id: 1, seq: 0, status: 'done' })
const failed = step({ id: 2, seq: 1, status: 'failed' })
const pending = step({ id: 3, seq: 2, status: 'pending' })
const steps = [done, failed, pending]
assert.equal(stepControlsFor(r, failed, steps).retry, true)
assert.equal(stepControlsFor(r, done, steps).retry, false)
assert.equal(stepControlsFor(r, pending, steps).retry, false)
})
test('retry is NOT offered for a failed step the run has moved past', () => {
// The case the server guard exists for, and the one this copy of it has to
// agree about: a phase that carried on past an `on_failure: skip` failure and
// then paused at a later step. Offering retry on the first would re-queue a row
// behind the runner's own cursor, where it sits pending for ever.
const r = run({ status: 'paused' })
const skippedOver = step({ id: 1, seq: 0, status: 'failed' })
const carriedOn = step({ id: 2, seq: 1, status: 'done' })
const stoppedAt = step({ id: 3, seq: 2, status: 'failed' })
const notYet = step({ id: 4, seq: 3, status: 'pending' })
const steps = [skippedOver, carriedOn, stoppedAt, notYet]
assert.equal(stepControlsFor(r, skippedOver, steps).retry, false)
assert.equal(stepControlsFor(r, stoppedAt, steps).retry, true)
})
test('retry is not offered while the run is still running, or in a phase it has left', () => {
const failed = step({ status: 'failed' })
assert.equal(stepControlsFor(run({ status: 'running' }), failed, [failed]).retry, false)
const old = step({ phase: 'one', status: 'failed' })
const r = run({ status: 'paused', currentPhase: 'two' })
assert.equal(stepControlsFor(r, old, [old]).retry, false)
})
test('no control is offered on a run that is over', () => {
for (const status of ['completed', 'cancelled', 'failed', 'missed']) {
const parked = step({ status: 'running', parked: true })
assert.deepEqual(stepControlsFor(run({ status }), parked, [parked]), {
confirm: false,
skip: false,
retry: false,
})
}
})
test('lastStartedSeqOf is the furthest step of the phase, and null when none has run', () => {
const steps = [
step({ id: 1, seq: 0, status: 'failed' }),
step({ id: 2, seq: 1, status: 'done' }),
step({ id: 3, seq: 2, status: 'pending' }),
step({ id: 4, seq: 0, phase: 'other', status: 'done' }),
]
assert.equal(lastStartedSeqOf(steps, 'main'), 1)
assert.equal(lastStartedSeqOf([step({ status: 'pending' })], 'main'), null)
assert.equal(lastStartedSeqOf(steps, 'nothing-here'), null)
})
// ── The definition form ────────────────────────────────────────────────────
const ANNOUNCE = {
id: 'core.announce',
label: 'Announce',
risk: 'notify',
params: [
{ name: 'leg', type: 'string', required: true, example: 'discord' },
{ name: 'title', type: 'string', required: false, example: 'The gates open' },
{ name: 'body', type: 'string', required: true, example: 'A caravan was sighted.' },
],
}
test('a new step arrives prefilled from the actions declared examples', () => {
const fresh = blankStep(ANNOUNCE)
assert.equal(fresh.actionId, 'core.announce')
assert.deepEqual(JSON.parse(fresh.paramsText), {
leg: 'discord',
title: 'The gates open',
body: 'A caravan was sighted.',
})
})
test('a new phase never collides with an existing key', () => {
// Two phases sharing a key would silently collapse at materialisation —
// `event_run_steps` is UNIQUE on (run_id, phase, seq) — so half the authored
// steps would never exist. The server refuses it; the form must not propose it.
const first = blankPhase([])
const second = blankPhase([first])
const third = blankPhase([first, second])
assert.equal(new Set([first.key, second.key, third.key]).size, 3)
})
test('the form round-trips a definition without losing a step', () => {
const event = {
title: 'Invasion',
graceSeconds: 600,
timezone: 'Europe/Berlin',
concurrencyKey: 'invasion:{region}',
spec: {
schedule: { kind: 'manual' },
phases: [
{
key: 'warn',
label: 'Warning',
steps: [
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
{ actionId: 'core.wait', params: { seconds: 300 } },
],
},
],
},
}
const built = payloadFromForm(formFromDefinition(event))
assert.equal(built.ok, true)
assert.deepEqual(built.payload.spec.phases, [
{
key: 'warn',
label: 'Warning',
steps: [
{ actionId: 'core.announce', label: 'Herald', onFailure: 'skip', params: { leg: 'discord', body: 'hi' } },
{ actionId: 'core.wait', params: { seconds: 300 } },
],
},
])
assert.equal(built.payload.graceSeconds, 600)
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
})
test('an unchosen onFailure is omitted rather than invented', () => {
// The server defaults it from the action's risk class, which is the whole
// reason `risk` is required at registration. A form that posted a value would
// silently override that — turning a `change` action's `pause` into a `skip`
// and advancing a run over a half-changed world.
const form = formFromDefinition({
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
})
const built = payloadFromForm(form)
assert.equal('onFailure' in built.payload.spec.phases[0].steps[0], false)
})
test('a params box that is not JSON is refused with the step named', () => {
const form = formFromDefinition({
spec: { phases: [{ key: 'main', label: 'Main', steps: [{ actionId: 'core.announce', params: {} }] }] },
})
form.phases[0].steps[0].paramsText = '{ leg: discord }'
const built = payloadFromForm(form)
assert.equal(built.ok, false)
assert.match(built.errors[0], /Phase 1 "Main", step 1/)
})
test('an empty params box is an empty object, not an error', () => {
assert.deepEqual(parseParams('').params, {})
assert.deepEqual(parseParams(' ').params, {})
assert.ok(parseParams('[1,2]').error, 'an array is not a params object')
assert.ok(parseParams('"leg"').error)
})
// ── Rendering what happened ────────────────────────────────────────────────
test('a human transition reads differently from the runners own', () => {
// Both are `run.status` rows. `detail.control` is the only thing that separates
// "the runner paused this because a world write failed" from "somebody pressed
// pause", and the console has to tell them apart at a glance.
const byRunner = describeLogLine({
kind: 'run.status',
detail: { from: 'running', to: 'paused', because: 'core.spawn' },
})
const byPerson = describeLogLine({
kind: 'run.status',
detail: { from: 'running', to: 'paused', control: 'pause', by: 4, reason: 'shard is lagging' },
})
assert.match(byRunner, /Running → Paused/)
assert.match(byRunner, /core\.spawn/)
assert.match(byPerson, /pause/)
assert.match(byPerson, /by staff/)
assert.match(byPerson, /shard is lagging/)
})
test('the log lines a run produces all render as something', () => {
const lines = [
{ kind: 'run.created', detail: { version: 3, rehearsal: true } },
{ kind: 'run.blocked', detail: { heldBy: 9, concurrencyKey: 'invasion:Yew' } },
{ kind: 'run.health', detail: { to: 'degraded', because: 'core.announce' } },
{ kind: 'phase.entered', phase: 'warn', detail: { steps: 2 } },
{ kind: 'phase.completed', phase: 'warn', detail: {} },
{ kind: 'step.parked', detail: { action: 'core.cue' } },
{ kind: 'step.retry', detail: { action: 'core.announce', attempt: 1, of: 3, error: 'timeout' } },
{ kind: 'step.status', detail: { action: 'core.wait', to: 'done' } },
{ kind: 'note', detail: {} },
]
for (const line of lines) {
const text = describeLogLine(line)
assert.equal(typeof text, 'string')
assert.ok(text.length > 0, `${line.kind} rendered as nothing`)
assert.ok(!text.includes('undefined'), `${line.kind} rendered an undefined: ${text}`)
}
})
test('every run status has a word, and an unknown one falls through rather than blanking', () => {
for (const s of ['scheduled', 'starting', 'running', 'paused', 'ending', 'completed', 'cancelled', 'failed', 'missed']) {
assert.ok(runStatusWord(s).length > 0)
}
assert.equal(runStatusWord('something-new'), 'something-new')
})