302 lines
10 KiB
JavaScript
302 lines
10 KiB
JavaScript
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)
|
|
})
|