fix(v0.5.1): 适配rc.5并修正控制室与更新提醒
This commit is contained in:
@@ -9,7 +9,7 @@ const { buildSync } = require('esbuild')
|
||||
const code = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/sessionCompat.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text
|
||||
const module = { exports: {} }
|
||||
runInNewContext(code, { module, exports: module.exports, setTimeout })
|
||||
const { createSessionSnapshotAdapter, sendHostSession, childSessionIdsOf, pendingAckOf, sessionRuntimeMs } = module.exports
|
||||
const { createSessionSnapshotAdapter, sendHostSession, childSessionIdsOf, pendingAckOf, sessionRuntimeMs, consoleStatusOf } = module.exports
|
||||
const list = (running = false) => ({ phase: 'ready', ids: ['a'], byId: { a: { id: 'a', running, retainedBy: { mainView: 1 } } }, projectionsBySession: {} })
|
||||
const source = (patch = {}) => ({ getSnapshot: () => new Map([['a', { running: false, pendingInteraction: undefined, completionUnread: false, ...patch }]]) })
|
||||
|
||||
@@ -58,6 +58,17 @@ test('ready removal clears run memory; transient pending list does not', () => {
|
||||
adapt({ phase: 'ready', byId: {} })
|
||||
assert.equal(adapt(list()).byId.a.completed, false)
|
||||
})
|
||||
test('console run-stop is neutral ended status even when the host reports an error', () => {
|
||||
assert.equal(consoleStatusOf({ completed: true, running: false, error: { code: 'INVALID_REQUEST' } }, null), 'done')
|
||||
assert.equal(consoleStatusOf({ running: false }, null, { completed: true }), 'done')
|
||||
assert.equal(consoleStatusOf({ running: false }, null), 'idle')
|
||||
assert.equal(consoleStatusOf(undefined, null), 'idle')
|
||||
})
|
||||
test('console running overrides a stale completion, while pending stays first', () => {
|
||||
assert.equal(consoleStatusOf({ completed: true, running: true }, null), 'busy')
|
||||
assert.equal(consoleStatusOf({ running: true }, null, { completed: true }), 'busy')
|
||||
assert.equal(consoleStatusOf({ completed: true, running: true }, 'need:fixture'), 'need')
|
||||
})
|
||||
function sendFixture(outcome = { kind: 'success' }) {
|
||||
const calls = []
|
||||
const face = { prompt() { assert.fail('must not resend through prompt') } }
|
||||
|
||||
@@ -10,7 +10,7 @@ const { buildSync, transformSync } = require('esbuild')
|
||||
const code = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/sessionDetails.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs' }).outputFiles[0].text
|
||||
const module = { exports: {} }
|
||||
runInNewContext(code, { module, exports: module.exports })
|
||||
const { cleanPreviewText, previewFromEvents, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } = module.exports
|
||||
const { cleanPreviewText, previewFromEvents, previewFromNodes, readSessionPreview, presetApiOf, modelApiOf, createHostSession, blankSessionNeedsWorkspace } = module.exports
|
||||
const msg = (text, type = 'assistant/message') => ({ type: 'event', event: { type, data: { content: [{ type: 'text', text }] } } })
|
||||
|
||||
test('preview filters code and joins final text blocks, without mutating events', () => {
|
||||
@@ -24,12 +24,47 @@ test('reasoning, tools and transient chunks never become a preview', () => {
|
||||
const entries = [msg('A readable earlier message'), { event: { type: 'assistant/message', data: { content: [{ type: 'reasoning', text: 'private reasoning' }] } } }, msg('tool argument', 'tool/call'), msg('stream token', 'assistant/live-chunk')]
|
||||
assert.equal(previewFromEvents(entries), 'A readable earlier message')
|
||||
})
|
||||
test('short/code-only latest message falls back, malformed input stays empty, length bounded', () => {
|
||||
test('code-only latest message falls back, malformed input stays empty, length bounded', () => {
|
||||
assert.equal(previewFromEvents([msg('Earlier readable message'), msg('```js\ncode\n```')]), 'Earlier readable message')
|
||||
assert.equal(previewFromEvents(null), '')
|
||||
assert.equal(previewFromEvents([{ event: { type: 'user/message', data: { content: 'not blocks' } } }]), '')
|
||||
assert.equal(previewFromEvents([msg('x'.repeat(300))]).length, 220)
|
||||
})
|
||||
test('preview uses assistant replies instead of later user or injected context', () => {
|
||||
const entries = [msg('Actual assistant answer'), msg('A real new question', 'user/message'),
|
||||
{ event: { type: 'user/message', data: { source: { kind: 'runtime-context' }, content: [{ type: 'text', text: 'Time sampled while preparing turn: injected context' }] } } }]
|
||||
const before = JSON.stringify(entries)
|
||||
assert.equal(previewFromEvents(entries), 'Actual assistant answer')
|
||||
assert.equal(previewFromEvents(entries.slice(1)), '')
|
||||
assert.equal(JSON.stringify(entries), before)
|
||||
})
|
||||
test('short assistant replies are retained, not replaced by earlier text', () => {
|
||||
assert.equal(previewFromEvents([msg('An earlier long answer'), msg('好了')]), '好了')
|
||||
})
|
||||
test('model-only replacement messages never replace the visible assistant preview', () => {
|
||||
const replacement = msg('Internal rewritten model-only text')
|
||||
replacement.event.surfaceOp = { op: 'replace', startSeq: 1, endSeq: 2 }
|
||||
assert.equal(previewFromEvents([msg('Visible answer'), replacement]), 'Visible answer')
|
||||
assert.equal(previewFromEvents([replacement]), '')
|
||||
const appended = msg('Visible appended reply')
|
||||
appended.event.surfaceOp = 'append'
|
||||
assert.equal(previewFromEvents([appended]), 'Visible appended reply')
|
||||
})
|
||||
|
||||
test('legacy nodes include only assistant text blocks, never context or reasoning', () => {
|
||||
const nodes = [{ kind: 'assistant', blocks: [{ kind: 'text', text: 'Earlier reply' }] },
|
||||
{ kind: 'assistant', blocks: [{ kind: 'reasoning', text: 'private reasoning' }, { kind: 'text', text: '好了' }] },
|
||||
{ kind: 'context', blocks: [{ kind: 'text', text: 'Injected auxiliary context' }] },
|
||||
{ kind: 'user', blocks: [{ kind: 'text', text: 'Latest question' }] },
|
||||
{ blocks: [{ type: 'text', text: 'Unknown node, must not guess' }] },
|
||||
{ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: 'Interrupted unfinished reply' }] }]
|
||||
const before = JSON.stringify(nodes)
|
||||
assert.equal(previewFromNodes(nodes), '好了')
|
||||
assert.equal(previewFromNodes(nodes.slice(2)), '')
|
||||
assert.equal(previewFromNodes(null), '')
|
||||
assert.equal(JSON.stringify(nodes), before)
|
||||
})
|
||||
|
||||
function held(binding, action) {
|
||||
const calls = []
|
||||
let active = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* WorktableSection popup regression against the actual client bundle.
|
||||
* Sidebar popup and control-room presentation regressions against the actual client bundle.
|
||||
* Usage: node 04_test/sidebar-popup.test.mjs [lib/client.js]
|
||||
* The small React hook/JSX harness runs the exported component and its event handlers;
|
||||
* it does not claim browser layout or desktop GUI coverage.
|
||||
@@ -77,7 +77,7 @@ function fakeElement(tagName = 'div') {
|
||||
}
|
||||
}
|
||||
|
||||
function loadComponent() {
|
||||
function loadComponent({ stored = {}, fetch: fetchFixture } = {}) {
|
||||
const values = new Map([
|
||||
['dsh.worktable.updateCheck.v1', '0'],
|
||||
['dsh.worktable.projects.v1', JSON.stringify({
|
||||
@@ -90,6 +90,7 @@ function loadComponent() {
|
||||
}],
|
||||
})],
|
||||
])
|
||||
for (const [key, value] of Object.entries(stored)) values.set(key, String(value))
|
||||
const localStorage = {
|
||||
getItem: (key) => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
@@ -132,7 +133,7 @@ function loadComponent() {
|
||||
console,
|
||||
atob,
|
||||
btoa,
|
||||
fetch: async () => ({ ok: false, status: 404, json: async () => ({}) }),
|
||||
fetch: fetchFixture ?? (async () => ({ ok: false, status: 404, json: async () => ({}) })),
|
||||
setTimeout: window.setTimeout,
|
||||
clearTimeout: window.clearTimeout,
|
||||
setInterval: window.setInterval,
|
||||
@@ -147,7 +148,12 @@ function loadComponent() {
|
||||
indexedDB: undefined,
|
||||
}
|
||||
createContext(sandbox)
|
||||
runInNewContext(source, sandbox, { filename: BUNDLE, timeout: 20000 })
|
||||
// Expose existing bundle locals in memory only; the delivered module stays unchanged.
|
||||
const footer = 'return module.exports; } });'
|
||||
assert(source.split(footer).length === 2, 'client bundle footer must be unique')
|
||||
const instrumented = source.replace(footer,
|
||||
'Object.defineProperty(module.exports, "__consoleTest", { value: Object.freeze({ ConsolePane, setSplitEnv, setSplitT, zh, en, css, sessionNotifyState, LOCAL_VERSION, cmpVer, readCache }) });\n' + footer)
|
||||
runInNewContext(instrumented, sandbox, { filename: BUNDLE, timeout: 20000 })
|
||||
assert(typeof window.captured?.factory === 'function', 'client bundle did not register a factory')
|
||||
const requireExternal = (id) => {
|
||||
if (id === 'react') return hooks.React
|
||||
@@ -158,7 +164,7 @@ function loadComponent() {
|
||||
}
|
||||
const exports = window.captured.factory(requireExternal)
|
||||
assert(typeof exports.WorktableSection === 'function', 'bundle did not export WorktableSection')
|
||||
return { component: exports.WorktableSection, hooks, window, values }
|
||||
return { component: exports.WorktableSection, hooks, window, values, consoleTest: exports.__consoleTest }
|
||||
}
|
||||
|
||||
function walk(node, predicate, out = []) {
|
||||
@@ -241,4 +247,149 @@ async function main() {
|
||||
console.log('all sidebar-popup tests passed: add, icon, binding, delete confirmation, wide/collapsed switching')
|
||||
}
|
||||
|
||||
function testConsolePresentation() {
|
||||
for (const [language, ended] of [['zh', '本轮已结束'], ['en', 'Run ended']]) {
|
||||
const { hooks, consoleTest: api } = loadComponent()
|
||||
const dictionary = api[language]
|
||||
const actions = []
|
||||
const cards = ['done', 'need', 'busy'].map((status) => ({
|
||||
id: status, name: status, icon: '•', status, runtimeMs: null, kids: 0,
|
||||
preview: 'Actual assistant reply', bound: true, self: false, glow: status !== 'busy',
|
||||
}))
|
||||
api.setSplitT((key) => dictionary[key] ?? key)
|
||||
api.setSplitEnv({ console: {
|
||||
getCards: () => cards, getTheme: () => 'dark', getBg: () => 'plain',
|
||||
subscribe: () => () => {}, getPhotoLib: async () => ({ list: [], activeId: null }),
|
||||
onAck: (id) => actions.push('ack:' + id), onOpen: (id) => actions.push('open:' + id),
|
||||
} })
|
||||
let tree = hooks.render(api.ConsolePane, {})
|
||||
for (const status of ['done', 'need', 'busy']) {
|
||||
const labels = byClass(tree, 'dsh-wt_consoleStatus-' + status)
|
||||
const expected = status === 'done' ? ended : dictionary['console.' + status]
|
||||
assert(labels.length === 1 && labels[0].props.children === expected, language + ' incorrect ' + status + ' label')
|
||||
}
|
||||
const tip = dictionary['bind.tipDone']
|
||||
assert(tip.includes(language === 'zh' ? ended : 'run ended'), language + ' sidebar tip does not describe a run ending')
|
||||
assert(!/已完成|成功|completed|success/i.test(tip), language + ' sidebar tip still claims success')
|
||||
assert(byClass(tree, 'dsh-wt_consoleCard-glowDone').length === 1, 'unread end reminder was lost')
|
||||
assert(byClass(tree, 'dsh-wt_consoleCard-glowNeed').length === 1, 'pending reminder was lost')
|
||||
assert(byClass(tree, 'dsh-wt_consoleCard-busy').length === 1, 'working indicator was lost')
|
||||
assert(byClass(tree, 'dsh-wt_consoleSweep').length === 1, 'working animation was lost')
|
||||
const previews = byClass(tree, 'dsh-wt_consolePreview')
|
||||
assert(previews.length === 3 && previews.every((node) => node.props.children === 'Actual assistant reply'), 'card preview was missing or replaced')
|
||||
click(byClass(tree, 'dsh-wt_consoleCard-glowDone')[0])
|
||||
assert(actions.join(',') === 'ack:done,open:done', 'ended card must acknowledge before opening')
|
||||
cards[0].glow = false
|
||||
tree = hooks.render(api.ConsolePane, {})
|
||||
assert(byClass(tree, 'dsh-wt_consoleCard-glowDone').length === 0, 'acknowledged end still glows')
|
||||
assert(byClass(tree, 'dsh-wt_consoleStatus-done')[0].props.children === ended, 'acknowledgement erased ended status')
|
||||
|
||||
const rule = (selector) => {
|
||||
const start = api.css.indexOf(selector + '{')
|
||||
assert(start >= 0, 'missing bundled CSS rule: ' + selector)
|
||||
return api.css.slice(start + selector.length + 1, api.css.indexOf('}', start))
|
||||
}
|
||||
for (const selector of ['.dsh-wt_bindBtn[data-bound=done]', '.dsh-wt_consoleStatus-done', '.dsh-wt_consoleCard-glowDone']) {
|
||||
assert(!/state-success|#3fb950|rgba?\(63,\s*185,\s*80/i.test(rule(selector)), 'ended reminder still uses success green: ' + selector)
|
||||
}
|
||||
assert(rule('.dsh-wt_bindBtn[data-bound=need]').includes('#d29922'), 'sidebar pending color changed')
|
||||
assert(rule('.dsh-wt_bindBtn[data-bound=busy]').includes('#4f8ef7'), 'sidebar working color changed')
|
||||
assert(rule('.dsh-wt_consoleStatus-need').includes('state-warning'), 'control-room pending color changed')
|
||||
assert(rule('.dsh-wt_consoleStatus-busy').includes('state-accent-primary'), 'control-room working color changed')
|
||||
assert(api.sessionNotifyState({ running: true, completed: true }) === null, 'stale end must not hide a running sidebar session')
|
||||
assert(api.sessionNotifyState({ running: true, completed: true, pendingInteraction: { key: 'fixture' } }) === 'need', 'pending must remain first')
|
||||
assert(api.sessionNotifyState({ running: false, completed: true }) === 'done', 'run ending lost its existing ack kind')
|
||||
}
|
||||
console.log('all control-room presentation tests passed: zh/en, neutral end, need/busy, preview, acknowledgement')
|
||||
}
|
||||
|
||||
async function testUpdatePresentation() {
|
||||
const releaseUrl = 'https://api.github.com/repos/Aisland-SJL/dsh-worktable/releases/latest'
|
||||
for (const outcome of ['older', 'same', 'forbidden', 'invalid', 'invalid-prefix', 'skip']) {
|
||||
let response = { status: 200, tag: 'v9.0.0' }
|
||||
let requests = 0
|
||||
const { component, hooks, values, consoleTest: api } = loadComponent({ fetch: async (url) => {
|
||||
assert(url === releaseUrl, 'unexpected request in update fixture: ' + url)
|
||||
requests++
|
||||
return { ok: response.status === 200, status: response.status,
|
||||
json: async () => ({ tag_name: response.tag, body: 'Fixture release', html_url: 'https://example.invalid/release' }) }
|
||||
} })
|
||||
assert(api.cmpVer('0.5.0', api.LOCAL_VERSION) < 0, 'bundled comparator does not reject the old release')
|
||||
const render = () => hooks.render(component, { wide: true, t: (key) => key })
|
||||
let tree = render()
|
||||
click(walk(tree, (node) => node.type === 'button' && node.props?.['aria-label'] === 'menu.viewOptions')[0])
|
||||
tree = render()
|
||||
const check = async () => {
|
||||
click(byClass(byClass(tree, 'dsh-wt_versionRow')[0], 'dsh-wt_updateBtn')[0])
|
||||
tree = render()
|
||||
assert(byClass(byClass(tree, 'dsh-wt_versionRow')[0], 'dsh-wt_updateBtn')[0].props.disabled, 'check button did not enter checking state')
|
||||
await new Promise(setImmediate)
|
||||
tree = render()
|
||||
}
|
||||
await check()
|
||||
assert(requests === 1, 'manual check did not issue one fixture request')
|
||||
assert(byClass(tree, 'dsh-wt_updateCard').length === 1 && byClass(tree, 'dsh-wt_updateBadge').length === 1, 'genuine newer release did not show card and badge')
|
||||
assert(JSON.stringify(byClass(tree, 'dsh-wt_updateVers')[0].props.children).includes('9.0.0'), 'newer release label missing')
|
||||
if (outcome === 'skip') {
|
||||
click(walk(tree, (node) => node.type === 'button' && node.props?.children === 'update.skip')[0])
|
||||
tree = render()
|
||||
assert(values.get('dsh.worktable.skipVersion.v1') === '9.0.0', 'skip button did not persist the shown release')
|
||||
assert(byClass(tree, 'dsh-wt_updateCard').length === 0 && byClass(tree, 'dsh-wt_updateBadge').length === 0, 'skip button left visible update UI')
|
||||
} else {
|
||||
response = outcome === 'forbidden' ? { status: 403 } : { status: 200,
|
||||
tag: outcome === 'older' ? 'v0.5.0' : outcome === 'same' ? 'v' + api.LOCAL_VERSION : outcome === 'invalid-prefix' ? 'vv9.0.0' : 'not-a-version' }
|
||||
}
|
||||
await check()
|
||||
assert(requests === 2, outcome + ': unexpected retries or skipped manual request')
|
||||
for (const name of ['dsh-wt_updateCard', 'dsh-wt_updateBadge', 'dsh-wt_updateBtnCopy']) {
|
||||
assert(byClass(tree, name).length === 0, outcome + ': stale update UI survived: ' + name)
|
||||
}
|
||||
if (['forbidden', 'invalid', 'invalid-prefix'].includes(outcome)) {
|
||||
assert(JSON.stringify(byClass(tree, 'dsh-wt_versionRow')[0].props.children).includes('update.checkFail'), 'failed check was hidden by old update info')
|
||||
}
|
||||
}
|
||||
|
||||
const localVersion = loadComponent().consoleTest.LOCAL_VERSION
|
||||
for (const [label, latest, skipped, shown] of [
|
||||
['older', '0.5.0', '', false], ['same', localVersion, '', false],
|
||||
['invalid', 'not-a-version', '', false], ['skipped', '9.0.0', '9.0.0', false],
|
||||
['newer', '9.0.0', '', true],
|
||||
]) {
|
||||
let requests = 0
|
||||
const { hooks, values, consoleTest: api } = loadComponent({
|
||||
stored: {
|
||||
'dsh.worktable.updateCheck.v1': '0',
|
||||
'dsh.worktable.lastUpdateCheck.v1': String(Date.now()),
|
||||
'dsh.worktable.skipVersion.v1': skipped,
|
||||
'dsh.worktable.updateCache.v1': JSON.stringify({ status: 'uptodate', info: { latest, notes: 'Fixture cache', url: 'https://example.invalid/release' } }),
|
||||
},
|
||||
fetch: async () => { requests++; return { ok: false, status: 404 } },
|
||||
})
|
||||
api.setSplitT((key) => key)
|
||||
api.setSplitEnv({ console: {
|
||||
getCards: () => [], getTheme: () => 'dark', getBg: () => 'plain',
|
||||
subscribe: () => () => {}, getPhotoLib: async () => ({ list: [], activeId: null }),
|
||||
} })
|
||||
const render = () => hooks.render(api.ConsolePane, {})
|
||||
let tree = render()
|
||||
assert(byClass(tree, 'dsh-wt_dockBadge').length === Number(shown), label + ': wrong cached dock badge')
|
||||
click(walk(tree, (node) => node.type === 'button' && node.props?.['aria-label'] === 'annot.updateTitle')[0])
|
||||
tree = render()
|
||||
for (const name of ['dsh-wt_announceUpdate', 'dsh-wt_announceUpg']) {
|
||||
assert(byClass(tree, name).length === Number(shown), label + ': wrong cached announcement: ' + name)
|
||||
}
|
||||
assert((api.readCache().info != null) === shown, label + ': cache reader still exposes an ineligible release')
|
||||
if (shown) {
|
||||
values.set('dsh.worktable.skipVersion.v1', latest)
|
||||
tree = render()
|
||||
assert(byClass(tree, 'dsh-wt_announceUpdate').length === 0 && byClass(tree, 'dsh-wt_dockBadge').length === 0, 'already-mounted control room retained ignored update info')
|
||||
}
|
||||
await new Promise(setImmediate)
|
||||
assert(requests === 0, label + ': disabled automatic update check made a request')
|
||||
}
|
||||
console.log('all update presentation tests passed: settings transitions and control-room cache, no network')
|
||||
}
|
||||
|
||||
await main()
|
||||
testConsolePresentation()
|
||||
await testUpdatePresentation()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { createRequire } from 'node:module'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const require = createRequire(new URL('../01_content/package.json', import.meta.url))
|
||||
const { buildSync } = require('esbuild')
|
||||
const LOCAL = '0.5.1-dev.2'
|
||||
const code = buildSync({ entryPoints: [fileURLToPath(new URL('../01_content/src/client/updateCheck.ts', import.meta.url))], bundle: true, write: false, platform: 'node', format: 'cjs', define: { __WT_VERSION__: JSON.stringify(LOCAL) } }).outputFiles[0].text
|
||||
const CACHE = 'dsh.worktable.updateCache.v1', LAST = 'dsh.worktable.lastUpdateCheck.v1', SKIP = 'dsh.worktable.skipVersion.v1'
|
||||
function fixture(reply = { tag_name: 'v0.5.0' }) {
|
||||
const values = new Map(), calls = [], module = { exports: {} }
|
||||
let response = reply
|
||||
const localStorage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)) }
|
||||
runInNewContext(code, { module, exports: module.exports, localStorage, AbortController, setTimeout, clearTimeout,
|
||||
fetch: async (url, options) => {
|
||||
calls.push([url, options])
|
||||
if (response instanceof Error) throw response
|
||||
return { ok: !response?.http, status: response?.http ?? 200, json: async () => response }
|
||||
},
|
||||
})
|
||||
return { api: module.exports, values, calls, localStorage, respond: value => { response = value } }
|
||||
}
|
||||
const info = latest => ({ latest, notes: 'Release notes', url: 'https://github.com/Aisland-SJL/dsh-worktable/releases/tag/v' + latest })
|
||||
const { cmpVer } = fixture().api
|
||||
|
||||
test('version precedence covers old release, candidate, stable, numeric prerelease and build metadata', () => {
|
||||
const pairs = [
|
||||
['0.5.0', LOCAL, -1], [LOCAL, '0.5.1', -1], ['0.5.1-dev.2', '0.5.1-dev.10', -1],
|
||||
['0.5.1-alpha', '0.5.1-alpha.1', -1], ['0.5.1-alpha.1', '0.5.1-alpha.beta', -1],
|
||||
['0.5.1-beta.11', '0.5.1-rc.1', -1], ['0.5.1-1', '0.5.1-alpha', -1],
|
||||
['0.5.1-A', '0.5.1-a', -1], ['v0.5.1', '0.5.1', 0],
|
||||
['0.5.1+build.9', '0.5.1+build.10', 0], ['0.5.1-dev.2+one', LOCAL, 0],
|
||||
['99999999999999999999.0.0', '99999999999999999998.9.9', 1],
|
||||
['0.5.1-dev.99999999999999999999', '0.5.1-dev.99999999999999999998', 1],
|
||||
]
|
||||
for (const [a, b, expected] of pairs) {
|
||||
assert.equal(cmpVer(a, b), expected, a + ' vs ' + b)
|
||||
assert.equal(cmpVer(b, a), expected === 0 ? 0 : -expected)
|
||||
}
|
||||
})
|
||||
test('invalid versions are explicitly incomparable, never NaN or an upgrade', () => {
|
||||
for (const bad of ['', 'dev', '0.5', '01.5.1', '0.5.1-dev.02', '0.5.1-', '0.5.1+','0.5.1-dev..2', 'v0.5.1x', 'vv0.5.1', '0.5.1.2', 42, null]) {
|
||||
assert.equal(cmpVer(bad, LOCAL), null, String(bad))
|
||||
assert.equal(cmpVer(LOCAL, bad), null, String(bad))
|
||||
}
|
||||
})
|
||||
test('cache rejects older, equal and malformed update info without touching unrelated state', () => {
|
||||
const f = fixture(); f.values.set('fixture.project', 'keep')
|
||||
for (const value of [info('0.5.0'), info(LOCAL), info('not-a-version'), { latest: '9.0.0' }, ['9.0.0'], null]) {
|
||||
f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: value }))
|
||||
assert.equal(f.api.readCache().info, null)
|
||||
}
|
||||
f.values.set(CACHE, '{broken json')
|
||||
assert.equal(f.api.readCache().info, null)
|
||||
f.values.set(CACHE, JSON.stringify({ status: 'made-up', info: info('9.0.0') }))
|
||||
assert.equal(f.api.readCache().status, 'idle')
|
||||
assert.equal(f.values.get('fixture.project'), 'keep')
|
||||
})
|
||||
test('newer cache remains usable but rechecks a later skip on every read', () => {
|
||||
const f = fixture(); f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('9.0.0') }))
|
||||
assert.equal(f.api.readCache().info.latest, '9.0.0')
|
||||
f.api.setSkipVersion('v9.0.0')
|
||||
assert.equal(f.api.readCache().info, null)
|
||||
})
|
||||
test('24-hour throttle and disabled automatic checks cannot resurrect an old cache', async () => {
|
||||
const f = fixture()
|
||||
f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('0.5.0') }))
|
||||
f.values.set(LAST, String(Date.now())); f.values.set('dsh.worktable.updateCheck.v1', '0')
|
||||
assert.equal(f.api.getAutoCheck(), false)
|
||||
assert.equal(f.api.readCache().info, null)
|
||||
assert.equal((await f.api.checkUpdate()).info, null)
|
||||
assert.equal(f.calls.length, 0)
|
||||
})
|
||||
test('a real stable release above this candidate is offered, notes are bounded', async () => {
|
||||
const f = fixture({ tag_name: 'v0.5.1', body: 'x'.repeat(1000), html_url: info('0.5.1').url })
|
||||
const result = await f.api.checkUpdate(true)
|
||||
assert.equal(result.status, 'uptodate'); assert.equal(result.info.latest, '0.5.1'); assert.equal(result.info.notes.length, 800)
|
||||
assert.equal(f.calls.length, 1)
|
||||
assert.equal(f.api.readCache().info.latest, '0.5.1')
|
||||
})
|
||||
test('older, equal and skipped network releases clear a previous update', async () => {
|
||||
for (const tag of ['0.5.0', LOCAL, '9.0.0']) {
|
||||
const f = fixture({ tag_name: 'v' + tag }); f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('9.0.0') }))
|
||||
if (tag === '9.0.0') f.api.setSkipVersion(tag)
|
||||
const result = await f.api.checkUpdate(true)
|
||||
assert.equal(result.status, 'uptodate'); assert.equal(result.info, null)
|
||||
assert.equal(f.api.readCache().info, null)
|
||||
}
|
||||
})
|
||||
test('malformed release versions fail rather than claiming an update or latest', async () => {
|
||||
for (const payload of [{}, { tag_name: 123 }, { tag_name: 'banana' }, { tag_name: '0.5' }, { tag_name: 'vv9.0.0' }]) {
|
||||
const f = fixture(payload); const result = await f.api.checkUpdate(true)
|
||||
assert.equal(result.status, 'failed'); assert.equal(result.info, null)
|
||||
}
|
||||
})
|
||||
test('network failures clear stale updates, retry at most three times and stop on 403/404', async () => {
|
||||
for (const [reply, expectedCalls] of [[new Error('offline'), 3], [{ http: 500 }, 3], [{ http: 403 }, 1], [{ http: 404 }, 1]]) {
|
||||
const f = fixture(reply); f.values.set(CACHE, JSON.stringify({ status: 'uptodate', info: info('9.0.0') }))
|
||||
const result = await f.api.checkUpdate(true)
|
||||
assert.equal(result.status, 'failed'); assert.equal(result.info, null); assert.equal(f.api.readCache().info, null)
|
||||
assert.equal(f.calls.length, expectedCalls)
|
||||
}
|
||||
})
|
||||
test('invalid and future check timestamps do not suppress a fresh request', async () => {
|
||||
for (const last of ['NaN', '-1', String(Date.now() + 86400000)]) {
|
||||
const f = fixture(); f.values.set(LAST, last)
|
||||
await f.api.checkUpdate(false)
|
||||
assert.equal(f.calls.length, 1)
|
||||
}
|
||||
})
|
||||
test('storage access failure cannot turn an older release into an update', async () => {
|
||||
const f = fixture(); f.localStorage.getItem = () => { throw new Error('storage unavailable') }
|
||||
f.localStorage.setItem = () => { throw new Error('storage unavailable') }
|
||||
const result = await f.api.checkUpdate(true)
|
||||
assert.equal(result.status, 'uptodate'); assert.equal(result.info, null)
|
||||
})
|
||||
Reference in New Issue
Block a user