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) })