Tray Updates Image Pulls
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-16 03:55:43 -04:00
parent ac10747bad
commit bc6bd8eda3
8 changed files with 2869 additions and 158 deletions
+301
View File
@@ -0,0 +1,301 @@
import test from 'brittle'
import {
PullProgressTracker,
mapStatusToPhase,
formatPullBytes,
beginPullProgress,
endPullProgress,
applyPullProgressEvent,
finalizePullProgress,
clearAllPullProgress,
isGlobalPullStatus,
shortLayerId,
} from '../client/pullProgress.js'
test('mapStatusToPhase covers Docker pull statuses', (t) => {
t.is(mapStatusToPhase('Pulling fs layer'), 'waiting')
t.is(mapStatusToPhase('Waiting'), 'waiting')
t.is(mapStatusToPhase('Downloading'), 'downloading')
t.is(mapStatusToPhase('Download complete'), 'downloaded')
t.is(mapStatusToPhase('Extracting'), 'extracting')
t.is(mapStatusToPhase('Pull complete'), 'done')
t.is(mapStatusToPhase('Already exists'), 'exists')
t.is(mapStatusToPhase('Totally unknown'), null)
})
test('formatPullBytes humanizes sizes', (t) => {
t.is(formatPullBytes(0), '0 B')
t.is(formatPullBytes(500), '500 B')
t.ok(formatPullBytes(1536).includes('KB'))
t.ok(formatPullBytes(5 * 1024 * 1024).includes('MB'))
})
test('isGlobalPullStatus detects image-level lines', (t) => {
t.ok(isGlobalPullStatus('Pulling from library/nginx'))
t.ok(isGlobalPullStatus('Status: Downloaded newer image for nginx:latest'))
t.ok(isGlobalPullStatus('Digest: sha256:abc'))
t.absent(isGlobalPullStatus('Downloading'))
})
test('shortLayerId truncates long ids', (t) => {
t.is(shortLayerId('abc'), 'abc')
t.is(shortLayerId('0123456789abcdef'), '0123456789ab')
})
test('PullProgressTracker aggregates multi-layer download progress', (t) => {
const tracker = new PullProgressTracker('nginx:latest')
tracker.update({ status: 'Pulling from library/nginx' })
let snap = tracker.snapshot()
t.is(snap.phase, 'resolving')
t.is(snap.percent, null)
tracker.update({
id: 'layerA',
status: 'Pulling fs layer',
progressDetail: {},
})
tracker.update({
id: 'layerB',
status: 'Pulling fs layer',
progressDetail: {},
})
// Waiting/pulling layers must still occupy active layer rows (UI stays intact)
snap = tracker.snapshot()
t.is(snap.phase, 'downloading')
t.is(snap.layersTotal, 2)
t.is(snap.layersWaiting, 2)
t.ok(snap.activeLayers.length >= 2, 'waiting layers stay in the layer list')
t.ok(
snap.activeLayers.every((l) => l.phase === 'waiting'),
'queued layers show as waiting'
)
t.ok(
snap.activeLayers.some((l) => /pulling|waiting/i.test(l.meta || '')),
'waiting rows show Pulling… / Waiting… meta'
)
tracker.update({
id: 'layerA',
status: 'Downloading',
progressDetail: { current: 50, total: 100 },
})
tracker.update({
id: 'layerB',
status: 'Downloading',
progressDetail: { current: 0, total: 100 },
})
snap = tracker.snapshot()
t.is(snap.phase, 'downloading')
t.is(snap.layersTotal, 2)
t.is(snap.bytesTotal, 200)
t.is(snap.bytesCurrent, 50)
t.ok(snap.percent != null && snap.percent > 0 && snap.percent < 100)
t.ok(snap.activeLayers.length >= 1)
t.ok(snap.activeLayers.some((l) => l.phase === 'downloading'))
t.ok(snap.summary.includes('layers'))
t.ok(snap.activity && snap.activity.length > 0)
// Finish A, extract B
tracker.update({ id: 'layerA', status: 'Download complete', progressDetail: {} })
tracker.update({ id: 'layerA', status: 'Extracting', progressDetail: { current: 50, total: 100 } })
tracker.update({ id: 'layerA', status: 'Pull complete', progressDetail: {} })
tracker.update({
id: 'layerB',
status: 'Downloading',
progressDetail: { current: 100, total: 100 },
})
tracker.update({ id: 'layerB', status: 'Pull complete', progressDetail: {} })
snap = tracker.snapshot()
t.is(snap.phase, 'complete')
t.is(snap.percent, 100)
t.is(snap.layersDone, 2)
})
test('Already exists layers count as done', (t) => {
const tracker = new PullProgressTracker('alpine:3')
tracker.update({ id: 'x', status: 'Already exists', progressDetail: {} })
tracker.update({ id: 'y', status: 'Already exists', progressDetail: {} })
const snap = tracker.snapshot()
t.is(snap.phase, 'complete')
t.is(snap.layersDone, 2)
t.is(snap.percent, 100)
})
test('layer percent does not jump to overall from a single layer event', (t) => {
const tracker = new PullProgressTracker('big:latest')
tracker.update({
id: 'a',
status: 'Downloading',
progressDetail: { current: 90, total: 100 },
})
tracker.update({
id: 'b',
status: 'Downloading',
progressDetail: { current: 10, total: 1000 },
})
const snap = tracker.snapshot()
// Per-layer 90% must not become overall 90%
t.ok(snap.percent < 80, `expected blended percent, got ${snap.percent}`)
})
test('error events surface phase error', (t) => {
const tracker = new PullProgressTracker('private:1')
tracker.update({ id: 'a', status: 'Downloading', progressDetail: { current: 1, total: 10 } })
tracker.update({ id: 'a', error: 'unauthorized', status: 'Downloading' })
const snap = tracker.snapshot()
t.is(snap.phase, 'error')
t.ok(snap.error.includes('unauthorized'))
})
test('milestones only fire on meaningful transitions', (t) => {
const tracker = new PullProgressTracker('m:1')
// waiting → downloading is a quiet one-shot milestone
let r = tracker.update({
id: 'L1',
status: 'Pulling fs layer',
progressDetail: {},
})
r = tracker.update({
id: 'L1',
status: 'Downloading',
progressDetail: { current: 1, total: 10 },
})
t.ok(r.milestone && r.milestone.startsWith('download-start'))
r = tracker.update({
id: 'L1',
status: 'Downloading',
progressDetail: { current: 5, total: 10 },
})
// Further download ticks are not milestones (UI bar handles them)
t.is(r.milestone, null)
// Layer complete is intentionally quiet (no log spam / tray growth)
r = tracker.update({ id: 'L1', status: 'Pull complete', progressDetail: {} })
t.is(r.milestone, null)
})
test('active pull registry routes events to job ids', (t) => {
clearAllPullProgress()
beginPullProgress('redis:7', 'job-1', 'pull')
const update = applyPullProgressEvent({
image: 'redis:7',
id: 'abc',
status: 'Downloading',
progressDetail: { current: 20, total: 100 },
})
t.ok(update)
t.is(update.jobId, 'job-1')
t.is(update.stepId, 'pull')
t.is(update.snapshot.kind, 'image-pull')
t.ok(update.snapshot.percent != null)
const final = finalizePullProgress('redis:7', { ok: true })
t.ok(final)
t.is(final.snapshot.phase, 'complete')
t.absent(applyPullProgressEvent({ image: 'redis:7', status: 'Downloading', id: 'x' }))
endPullProgress('redis:7')
})
test('markComplete fills remaining layers', (t) => {
const tracker = new PullProgressTracker('x')
tracker.update({ id: 'a', status: 'Downloading', progressDetail: { current: 1, total: 2 } })
const snap = tracker.markComplete()
t.is(snap.phase, 'complete')
t.is(snap.percent, 100)
})
test('extract phase sticks across short gaps between layer unpacks', (t) => {
const tracker = new PullProgressTracker('sticky:1')
// Docker usually announces layers first, then download/extract
tracker.update({ id: 'a', status: 'Pulling fs layer', progressDetail: {} })
tracker.update({ id: 'b', status: 'Pulling fs layer', progressDetail: {} })
tracker.update({ id: 'a', status: 'Downloading', progressDetail: { current: 100, total: 100 } })
tracker.update({ id: 'a', status: 'Extracting', progressDetail: { current: 1, total: 10 } })
t.is(tracker.snapshot().phase, 'extracting')
// Layer a finishes extract; gap before b extracts — must not flip back to downloading
tracker.update({ id: 'a', status: 'Pull complete', progressDetail: {} })
tracker.update({ id: 'b', status: 'Downloading', progressDetail: { current: 50, total: 100 } })
const mid = tracker.snapshot()
t.is(mid.phase, 'extracting')
t.ok(mid.activity.includes('Unpacking'))
// Activity should not embed short-lived layer ids during extract
t.absent(/Extracting layer /i.test(mid.activity))
})
test('overall percent is monotonic', (t) => {
const tracker = new PullProgressTracker('mono:1')
tracker.update({ id: 'a', status: 'Downloading', progressDetail: { current: 80, total: 100 } })
const p1 = tracker.snapshot().percent
tracker.update({ id: 'a', status: 'Downloading', progressDetail: { current: 20, total: 100 } })
const p2 = tracker.snapshot().percent
t.ok(p1 != null && p2 != null)
t.ok(p2 >= p1, `expected ${p2} >= ${p1}`)
})
test('extract progressDetail does not clobber download byte totals', (t) => {
const tracker = new PullProgressTracker('bytes:1')
tracker.update({
id: 'big',
status: 'Downloading',
progressDetail: { current: 50_000_000, total: 100_000_000 },
})
tracker.update({ id: 'big', status: 'Download complete', progressDetail: {} })
const afterDl = tracker.snapshot()
t.ok(afterDl.bytesTotal >= 100_000_000, `expected download total kept, got ${afterDl.bytesTotal}`)
t.ok(afterDl.summary.includes('MB') || afterDl.summary.includes('GB') || afterDl.bytesTotal > 1_000_000)
// Docker extract events often report tiny current/total unrelated to download size
tracker.update({
id: 'big',
status: 'Extracting',
progressDetail: { current: 1, total: 10 },
})
const mid = tracker.snapshot()
t.is(mid.phase, 'extracting')
t.ok(mid.bytesTotal >= 100_000_000, `extract must not shrink bytesTotal, got ${mid.bytesTotal}`)
t.ok(mid.bytesCurrent >= 100_000_000, `extract should count layer as fully downloaded`)
// Summary should not flash "1 B / 10 B"
t.absent(/\b10 B\b/.test(mid.summary))
t.ok(mid.layerPips.length >= 1)
t.ok(mid.layerPips.includes('extract') || mid.layerPips.includes('done'))
})
test('layer pips stay fixed-height friendly across extract bursts', (t) => {
const tracker = new PullProgressTracker('pips:1')
for (const id of ['a', 'b', 'c', 'd']) {
tracker.update({ id, status: 'Pulling fs layer', progressDetail: {} })
}
const resolving = tracker.snapshot()
t.ok(resolving.layerPips.length >= 4)
t.ok(resolving.layerPips.every((p) => p === 'wait' || p === 'idle'))
tracker.update({
id: 'a',
status: 'Downloading',
progressDetail: { current: 10, total: 100 },
})
t.ok(tracker.snapshot().layerPips.includes('download'))
for (const id of ['a', 'b', 'c', 'd']) {
tracker.update({
id,
status: 'Downloading',
progressDetail: { current: 100, total: 100 },
})
tracker.update({ id, status: 'Download complete', progressDetail: {} })
tracker.update({ id, status: 'Extracting', progressDetail: { current: 5, total: 10 } })
tracker.update({ id, status: 'Pull complete', progressDetail: {} })
}
const done = tracker.snapshot()
t.is(done.phase, 'complete')
t.ok(done.layerPips.every((p) => p === 'done'))
t.is(done.percent, 100)
})