This commit is contained in:
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user