import assert from 'node:assert/strict' import test from 'node:test' import vm from 'node:vm' import { injectRandomUuidPolyfill, LIFECYCLE_TRANSITION_GUARD, PLUGIN_RECOVERY_GUARD, RANDOM_UUID_POLYFILL, SESSION_VALIDITY_GUARD } from '../lib/polyfill.mjs' async function simulatePluginError(bundlePath, { eventType = 'error', storage = new Map() } = {}) { let replacement const events = [] const listeners = new Map() const context = { URL, crypto: globalThis.crypto, location: { href: 'http://gateway.local/sessions/current', pathname: '/sessions/current', search: '', hash: '', replace: value => { replacement = value }, }, sessionStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), }, addEventListener: (name, callback) => listeners.set(name, callback), fetch: async (input, init) => { if (input === '/_dsh_gateway/readiness') return { json: async () => ({ state: 'ready', pluginRecoveryEligible: true }) } if (input === '/_dsh_platform/plugin-api/v1/status') return { json: async () => ({ dshLifecycle: { state: 'running', taskId: 'lifecycle-task', updatedAt: new Date().toISOString(), }, }), } if (input === '/_dsh_gateway/client-event') { events.push(JSON.parse(init.body)) return { json: async () => ({}) } } return { ok: true, json: async () => ({}) } }, } vm.runInNewContext(PLUGIN_RECOVERY_GUARD.slice(''.length), context) if (eventType === 'error') listeners.get('error')?.({ filename: `http://gateway.local${bundlePath}` }) else if (eventType === 'unhandledrejection') listeners.get('unhandledrejection')?.({ reason: { stack: `TypeError: failed to import http://gateway.local${bundlePath}` }, }) await new Promise(resolve => setImmediate(resolve)) return { events, replacement, storage } } test('polyfill is inserted immediately after the head tag', () => { assert.equal( injectRandomUuidPolyfill('x'), `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}x`, ) }) test('polyfill falls back to the start when no head exists', () => { assert.equal(injectRandomUuidPolyfill('
x
'), `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}
x
`) }) test('gateway lifecycle guard moves an already-open DSH page into the holding flow', async () => { let inspect let replacement const context = { EventSource: class { addEventListener(name, callback) { if (name === 'state') inspect = callback } }, URL, fetch: async () => ({ json: async () => ({ dshLifecycle: { state: 'restarting' } }) }), location: { pathname: '/sessions/current', search: '?view=chat', hash: '#latest', replace: value => { replacement = value }, }, } vm.runInNewContext(LIFECYCLE_TRANSITION_GUARD.slice(''.length), context) await new Promise(resolve => setImmediate(resolve)) assert.equal(replacement, '/_dsh_gateway/wait?return=%2Fsessions%2Fcurrent%3Fview%3Dchat%23latest') replacement = undefined await inspect() assert.equal(replacement, undefined) }) test('plugin recovery guard runs before DSH modules and bounds lifecycle recovery', () => { assert.doesNotThrow(() => new Function(PLUGIN_RECOVERY_GUARD.slice(''.length))) assert.match(PLUGIN_RECOVERY_GUARD, /globalThis\.fetch=function/) assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/client-event/) assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/wait/) assert.match(PLUGIN_RECOVERY_GUARD, /attempt>=3/) assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/plugin-failure/) assert.match(PLUGIN_RECOVERY_GUARD, /browser\.plugin-load\.recovery\.completed/) assert.match(PLUGIN_RECOVERY_GUARD, /addEventListener\("error"/) assert.match(PLUGIN_RECOVERY_GUARD, /unhandledrejection/) assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /Failed to load plugins/) assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /localStorage/) }) test('Gateway session guard reloads DSH pages only after Access Manager rejects the session', () => { assert.doesNotThrow(() => new Function(SESSION_VALIDITY_GUARD.slice(''.length))) assert.match(SESSION_VALIDITY_GUARD, /auth\/session-context/) assert.match(SESSION_VALIDITY_GUARD, /response\.status===401/) assert.doesNotMatch(SESSION_VALIDITY_GUARD, /innerText|textContent/) }) test('plugin recovery guard catches official and third-party dynamic import failures', async () => { for (const [path, pluginId] of [ ['/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123', '@deepseek-ai/dsh-client-ui-input-trigger'], ['/plugins/community-plugin/client.js?rev=def456', 'community-plugin'], ]) { const result = await simulatePluginError(path) assert.match(result.replacement, /^\/_dsh_gateway\/wait\?return=/) assert.deepEqual(result.events.map(value => [value.event, value.pluginId]), [ ['browser.plugin-load.failed', pluginId], ['browser.plugin-load.recovery.started', pluginId], ]) } }) test('plugin recovery guard retries twice and opens the Gateway failure page after the third failure', async () => { const path = '/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123' const storage = new Map() const first = await simulatePluginError(path, { storage }) const second = await simulatePluginError(path, { storage }) const third = await simulatePluginError(path, { storage }) assert.match(first.replacement, /^\/_dsh_gateway\/wait\?return=/) assert.match(second.replacement, /^\/_dsh_gateway\/wait\?return=/) assert.equal(third.replacement, '/_dsh_gateway/plugin-failure') assert.equal(first.events.at(-1).recoveryAttempt, 1) assert.equal(second.events.at(-1).recoveryAttempt, 2) assert.deepEqual(third.events.map(value => [value.event, value.recoveryAttempt]), [ ['browser.plugin-load.recovery.failed', 3], ]) }) test('plugin recovery guard does not inspect page text', async () => { const result = await simulatePluginError('/plugins/example/client.js?rev=abc123', { eventType: 'none' }) assert.equal(result.replacement, undefined) assert.deepEqual(result.events, []) }) test('polyfill is feature guarded and uses no weak random source', () => { assert.match(RANDOM_UUID_POLYFILL, /typeof c\.randomUUID!=="function"/) assert.match(RANDOM_UUID_POLYFILL, /c\.getRandomValues/) assert.doesNotMatch(RANDOM_UUID_POLYFILL, /Math\.random/) })