CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 30s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m44s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped
tests/factories.py holds image_row/make_image/make_image_async/make_tag. The 17 byte-identical _img/_tag helpers (15 modules) now import them under their old names, so no call site changed. frontend/test/support/stubFetch.js replaces 15 copies that differed only in formatting. Copies whose bodies differ (other defaults, other columns, a url-only stub) are left as they are; folding those needs a look at each caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
224 lines
8.9 KiB
JavaScript
224 lines
8.9 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { setActivePinia, createPinia } from 'pinia'
|
|
import { laneStuckFor, useSystemActivityStore } from '../src/stores/systemActivity.js'
|
|
import { stubFetch } from './support/stubFetch.js'
|
|
|
|
// Milestone 422 step 4. Covers the store half of the worker-lane dial — the
|
|
// part that decides what the card can tell the operator.
|
|
//
|
|
// What is protected here since 2026-09-23: pressing the dial must not wait on
|
|
// a broker round trip. Operator: *"when the number is changed the change
|
|
// should be queued so that it isn't blocking of the webui or the system
|
|
// itself. we shouldn't have to wait for the validation live."* The endpoint
|
|
// answers once the cap is STORED, so the store patches its row from that
|
|
// reply and lets the 15s poll bring the live columns — it used to refetch,
|
|
// and GET /api/system/workers costs a celery inspect on an eleven-second
|
|
// budget.
|
|
//
|
|
// And still: a REFUSED value (400) is a failure and must reach the operator
|
|
// as one. A control that silently does nothing is worse than one that
|
|
// refuses out loud.
|
|
|
|
const LANES_BODY = {
|
|
lanes: [
|
|
{
|
|
name: 'worker', display_name: 'Worker',
|
|
queues: ['default', 'import', 'thumbnail', 'download'],
|
|
slots_cap: 1, ceiling: 8, enabled: true, memory_bound: false,
|
|
live: { present: true, replicas: 1, pool: 1, active: 0, reserved: 3 },
|
|
queue_depth: 5, pending: 8,
|
|
},
|
|
{
|
|
name: 'ml', display_name: 'ML tagging', queues: ['ml'],
|
|
slots_cap: 0, ceiling: 2, enabled: false, memory_bound: true,
|
|
live: { present: false, replicas: 0, pool: null, active: 0, reserved: 0 },
|
|
queue_depth: null, pending: null,
|
|
},
|
|
],
|
|
fetched_at: '2026-09-22T12:00:00Z',
|
|
}
|
|
|
|
describe('worker lanes store', () => {
|
|
beforeEach(() => setActivePinia(createPinia()))
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
it('loads the lanes', async () => {
|
|
stubFetch(() => ({ status: 200, body: LANES_BODY }))
|
|
const s = useSystemActivityStore()
|
|
await s.loadLanes()
|
|
expect(s.lanes.lanes.map((l) => l.name)).toEqual(['worker', 'ml'])
|
|
})
|
|
|
|
it('a load failure records the error rather than throwing at the caller', async () => {
|
|
// The System tab polls this every 15s. An unhandled rejection per tick
|
|
// would drown the console and stop the other pollers in the same
|
|
// function.
|
|
stubFetch(() => ({ status: 500, body: { error: 'boom' } }))
|
|
const s = useSystemActivityStore()
|
|
await expect(s.loadLanes()).resolves.toBeUndefined()
|
|
expect(s.lastError).toBeTruthy()
|
|
})
|
|
|
|
it('setLane posts only the fields it was given', async () => {
|
|
// One field: the cap. How many workers are running is a measurement the
|
|
// sizing pass owns, so there is nothing else for the UI to send.
|
|
const calls = []
|
|
stubFetch((url, init) => {
|
|
calls.push({ url, init })
|
|
if (init?.method === 'POST') {
|
|
return { status: 200, body: { name: 'worker', slots_cap: 2, queued: true } }
|
|
}
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
await s.setLane('worker', { slots_cap: 2 })
|
|
|
|
const post = calls.find((c) => c.init?.method === 'POST')
|
|
expect(post.url).toContain('/api/system/workers/worker')
|
|
expect(JSON.parse(post.init.body)).toEqual({ slots_cap: 2 })
|
|
})
|
|
|
|
it('setLane does not refetch — that refetch is what blocked the press', async () => {
|
|
// It refetched until 2026-09-23, so that the card showed server truth
|
|
// rather than a local guess. The cost was the operator's: GET
|
|
// /api/system/workers runs a celery inspect, so every press of `+` sat
|
|
// with the stepper disabled through a broker round trip the press did not
|
|
// need.
|
|
let gets = 0
|
|
stubFetch((url, init) => {
|
|
if (init?.method === 'POST') {
|
|
return { status: 200, body: { slots_cap: 2, ceiling: 8, enabled: true } }
|
|
}
|
|
gets += 1
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
await s.loadLanes()
|
|
gets = 0
|
|
await s.setLane('worker', { slots_cap: 2 })
|
|
|
|
expect(gets).toBe(0)
|
|
})
|
|
|
|
it('patches the row from the reply, so the new number shows at once', async () => {
|
|
stubFetch((url, init) => {
|
|
if (init?.method === 'POST') {
|
|
return {
|
|
status: 200,
|
|
body: { name: 'worker', slots_cap: 2, ceiling: 8, enabled: true, queued: true },
|
|
}
|
|
}
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
await s.loadLanes()
|
|
await s.setLane('worker', { slots_cap: 2 })
|
|
|
|
const worker = s.lanes.lanes.find((l) => l.name === 'worker')
|
|
expect(worker.slots_cap).toBe(2)
|
|
})
|
|
|
|
it('does not invent the live columns the reply cannot know', async () => {
|
|
// The reply is decided from the stored cap and the machine's ceiling
|
|
// alone — it never asked a worker anything. Pool, active and pending are
|
|
// MEASUREMENTS, and the poll a few seconds later is what carries them.
|
|
// Zeroing or guessing them here would make the table lie in the direction
|
|
// that reads as "the lane stopped".
|
|
stubFetch((url, init) => {
|
|
if (init?.method === 'POST') {
|
|
return { status: 200, body: { slots_cap: 2, ceiling: 8, enabled: true } }
|
|
}
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
await s.loadLanes()
|
|
const before = { ...s.lanes.lanes[0].live }
|
|
await s.setLane('worker', { slots_cap: 2 })
|
|
|
|
expect(s.lanes.lanes[0].live).toEqual(before)
|
|
expect(s.lanes.lanes[0].pending).toBe(8)
|
|
})
|
|
|
|
it('survives a reply arriving for a lane it has not loaded yet', async () => {
|
|
// First paint, or a lane added by a newer build. Patching must not be the
|
|
// thing that throws inside the click handler.
|
|
stubFetch(() => ({ status: 200, body: { slots_cap: 2 } }))
|
|
const s = useSystemActivityStore()
|
|
await expect(s.setLane('worker', { slots_cap: 2 })).resolves.toBeTruthy()
|
|
})
|
|
|
|
it('a refused value throws so the card can show the reason', async () => {
|
|
// Deliberately NOT swallowed. The detail is written to be read by a person
|
|
// ("above what this container can hold"), and a control that silently does
|
|
// nothing is worse than one that refuses out loud.
|
|
stubFetch((url, init) => {
|
|
if (init?.method === 'POST') {
|
|
return {
|
|
status: 400,
|
|
body: { error: 'refused', detail: 'cap 10000 is above what this container can hold (2 for ML tagging)' },
|
|
}
|
|
}
|
|
return { status: 200, body: LANES_BODY }
|
|
})
|
|
const s = useSystemActivityStore()
|
|
// Assert the REASON is reachable, not merely that it threw. `toThrow()`
|
|
// alone passes whether the card can read the sentence or not — which is
|
|
// how the first version of the card shipped reading `e.detail` (always
|
|
// undefined) and would have shown the operator the bare word "refused".
|
|
const err = await s.setLane('ml', { slots_cap: 10000 }).catch((e) => e)
|
|
expect(err.status).toBe(400)
|
|
expect(err.body.detail).toContain('container can hold')
|
|
})
|
|
})
|
|
|
|
// --- the long-running-task warning (step 7) ----------------------------------
|
|
//
|
|
// The operator asked for "a task runs for x concurrent time" to TRIGGER
|
|
// growth. It became a warning: extra slots do not make a running task finish
|
|
// sooner. What is pinned here is the AND — a warning that fires on half its
|
|
// condition is one people learn to ignore, and at that point it is worse than
|
|
// not having it.
|
|
|
|
describe('laneStuckFor', () => {
|
|
const lane = (over = {}) => ({
|
|
oldest_running_minutes: 40,
|
|
live: { present: true, pool: 4, active: 4 },
|
|
...over,
|
|
})
|
|
|
|
it('warns when every slot is busy and the oldest task is old', () => {
|
|
expect(laneStuckFor(lane())).toBe('40 minutes')
|
|
})
|
|
|
|
it('says nothing when the lane has a free slot', () => {
|
|
expect(laneStuckFor(lane({ live: { present: true, pool: 4, active: 3 } })))
|
|
.toBeNull()
|
|
})
|
|
|
|
it('says nothing for a saturated lane whose tasks are young', () => {
|
|
expect(laneStuckFor(lane({ oldest_running_minutes: 2 }))).toBeNull()
|
|
})
|
|
|
|
it('says nothing when nothing is running', () => {
|
|
expect(laneStuckFor(lane({ oldest_running_minutes: null }))).toBeNull()
|
|
})
|
|
|
|
it('says nothing about a lane that is not answering', () => {
|
|
// Unknown is not busy. A lane nothing answered for has no live reading to
|
|
// call saturated, and asserting one would be a verdict from an unswept
|
|
// read — the same distinction `present` exists for everywhere else here.
|
|
expect(laneStuckFor(lane({ live: { present: false, pool: null, active: 0 } })))
|
|
.toBeNull()
|
|
})
|
|
|
|
it('says nothing when the pool is zero', () => {
|
|
// A lane with no workers is not "fully busy at zero" — it is off.
|
|
expect(laneStuckFor(lane({ live: { present: true, pool: 0, active: 0 } })))
|
|
.toBeNull()
|
|
})
|
|
|
|
it('switches to hours once minutes stop being readable', () => {
|
|
expect(laneStuckFor(lane({ oldest_running_minutes: 195 }))).toBe('3 hours')
|
|
})
|
|
})
|