A delayed browser could miss the 900ms terminal event and spin forever after the updater exited. Retain terminal delivery until the page acknowledges it, bound unavailable-client teardown and failed requests, and preserve a truthful final display. Fixes #103747. Builds on OutThisLife and Teknium detached handoff work in #83634 and the #75895 quiet-window design. Continues Axl Ibiza Windows update investigation (#60233, #94107, #100763), including source/review contributions carried by merged #93353 and #85170. Existing #102373, #103140, #95719, #97299 and #103632 retain their separate scopes.
101 lines
4.3 KiB
JavaScript
101 lines
4.3 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import { JSDOM } from 'jsdom'
|
|
import { afterEach, test, vi } from 'vitest'
|
|
|
|
// Execute the shipped page, including its inline script, rather than matching
|
|
// source strings or testing a second implementation of the progress client.
|
|
const html = fs.readFileSync(new URL('../../../scripts/desktop-update/ui.html', import.meta.url), 'utf8')
|
|
const windows = []
|
|
|
|
function openPage(fetch) {
|
|
vi.useFakeTimers()
|
|
const dom = new JSDOM(html, {
|
|
url: 'http://127.0.0.1:12345/',
|
|
runScripts: 'dangerously',
|
|
beforeParse(window) {
|
|
window.fetch = fetch
|
|
window.AbortController = AbortController
|
|
window.setTimeout = setTimeout
|
|
window.clearTimeout = clearTimeout
|
|
window.requestAnimationFrame = () => 1
|
|
window.cancelAnimationFrame = () => {}
|
|
}
|
|
})
|
|
windows.push(dom.window)
|
|
return dom.window.document
|
|
}
|
|
|
|
afterEach(() => {
|
|
windows.splice(0).forEach(window => window.close())
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
test.each(['done', 'manual', 'error'])('renders %s before acknowledging terminal delivery', async status => {
|
|
let document
|
|
const requests = []
|
|
const receipt = '550e8400-e29b-41d4-a716-446655440000'
|
|
const fetch = vi.fn(async (url, options) => {
|
|
requests.push(url)
|
|
if (url.startsWith('/ack/')) {
|
|
assert.equal(options.method, 'POST')
|
|
assert.equal(document.body.className, status === 'error' ? 'error' : 'done')
|
|
assert.notEqual(document.getElementById('title').textContent, 'Updating Hermes')
|
|
return { ok: true }
|
|
}
|
|
return { ok: true, json: async () => ({ status, receipt, message: 'The updater result' }) }
|
|
})
|
|
document = openPage(fetch)
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
assert.equal(document.body.className, status === 'error' ? 'error' : 'done')
|
|
assert.notEqual(document.getElementById('title').textContent, 'Updating Hermes')
|
|
assert.deepEqual(requests, ['/progress', `/ack/${receipt}`])
|
|
})
|
|
|
|
test.each(['disconnect', 'hung', 'hung-body', 'http', 'invalid'])('bounds %s progress failures without inventing an update outcome', async failure => {
|
|
let attempts = 0
|
|
const fetch = vi.fn((_url, options) => {
|
|
attempts++
|
|
if (attempts === 1) {
|
|
return Promise.resolve({ ok: true, json: async () => ({ status: 'running', message: 'Installing dependencies' }) })
|
|
}
|
|
if (failure === 'hung' || failure === 'hung-body') {
|
|
const pending = () => new Promise((_resolve, reject) => {
|
|
options.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true })
|
|
})
|
|
return failure === 'hung' ? pending() : Promise.resolve({ ok: true, json: pending })
|
|
}
|
|
if (failure === 'http') return Promise.resolve({ ok: false })
|
|
if (failure === 'invalid') return Promise.resolve({ ok: true, json: async () => ({}) })
|
|
return Promise.reject(new Error('connection refused'))
|
|
})
|
|
const document = openPage(fetch)
|
|
await vi.advanceTimersByTimeAsync(20_000)
|
|
assert.equal(document.body.className, 'disconnected')
|
|
assert.equal(document.getElementById('title').textContent, 'Update status unavailable')
|
|
assert.match(document.getElementById('line').textContent, /Check Hermes/)
|
|
assert.ok(attempts <= 4, `unbounded retry loop: ${attempts}`)
|
|
})
|
|
|
|
test.each(['legacy', 'transient', 'ack-failure'])('preserves terminal truth with %s servers', async mode => {
|
|
let attempts = 0
|
|
const fetch = vi.fn(async url => {
|
|
if (url.startsWith('/ack/')) throw new Error('server already stopped')
|
|
if (++attempts === 1 && mode === 'transient') throw new Error('temporary disconnect')
|
|
return { ok: true, json: async () => ({ status: 'done', ...(mode === 'ack-failure' ? { receipt: 'test-receipt' } : {}) }) }
|
|
})
|
|
const document = openPage(fetch)
|
|
await vi.advanceTimersByTimeAsync(20_000)
|
|
assert.equal(document.body.className, 'done')
|
|
assert.equal(document.getElementById('title').textContent, 'Update complete')
|
|
})
|
|
|
|
test('continues displaying a healthy long update while progress remains reachable', async () => {
|
|
const fetch = vi.fn(async () => ({ ok: true, json: async () => ({ status: 'running', message: 'Building Desktop' }) }))
|
|
const document = openPage(fetch)
|
|
await vi.advanceTimersByTimeAsync(60_000)
|
|
assert.equal(document.body.className, '')
|
|
assert.equal(document.getElementById('title').textContent, 'Updating Hermes')
|
|
assert.equal(document.getElementById('line').textContent, 'Building Desktop')
|
|
})
|