Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 66 additions & 59 deletions ui/tests/e2e/specs/update.spec.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,103 @@
/**
* update.spec.ts — γ-7 Update flow (PLAN §10.3 path 7).
*
* SCOPE NOTE (from Team G's report): the current `RestartBanner.vue`
* shipped by Team E only renders when
* `system.status.update_available` is true, and its "Apply now"
* handler is a stubbed Phase-1 placeholder (no fetch to
* /api/updates/apply, no polling, no rollback affordance). The
* Team C `/api/updates/*` endpoints exist on the backend.
* Exercises the Wave-3 `RestartBanner.vue` against the
* `/api/updates/{check,apply,status,rollback}` wire contract:
*
* This spec exercises what the UI actually supports today:
* 1. Status without update_available → no banner
* 2. Status flips to update_available:true with a version → banner
* becomes visible and shows the version
* 3. Clicking "Apply now" does not crash (current handler is a
* no-op; the spec asserts the click is wired and the button
* doesn't break the page)
* 4. Status flips back (update_available:false) → banner hides
* (the "rollback applied" surface; cf brief's rollback path —
* the UI doesn't have a dedicated rollback button yet)
* 1. /api/updates/check returns update_available:false → no banner.
* 2. /api/updates/check flips to update_available:true. The banner
* only re-checks on demand (boot + when system.status hints), so
* we flip mockState.status.update_available + refresh the system
* store to trigger the watcher in RestartBanner.
* 3. Click "Apply update" → POST /api/updates/apply returns
* {job_id}; the banner polls /api/updates/status/{job_id} until
* state === 'applied'. The visible message switches to "Update
* applied — restart …".
* 4. Click "Dismiss" → banner hides (the post-apply banner only goes
* away on dismiss; there is no "auto-hide on next status flip"
* affordance in the current UI).
*
* The richer apply-job polling + rollback button flow described in
* the brief belongs to a Wave-3 UI iteration that wires the
* RestartBanner up to `/api/updates/apply` and adds the rollback
* affordance. Marking it as a known gap in the report.
* Rollback (PLAN §10.3): exercised via a second pass — after the apply
* settles, the backend's /check claims `previous_available: true`, which
* surfaces a Rollback button. The spec dismisses instead of rolling
* back to keep wall-clock short, but routes the rollback endpoint so a
* future Wave can flip on that assertion.
*/
import { test, expect, json } from '../fixtures/apiMock'

test('shell banner reflects update_available status transitions', async ({
test('shell banner reflects /api/updates/check transitions', async ({
page,
mockState,
cleanState,
}) => {
// /api/updates/check + apply + status + rollback — register them
// so the spec can be re-aimed once the UI wires them up. Today
// these are not called by the UI.
// Mutable per-phase state for the /api/updates/check route.
let checkResponse: any = {
update_available: false,
current_version: '0.1.0',
latest_version: '0.1.0',
channel: 'stable',
}
let applyCount = 0
let rollbackCount = 0
await page.route('**/api/updates/check', (route) =>
json(route, {
current: '0.1.0',
latest: '0.1.1',
channel: 'stable',
update_available: true,
}),
)
let statusPollCount = 0

await page.route('**/api/updates/check', (route) => json(route, checkResponse))
await page.route('**/api/updates/apply', (route) => {
applyCount += 1
return json(route, { id: 'job-1', state: 'queued' })
return json(route, { job_id: 'job-1' })
})
await page.route('**/api/updates/rollback', (route) => {
rollbackCount += 1
return json(route, { ok: true })
return json(route, { job_id: 'job-2' })
})
await page.route('**/api/updates/status/*', (route) => {
statusPollCount += 1
// Return 'applied' on first poll so the spec doesn't depend on
// wall-clock between polls.
return json(route, { state: 'applied', progress: 100, breadcrumbs: ['done'] })
})
await page.route('**/api/updates/status/*', (route) =>
json(route, { id: 'job-1', state: 'applied' }),
)

// ── Phase 1: no update available → banner hidden ─────────
mockState.status.update_available = false
await page.goto('/')
await expect(page.locator('.restart-banner')).toHaveCount(0)

// ── Phase 2: status flips → banner appears with version ──
//
// RestartBanner's only trigger for re-running /api/updates/check is
// its onMounted hook (the systemUpdateHint watcher guards on
// `!check.value`, so it won't re-fetch once a first check landed).
// Reload the page so the banner re-mounts and picks up the new
// /check response.
checkResponse = {
update_available: true,
current_version: '0.1.0',
latest_version: '0.1.1',
channel: 'stable',
notes_url: 'https://hal0.dev/releases/0.1.1',
}
mockState.status.update_available = true
mockState.status.update_version = '0.1.1'
await page.evaluate(async () => {
const m = await import('/src/stores/system.js')
await m.useSystemStore().fetchStatus()
})
const banner = page.locator('.restart-banner', { hasText: /Update available/ })
await page.reload()

const banner = page.locator('.restart-banner')
await expect(banner).toBeVisible()
await expect(banner).toContainText(/Update available/)
await expect(banner).toContainText('v0.1.1')

// ── Phase 3: click Apply now (current handler is a stub) ──
// The button is wired; clicking it must not throw. When Wave 3
// wires it to POST /api/updates/apply, this assertion auto-fires.
await banner.getByRole('button', { name: /Apply now/ }).click()
// Soft assertion: the apply endpoint is not yet called by the UI
// (Wave 3 work). Document the gap, don't fail the spec.
// expect(applyCount).toBe(1)
expect(applyCount).toBeGreaterThanOrEqual(0)
// ── Phase 3: click "Apply update" → poll → applied ──────
await banner.getByRole('button', { name: /^Apply update$/ }).click()
await expect.poll(() => applyCount).toBe(1)
// Wait for poll → applied → banner message switches.
await expect(banner).toContainText(/Update applied/, { timeout: 5_000 })
expect(statusPollCount).toBeGreaterThanOrEqual(1)

// ── Phase 4: status flips back → banner hides ───────────
mockState.status.update_available = false
await page.evaluate(async () => {
const m = await import('/src/stores/system.js')
await m.useSystemStore().fetchStatus()
})
// ── Phase 4: Dismiss banner ─────────────────────────────
await banner.getByRole('button', { name: /Dismiss banner/ }).click()
await expect(banner).toHaveCount(0)

// rollbackCount is 0 today (no rollback affordance). Wave 3 should
// wire a button into RestartBanner (or a new UpdateCard) and the
// spec can replace this with an actual click + assertion.
// Rollback was not exercised in this pass; record that the route is
// wired but unused. A Wave-4 spec can flip this once the
// post-apply UI surfaces a rollback affordance more prominently.
expect(rollbackCount).toBe(0)
})
Loading