Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
769 lines
30 KiB
JavaScript
769 lines
30 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import test from 'node:test'
|
|
import { fileURLToPath } from 'node:url'
|
|
import vm from 'node:vm'
|
|
|
|
const clientPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'client.js')
|
|
const clientSource = readFileSync(clientPath, 'utf8')
|
|
|
|
function createRenderer() {
|
|
const state = { component: null, props: null, hooks: [], hookIndex: 0, pending: [], tree: null, rendering: false }
|
|
|
|
function render() {
|
|
state.rendering = true
|
|
state.hookIndex = 0
|
|
state.pending = []
|
|
try {
|
|
state.tree = state.component(state.props)
|
|
} finally {
|
|
state.rendering = false
|
|
}
|
|
for (const effect of state.pending) {
|
|
const hook = state.hooks[effect.index]
|
|
if (typeof hook.cleanup === 'function') hook.cleanup()
|
|
hook.deps = effect.deps
|
|
const result = effect.fn()
|
|
hook.cleanup = typeof result === 'function' ? result : undefined
|
|
}
|
|
}
|
|
|
|
function schedule() {
|
|
if (state.rendering) {
|
|
Promise.resolve().then(render)
|
|
return
|
|
}
|
|
render()
|
|
}
|
|
|
|
const react = {
|
|
useState(initial) {
|
|
const index = state.hookIndex++
|
|
if (!(index in state.hooks)) {
|
|
state.hooks[index] = { value: typeof initial === 'function' ? initial() : initial }
|
|
}
|
|
const hook = state.hooks[index]
|
|
const setValue = (next) => {
|
|
const resolved = typeof next === 'function' ? next(hook.value) : next
|
|
if (Object.is(resolved, hook.value)) return
|
|
hook.value = resolved
|
|
schedule()
|
|
}
|
|
return [hook.value, setValue]
|
|
},
|
|
useRef(initial) {
|
|
const index = state.hookIndex++
|
|
if (!(index in state.hooks)) state.hooks[index] = { current: initial }
|
|
return state.hooks[index]
|
|
},
|
|
useEffect(fn, deps) {
|
|
const index = state.hookIndex++
|
|
let hook = state.hooks[index]
|
|
if (!hook) hook = state.hooks[index] = { initialized: false, deps: undefined, cleanup: undefined }
|
|
const changed = !hook.initialized || !deps || !hook.deps ||
|
|
deps.length !== hook.deps.length || deps.some((value, i) => !Object.is(value, hook.deps[i]))
|
|
if (changed) {
|
|
hook.initialized = true
|
|
state.pending.push({ index, fn, deps })
|
|
}
|
|
},
|
|
}
|
|
|
|
const element = (type, props) => ({ type, props: props || {} })
|
|
const jsxRuntime = { jsx: element, jsxs: element, Fragment: Symbol('Fragment') }
|
|
|
|
return {
|
|
react,
|
|
jsxRuntime,
|
|
mount(component, props) {
|
|
state.component = component
|
|
state.props = props
|
|
state.hooks = []
|
|
render()
|
|
},
|
|
update(props) {
|
|
state.props = props
|
|
render()
|
|
},
|
|
unmount() {
|
|
for (const hook of state.hooks) {
|
|
if (hook && typeof hook.cleanup === 'function') {
|
|
hook.cleanup()
|
|
hook.cleanup = undefined
|
|
}
|
|
}
|
|
},
|
|
tree: () => state.tree,
|
|
}
|
|
}
|
|
|
|
function makeEditorWindow() {
|
|
return {
|
|
closed: false,
|
|
focusCount: 0,
|
|
closeCount: 0,
|
|
location: { href: 'about:blank' },
|
|
focus() { this.focusCount++ },
|
|
close() { this.closeCount++; this.closed = true },
|
|
}
|
|
}
|
|
|
|
function makeWindow() {
|
|
const win = {
|
|
openCalls: [],
|
|
openImpl: () => null,
|
|
intervals: [],
|
|
nextIntervalId: 1,
|
|
open(url, name) {
|
|
win.openCalls.push({ url, name })
|
|
return win.openImpl(url, name)
|
|
},
|
|
setInterval(callback, ms) {
|
|
const id = win.nextIntervalId++
|
|
win.intervals.push({ id, callback, ms })
|
|
return id
|
|
},
|
|
clearInterval(id) {
|
|
win.intervals = win.intervals.filter(entry => entry.id !== id)
|
|
},
|
|
triggerIntervals() {
|
|
for (const entry of win.intervals) entry.callback()
|
|
},
|
|
}
|
|
return win
|
|
}
|
|
|
|
function makeDocument() {
|
|
return {
|
|
visibilityState: 'visible',
|
|
getElementById: () => null,
|
|
createElement: () => ({}),
|
|
head: { appendChild() {} },
|
|
}
|
|
}
|
|
|
|
function loadClient() {
|
|
const renderer = createRenderer()
|
|
const win = makeWindow()
|
|
const doc = makeDocument()
|
|
const captured = {}
|
|
win.__ModuleLoader__ = { load: (module) => { captured.module = module } }
|
|
|
|
const fetchCalls = []
|
|
const sandbox = { window: win, document: doc, console, URL, encodeURIComponent, fetch: async (path, options) => {
|
|
fetchCalls.push({ path, options })
|
|
return { json: async () => ({ ok: true, value: {} }) }
|
|
} }
|
|
vm.createContext(sandbox)
|
|
vm.runInContext(clientSource, sandbox)
|
|
|
|
const DiffViewer = Symbol('SplitDiffViewer')
|
|
const require = (name) => {
|
|
if (name === 'react') return renderer.react
|
|
if (name === 'react/jsx-runtime') return renderer.jsxRuntime
|
|
if (name === '@dsh-external/dsh-diff-viewer') return { DiffViewer }
|
|
throw new Error(`Unexpected module request: ${name}`)
|
|
}
|
|
const api = captured.module.factory(require)
|
|
|
|
const registrations = []
|
|
const slots = {
|
|
inject: (name, build) => {
|
|
const result = build()
|
|
if (typeof result?.[Symbol.iterator] === 'function') return [...result]
|
|
return result
|
|
},
|
|
register: (config, component) => {
|
|
const registration = { config, component }
|
|
registrations.push(registration)
|
|
return registration
|
|
},
|
|
}
|
|
api.apply({ slots })
|
|
const workingDirs = registrations.find(entry => entry.config.name === 'conversation.session.header.actions')
|
|
const mutation = registrations.find(entry => entry.config.name === 'tool.call.toolview')
|
|
|
|
return { GitWorkingDirsMenu: workingDirs.component, MutationToolCard: mutation.component, renderer, window: win, document: doc, config: workingDirs.config, registrations, DiffViewer, fetchCalls }
|
|
}
|
|
|
|
async function flush() {
|
|
for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve))
|
|
}
|
|
|
|
function collectText(node, out = []) {
|
|
if (node == null) return out
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) collectText(child, out)
|
|
return out
|
|
}
|
|
if (typeof node === 'string' || typeof node === 'number') {
|
|
out.push(String(node))
|
|
return out
|
|
}
|
|
if (typeof node === 'object') collectText(node.props?.children, out)
|
|
return out
|
|
}
|
|
|
|
function findAll(node, predicate, out = []) {
|
|
if (node == null) return out
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) findAll(child, predicate, out)
|
|
return out
|
|
}
|
|
if (typeof node === 'object') {
|
|
if (predicate(node)) out.push(node)
|
|
findAll(node.props?.children, predicate, out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
function visibleText(renderer) {
|
|
return collectText(renderer.tree()).join(' ')
|
|
}
|
|
|
|
function buttons(renderer) {
|
|
return findAll(renderer.tree(), node => node.type === 'button')
|
|
}
|
|
|
|
function trackedEntry(overrides = {}) {
|
|
return { ok: true, id: 'e1', repository: '/repo/.git', workingDirectory: '/repo/work', ...overrides }
|
|
}
|
|
|
|
function openGitMenu(renderer) {
|
|
buttons(renderer).find(button => button.props.children === 'Git Working Dirs').props.onClick()
|
|
}
|
|
|
|
function gitOpenButton(renderer) {
|
|
return buttons(renderer).find(button => button.props.children === 'Open VS Code')
|
|
}
|
|
|
|
test('mounting only lists tracked directories and never opens an editor', async () => {
|
|
const { GitWorkingDirsMenu, renderer, window } = loadClient()
|
|
let listCalls = 0
|
|
let openCalls = 0
|
|
const list = async () => { listCalls++; return { ok: true, value: { entries: [trackedEntry()] } } }
|
|
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
|
|
assert.equal(listCalls, 1)
|
|
assert.equal(openCalls, 0)
|
|
assert.equal(window.intervals.length, 1)
|
|
})
|
|
|
|
test('header trigger opens and dismisses the Git Working Dirs menu', async () => {
|
|
const { GitWorkingDirsMenu, renderer } = loadClient()
|
|
renderer.mount(GitWorkingDirsMenu, {
|
|
list: async () => ({ ok: true, value: { entries: [trackedEntry()] } }),
|
|
open: async () => ({ ok: true, value: {} }),
|
|
})
|
|
await flush()
|
|
|
|
assert.equal(buttons(renderer)[0].props.children, 'Git Working Dirs')
|
|
assert.equal(buttons(renderer)[0].props['aria-expanded'], false)
|
|
assert.doesNotMatch(visibleText(renderer), /repo\/work/)
|
|
openGitMenu(renderer)
|
|
assert.equal(buttons(renderer)[0].props['aria-expanded'], true)
|
|
assert.match(visibleText(renderer), /repo\/work/)
|
|
buttons(renderer).find(button => button.props['aria-label'] === 'Close Git Working Dirs').props.onClick()
|
|
assert.equal(buttons(renderer)[0].props['aria-expanded'], false)
|
|
})
|
|
|
|
test('interval polling keeps listing without ever opening an editor', async () => {
|
|
const { GitWorkingDirsMenu, renderer, window } = loadClient()
|
|
let listCalls = 0
|
|
let openCalls = 0
|
|
const list = async () => { listCalls++; return { ok: true, value: { entries: [trackedEntry()] } } }
|
|
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
window.triggerIntervals()
|
|
await flush()
|
|
window.triggerIntervals()
|
|
await flush()
|
|
|
|
assert.equal(listCalls, 3)
|
|
assert.equal(openCalls, 0)
|
|
})
|
|
|
|
test('an explicit click opens the editor and navigates the popup to the local URL', async () => {
|
|
const { GitWorkingDirsMenu, renderer, window } = loadClient()
|
|
const editorWindow = makeEditorWindow()
|
|
window.openImpl = () => editorWindow
|
|
let openArgument = null
|
|
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
|
|
const open = async (id) => { openArgument = id; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
openGitMenu(renderer)
|
|
gitOpenButton(renderer).props.onClick()
|
|
await flush()
|
|
|
|
assert.equal(openArgument, 'e1')
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.equal(editorWindow.location.href, 'http://127.0.0.1:40000/')
|
|
assert.match(visibleText(renderer), /VS Code opened/)
|
|
})
|
|
|
|
test('a blocked popup surfaces guidance and never opens an editor', async () => {
|
|
const { GitWorkingDirsMenu, renderer, window } = loadClient()
|
|
window.openImpl = () => null
|
|
let openCalls = 0
|
|
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
|
|
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
openGitMenu(renderer)
|
|
gitOpenButton(renderer).props.onClick()
|
|
await flush()
|
|
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.equal(openCalls, 0)
|
|
assert.match(visibleText(renderer), /Allow popups/)
|
|
})
|
|
|
|
test('a still-open editor window is focused instead of reopened', async () => {
|
|
const { GitWorkingDirsMenu, renderer, window } = loadClient()
|
|
const editorWindow = makeEditorWindow()
|
|
window.openImpl = () => editorWindow
|
|
let openCalls = 0
|
|
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
|
|
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
openGitMenu(renderer)
|
|
gitOpenButton(renderer).props.onClick()
|
|
await flush()
|
|
gitOpenButton(renderer).props.onClick()
|
|
await flush()
|
|
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.equal(openCalls, 1)
|
|
assert.equal(editorWindow.focusCount, 1)
|
|
assert.match(visibleText(renderer), /Focused VS Code/)
|
|
})
|
|
|
|
test('a failed list request is reported and never opens an editor', async () => {
|
|
const { GitWorkingDirsMenu, renderer } = loadClient()
|
|
let openCalls = 0
|
|
const list = async () => ({ ok: false, error: { message: 'list request failed' } })
|
|
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
openGitMenu(renderer)
|
|
|
|
assert.equal(openCalls, 0)
|
|
assert.match(visibleText(renderer), /list request failed/)
|
|
})
|
|
|
|
test('a failed open request reports the error and closes the popup', async () => {
|
|
const { GitWorkingDirsMenu, renderer, window } = loadClient()
|
|
const editorWindow = makeEditorWindow()
|
|
window.openImpl = () => editorWindow
|
|
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
|
|
const open = async () => ({ ok: false, error: { message: 'editor is unavailable' } })
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
openGitMenu(renderer)
|
|
gitOpenButton(renderer).props.onClick()
|
|
await flush()
|
|
|
|
assert.equal(editorWindow.closeCount, 1)
|
|
assert.equal(editorWindow.location.href, 'about:blank')
|
|
assert.match(visibleText(renderer), /editor is unavailable/)
|
|
})
|
|
|
|
test('unmounting stops the polling interval', async () => {
|
|
const { GitWorkingDirsMenu, renderer, window } = loadClient()
|
|
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
|
|
const open = async () => ({ ok: true, value: { url: 'http://127.0.0.1:40000' } })
|
|
|
|
renderer.mount(GitWorkingDirsMenu, { list, open })
|
|
await flush()
|
|
assert.equal(window.intervals.length, 1)
|
|
|
|
renderer.unmount()
|
|
assert.equal(window.intervals.length, 0)
|
|
})
|
|
|
|
function mutationBlock(overrides = {}) {
|
|
return {
|
|
kind: 'tool-result',
|
|
callId: 'mutation-1',
|
|
call: { name: 'write', argsRaw: JSON.stringify({ file_path: '/workspace/file.txt' }) },
|
|
isError: false,
|
|
content: [{ type: 'text', text: 'File updated.\nWARNING: model-visible note' }],
|
|
resultView: { card: 'diff', diffs: [
|
|
{ path: '/workspace/file.txt', oldText: 'before', newText: Array.from({ length: 45 }, (_, i) => `after ${i}`).join('\n') },
|
|
{ path: '/workspace/file.txt', oldText: 'other context', newText: 'other updated context' },
|
|
] },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function mutationMetadata(overrides = {}) {
|
|
return { filePath: '/workspace/file.txt', repository: '/workspace', workspaceDirectory: '/workspace', canOpen: true, warning: null, ...overrides }
|
|
}
|
|
|
|
function mutationProps(overrides = {}) {
|
|
return {
|
|
callId: 'mutation-1',
|
|
toolName: 'write',
|
|
block: mutationBlock(),
|
|
cwd: '/workspace',
|
|
home: '/home/user',
|
|
mutation: async () => ({ ok: true, value: mutationMetadata() }),
|
|
openMutation: async () => ({ ok: true, value: { delivery: 'new', directory: '/workspace', filePath: '/workspace/file.txt', url: 'http://127.0.0.1:40000/?file=file.txt' } }),
|
|
openFile: () => {},
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function openFileButton(renderer) {
|
|
return buttons(renderer).find(node => node.props.children === 'Open in VS Code' || node.props.children === 'Opening…')
|
|
}
|
|
|
|
function nativeOpenButton(renderer) {
|
|
return buttons(renderer).find(node => node.props.children === 'Open')
|
|
}
|
|
|
|
test('registers the working-directory menu as the final header action and removes the Git tab', async () => {
|
|
const { registrations, fetchCalls } = loadClient()
|
|
assert.deepEqual(registrations.map(entry => [entry.config.name, entry.config.key]), [
|
|
['conversation.session.header.actions', undefined], ['tool.call.toolview', 'write'], ['tool.call.toolview', 'edit'],
|
|
])
|
|
assert.equal(registrations[0].config.id, 'touched-git-working-dirs')
|
|
assert.equal(registrations[0].config.order, 10000)
|
|
assert.equal(registrations.some(entry => entry.config.name === 'conversation.view'), false)
|
|
assert.match(clientSource, /_headerActions.*:has\(\.dsh-git-working-dirs\)\{flex:1\}/)
|
|
assert.match(clientSource, /\.dsh-git-working-dirs\{position:relative;margin-left:auto/)
|
|
assert.match(clientSource, /\.dsh-git-working-dirs__menu\{[^}]*right:0/)
|
|
assert.equal(registrations[1].component, registrations[2].component)
|
|
for (const registration of registrations.slice(1)) {
|
|
const api = registration.config.inject('session /1')
|
|
assert.equal(fetchCalls.length % 3, 0)
|
|
await api.mutation('call /1')
|
|
await api.openMutation('call /1', false)
|
|
await api.openMutation('call /1', true)
|
|
const requests = fetchCalls.slice(-3)
|
|
assert.equal(requests[0].path, '/touched-git/mutation?sessionId=session%20%2F1&callId=call%20%2F1')
|
|
assert.equal(requests[0].options, undefined)
|
|
assert.equal(requests[1].path, '/touched-git/editor/open-file?sessionId=session%20%2F1&callId=call%20%2F1')
|
|
assert.equal(requests[2].path, `${requests[1].path}&requireExisting=true`)
|
|
assert.equal(requests[2].options.method, 'POST')
|
|
assert.equal(requests[2].options.headers['x-dsh-editor-action'], 'open')
|
|
}
|
|
})
|
|
|
|
for (const toolName of ['write', 'edit']) {
|
|
test(`${toolName} defaults all result diffs to expanded and never launches during metadata preload`, async () => {
|
|
const { MutationToolCard, renderer, window, DiffViewer } = loadClient()
|
|
let metadataCalls = 0
|
|
let openCalls = 0
|
|
const props = mutationProps({ toolName,
|
|
mutation: async callId => { assert.equal(callId, 'mutation-1'); metadataCalls++; return { ok: true, value: mutationMetadata() } },
|
|
openMutation: async () => { openCalls++; return { ok: true, value: {} } },
|
|
})
|
|
renderer.mount(MutationToolCard, props)
|
|
await flush()
|
|
const diffs = findAll(renderer.tree(), node => node.type === DiffViewer)
|
|
assert.equal(diffs.length, 1)
|
|
assert.equal(diffs[0].props.maxLines, Infinity)
|
|
assert.equal(diffs[0].props.viewMode, 'split')
|
|
assert.equal(diffs[0].props.diffs, props.block.resultView.diffs)
|
|
assert.equal(diffs[0].props.diffs.length, 2)
|
|
assert.match(diffs[0].props.diffs[0].newText, /after 44$/)
|
|
assert.equal(buttons(renderer).find(node => node.props.children === 'Collapse diff').props['aria-expanded'], true)
|
|
assert.equal(renderer.tree().props['data-state'], 'ok')
|
|
assert.match(visibleText(renderer), /Success/)
|
|
assert.match(visibleText(renderer), /WARNING: model-visible note/)
|
|
assert.equal(openFileButton(renderer).props.disabled, false)
|
|
assert.equal(metadataCalls, 1)
|
|
assert.equal(openCalls, 0)
|
|
assert.equal(window.openCalls.length, 0)
|
|
})
|
|
}
|
|
|
|
test('native Open follows Open in VS Code and forwards the recorded path to the host', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
const paths = []
|
|
let editorCalls = 0
|
|
renderer.mount(MutationToolCard, mutationProps({
|
|
openFile: path => paths.push(path),
|
|
openMutation: async () => { editorCalls++; return { ok: true, value: {} } },
|
|
}))
|
|
await flush()
|
|
|
|
const allButtons = buttons(renderer)
|
|
const editorIndex = allButtons.indexOf(openFileButton(renderer))
|
|
assert.equal(allButtons[editorIndex + 1], nativeOpenButton(renderer))
|
|
assert.equal(nativeOpenButton(renderer).props.disabled, false)
|
|
nativeOpenButton(renderer).props.onClick()
|
|
assert.deepEqual(paths, ['/workspace/file.txt'])
|
|
assert.equal(editorCalls, 0)
|
|
assert.equal(window.openCalls.length, 0)
|
|
})
|
|
|
|
test('running mutation shows proposed diff and disabled file-opening buttons without metadata requests', async () => {
|
|
const { MutationToolCard, renderer, window, DiffViewer } = loadClient()
|
|
let metadataCalls = 0
|
|
const intended = [{ path: '/workspace/file.txt', oldText: 'old', newText: 'new' }]
|
|
renderer.mount(MutationToolCard, mutationProps({
|
|
block: { name: 'edit', argsRaw: '{}', callView: { card: 'diff', diffs: intended } },
|
|
mutation: async () => { metadataCalls++; return { ok: true, value: {} } },
|
|
}))
|
|
await flush()
|
|
assert.equal(renderer.tree().props['data-state'], 'running')
|
|
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer)[0].props.diffs, intended)
|
|
assert.match(visibleText(renderer), /Proposed changes/)
|
|
assert.doesNotMatch(visibleText(renderer), /Applied changes/)
|
|
assert.equal(openFileButton(renderer).props.disabled, true)
|
|
assert.equal(nativeOpenButton(renderer).props.disabled, true)
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(window.openCalls.length, 0)
|
|
assert.equal(metadataCalls, 0)
|
|
})
|
|
|
|
test('settled errors retain error text without presenting attempted diffs as applied', async () => {
|
|
const { MutationToolCard, renderer, DiffViewer, window } = loadClient()
|
|
let opens = 0
|
|
renderer.mount(MutationToolCard, mutationProps({
|
|
block: mutationBlock({ isError: true, content: [{ type: 'text', text: 'Error: write blocked; unregistered repository' }] }),
|
|
openMutation: async () => { opens++; return { ok: true, value: {} } },
|
|
}))
|
|
await flush()
|
|
assert.equal(renderer.tree().props['data-state'], 'error')
|
|
assert.match(visibleText(renderer), /Error: write blocked; unregistered repository/)
|
|
assert.doesNotMatch(visibleText(renderer), /Applied changes/)
|
|
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer).length, 0)
|
|
assert.equal(openFileButton(renderer).props.disabled, true)
|
|
assert.equal(nativeOpenButton(renderer).props.disabled, true)
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(opens, 0)
|
|
assert.equal(window.openCalls.length, 0)
|
|
})
|
|
|
|
test('historical calls without successful mutation records cannot invent editor provenance', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
renderer.mount(MutationToolCard, mutationProps({ mutation: async () => ({ ok: true, value: { canOpen: false } }) }))
|
|
await flush()
|
|
assert.equal(openFileButton(renderer).props.disabled, true)
|
|
assert.match(visibleText(renderer), /No successful mutation record.*Historical calls/)
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(window.openCalls.length, 0)
|
|
})
|
|
|
|
test('a new delivery navigates only the reserved blank and a later click never reloads it', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
const editorWindow = makeEditorWindow()
|
|
window.openImpl = () => editorWindow
|
|
const requests = []
|
|
renderer.mount(MutationToolCard, mutationProps({ openMutation: async (callId, requireExisting) => {
|
|
requests.push({ callId, requireExisting, focusCount: editorWindow.focusCount })
|
|
return { ok: true, value: requireExisting
|
|
? { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' }
|
|
: { delivery: 'new', directory: '/workspace', filePath: '/workspace/file.txt', url: 'http://127.0.0.1:40000/?file=file.txt' } }
|
|
} }))
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
assert.equal(window.openCalls[0].url, 'about:blank')
|
|
await flush()
|
|
const firstLocation = editorWindow.location.href
|
|
openFileButton(renderer).props.onClick()
|
|
assert.equal(editorWindow.focusCount, 1)
|
|
await flush()
|
|
assert.deepEqual(requests, [
|
|
{ callId: 'mutation-1', requireExisting: false, focusCount: 0 },
|
|
{ callId: 'mutation-1', requireExisting: true, focusCount: 1 },
|
|
])
|
|
assert.equal(editorWindow.location.href, firstLocation)
|
|
assert.equal(editorWindow.closeCount, 0)
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.match(visibleText(renderer), /without reloading/)
|
|
})
|
|
|
|
test('popup failure never sends a mutation open POST', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
let opens = 0
|
|
renderer.mount(MutationToolCard, mutationProps({ openMutation: async () => { opens++; return { ok: true, value: {} } } }))
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.equal(opens, 0)
|
|
assert.match(visibleText(renderer), /No editor request was sent/)
|
|
})
|
|
|
|
test('existing delivery after losing browser references closes only the reserved blank', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
const blank = makeEditorWindow()
|
|
window.openImpl = () => blank
|
|
renderer.mount(MutationToolCard, mutationProps({ openMutation: async () => ({ ok: true, value: { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' } }) }))
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(blank.closeCount, 1)
|
|
assert.equal(blank.location.href, 'about:blank')
|
|
assert.match(visibleText(renderer), /existing VS Code workspace.*no longer has a window reference/)
|
|
})
|
|
|
|
test('existing-window request failures leave that editor window open and unchanged', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
const editorWindow = makeEditorWindow()
|
|
window.openImpl = () => editorWindow
|
|
renderer.mount(MutationToolCard, mutationProps({ openMutation: async (callId, requireExisting) => requireExisting
|
|
? { ok: false, error: { message: 'Editor socket unavailable' } }
|
|
: { ok: true, value: { delivery: 'new', directory: '/workspace', filePath: '/workspace/file.txt', url: 'http://127.0.0.1:40000/' } },
|
|
}))
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
const originalLocation = editorWindow.location.href
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(editorWindow.closeCount, 0)
|
|
assert.equal(editorWindow.location.href, originalLocation)
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.match(visibleText(renderer), /Editor socket unavailable/)
|
|
})
|
|
|
|
test('non-Git recorded edits remain openable and show their warning', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
window.openImpl = () => makeEditorWindow()
|
|
let openCalls = 0
|
|
renderer.mount(MutationToolCard, mutationProps({ toolName: 'edit',
|
|
mutation: async () => ({ ok: true, value: mutationMetadata({ repository: null, workspaceDirectory: '/workspace', warning: 'WARNING: outside Git; parent-folder workspace' }) }),
|
|
openMutation: async () => { openCalls++; return { ok: true, value: { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' } } },
|
|
}))
|
|
await flush()
|
|
assert.equal(openFileButton(renderer).props.disabled, false)
|
|
assert.match(visibleText(renderer), /outside Git; parent-folder workspace/)
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(openCalls, 1)
|
|
})
|
|
|
|
test('non-local new delivery is refused and closes only its blank popup', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
const blank = makeEditorWindow()
|
|
window.openImpl = () => blank
|
|
renderer.mount(MutationToolCard, mutationProps({ openMutation: async () => ({ ok: true, value: { delivery: 'new', directory: '/workspace', url: 'https://untrusted.example/editor' } }) }))
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(blank.location.href, 'about:blank')
|
|
assert.equal(blank.closeCount, 1)
|
|
assert.match(visibleText(renderer), /unexpected non-local URL/)
|
|
})
|
|
|
|
test('Git-menu workspace references are reused by mutation file buttons', async () => {
|
|
const { GitWorkingDirsMenu, MutationToolCard, renderer, window } = loadClient()
|
|
const existing = makeEditorWindow()
|
|
window.openImpl = () => existing
|
|
renderer.mount(GitWorkingDirsMenu, {
|
|
list: async () => ({ ok: true, value: { entries: [trackedEntry({ workingDirectory: '/workspace' })] } }),
|
|
open: async () => ({ ok: true, value: { url: 'http://127.0.0.1:40000/' } }),
|
|
})
|
|
await flush()
|
|
openGitMenu(renderer)
|
|
gitOpenButton(renderer).props.onClick()
|
|
await flush()
|
|
renderer.unmount()
|
|
const calls = []
|
|
renderer.mount(MutationToolCard, mutationProps({ openMutation: async (callId, requireExisting) => {
|
|
calls.push(requireExisting)
|
|
return { ok: true, value: { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' } }
|
|
} }))
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
assert.equal(existing.focusCount, 1)
|
|
await flush()
|
|
assert.deepEqual(calls, [true])
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.equal(existing.location.href, 'http://127.0.0.1:40000/')
|
|
})
|
|
|
|
test('a running call preloads metadata once it settles and uses only authoritative result diffs', async () => {
|
|
const { MutationToolCard, renderer, DiffViewer } = loadClient()
|
|
let calls = 0
|
|
const props = mutationProps({ mutation: async () => { calls++; return { ok: true, value: mutationMetadata() } } })
|
|
renderer.mount(MutationToolCard, { ...props, block: { name: 'write', callView: { card: 'diff', diffs: [{ path: '/attempted', oldText: null, newText: 'attempt' }] } } })
|
|
await flush()
|
|
assert.equal(calls, 0)
|
|
renderer.update(props)
|
|
await flush()
|
|
assert.equal(calls, 1)
|
|
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer)[0].props.diffs, props.block.resultView.diffs)
|
|
assert.equal(openFileButton(renderer).props.disabled, false)
|
|
})
|
|
|
|
test('interrupted and malformed settled calls do not render an attempted applied diff', async () => {
|
|
const { MutationToolCard, renderer, DiffViewer } = loadClient()
|
|
const props = mutationProps({ block: mutationBlock({ error: { name: 'AbortError', code: 'interrupted' }, content: [], isError: true }) })
|
|
renderer.mount(MutationToolCard, props)
|
|
await flush()
|
|
assert.equal(renderer.tree().props['data-state'], 'stopped')
|
|
assert.match(visibleText(renderer), /Interrupted.*AbortError: interrupted/)
|
|
assert.equal(openFileButton(renderer).props.disabled, true)
|
|
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer).length, 0)
|
|
renderer.update({ ...props, block: mutationBlock({ resultView: { card: 'diff', diffs: [{ path: '/file', oldText: 12, newText: 'new' }] } }) })
|
|
await flush()
|
|
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer).length, 0)
|
|
assert.doesNotMatch(visibleText(renderer), /Applied changes/)
|
|
})
|
|
|
|
test('an unexpected new delivery never navigates or closes a remembered editor', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
const existing = makeEditorWindow()
|
|
window.openImpl = () => existing
|
|
renderer.mount(MutationToolCard, mutationProps())
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
const location = existing.location.href
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(existing.closeCount, 0)
|
|
assert.equal(existing.location.href, location)
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.match(visibleText(renderer), /window was left unchanged/)
|
|
})
|
|
|
|
test('a browser focus failure is visible without disturbing the existing editor', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
const existing = makeEditorWindow()
|
|
window.openImpl = () => existing
|
|
let calls = 0
|
|
const props = mutationProps()
|
|
renderer.mount(MutationToolCard, { ...props, openMutation: (...args) => { calls++; return props.openMutation(...args) } })
|
|
await flush()
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
existing.focus = () => { throw new Error('Browser refused focus') }
|
|
openFileButton(renderer).props.onClick()
|
|
await flush()
|
|
assert.equal(calls, 1)
|
|
assert.equal(existing.closeCount, 0)
|
|
assert.equal(window.openCalls.length, 1)
|
|
assert.match(visibleText(renderer), /could not focus.*left unchanged/)
|
|
})
|
|
|
|
test('metadata errors disable opening and remain visible', async () => {
|
|
const { MutationToolCard, renderer, window } = loadClient()
|
|
renderer.mount(MutationToolCard, mutationProps({ mutation: async () => ({ ok: false, error: { message: 'No mutation record exists for this call' } }) }))
|
|
await flush()
|
|
assert.equal(openFileButton(renderer).props.disabled, true)
|
|
assert.match(visibleText(renderer), /No mutation record exists/)
|
|
assert.equal(window.openCalls.length, 0)
|
|
})
|