/** * The browser half: the two keys it binds, and the fields it stages. * * This file exists because of a bug that no host-side test could catch. Marking * the schema's fields `volatile()` makes the Host *serve* the namespace, but the * Plugins page draws only what a browser half registers — so the plugin showed no * settings card at all, and every host-side test passed. * * The wiring is therefore the thing worth testing, and the two keys are the trap: * the settings DOCUMENT is keyed by the profile entry id (`facet-policy`) while * the CARD is keyed by the package name (`dsh-facet`). Every sibling plugin * happens to name those identically, which is exactly why this drifted. A card * bound to the wrong id renders, saves, and writes nowhere. * * `client/client.js` is a `window.__ModuleLoader__` module rather than an ES * module — that is how the harness loads a browser half without a bundler — so * the test stands up the two globals it reads and drives it from there. */ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { test } from 'node:test' import { Config } from '../index.js' /** Load the browser half with `window` and `require` faked, as the seed would. */ async function loadClient() { const source = await readFile(new URL('../client/client.js', import.meta.url), 'utf8') /** Every spec a card staged, in construction order. */ const staged = [] const primitives = { Modal: 'Modal', SettingsForm: 'SettingsForm', SettingsValueField: 'SettingsValueField', SegmentedControl: 'SegmentedControl', // The listing's row glyphs; a client half may only use what the primitives // actually export, so the stand-ins are named the same way. IconFolderCloseRegular: 'IconFolderCloseRegular', FileTypeIcon: 'FileTypeIcon', /** Stands in for the real model: records the specs and answers with the value. */ SettingsFormModel: class { constructor(scope, specs) { this.scope = scope this.specs = specs staged.push(...specs) } bind(project) { this.project = project return { project } } shell() { return { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } } field(name) { const spec = this.specs.find((candidate) => candidate.field === name) return { text: spec?.format(this.scope.value?.[name]) ?? '', overridden: false, invalid: false } } actions() { return { edit: () => {}, resetField: () => {}, save: () => {}, discard: () => {} } } dispose() {} }, } const require = (id) => { if (id === 'react') { return { createElement: (type, props, ...children) => ({ type, props, children }), // Single-pass stand-ins: the tests render a component once and read the // tree, so a hook only has to answer, not to re-render. useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}], useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(), } } if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives throw new Error(`client.js required an unexpected module: ${id}`) } let registered const loader = { load: (value) => (registered = value) } // eslint-disable-next-line no-new-func -- the file is a ModuleLoader script, not an ES module. new Function('window', 'require', source)({ __ModuleLoader__: loader }, require) assert.notEqual(registered, undefined, 'client.js never called window.__ModuleLoader__.load') return { module: registered.factory(require), staged, primitives } } /** Drive `apply` against a fake slot/configForms surface and report what it did. */ function drive(mod, { served = true, value = {}, workspace } = {}) { const seen = { formNs: undefined, watched: undefined, registrations: [] } const disposers = [] let currentSlot // The optional service the context panel browses through. `get` is the // Cordis read that answers `undefined` rather than throwing, which is what // the plugin relies on to keep the panel in a deployment with no browser. const get = (name) => (name === 'uiWorkspace' ? workspace : undefined) const ctx = { get, inject: (names, scope) => { const scoped = { get, configForms: { get: (ns) => { seen.formNs = ns return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true } }, whileServed: (namespaces, register) => { seen.watched = namespaces // The real `whileServed` registers only while the Host serves the // namespace; `served` is what the Host's `settings.describe` answers. if (!served) return () => {} return register(new Set(namespaces)) }, }, slots: { inject: (name, register) => { currentSlot = name register() currentSlot = undefined }, register: (options, component) => { seen.registrations.push({ slot: currentSlot, options, component }) return () => {} }, }, effect: (fn) => { const dispose = fn() if (typeof dispose === 'function') disposers.push(dispose) return dispose }, } scope(scoped) }, } mod.apply(ctx) return { seen, disposers } } /** The one registration a test cares about, by the slot it went into. */ function registrationFor(seen, slot) { const found = seen.registrations.filter((entry) => entry.slot === slot) assert.equal(found.length, 1, `expected exactly one registration into ${slot}`) return found[0] } test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => { const { module: mod } = await loadClient() assert.equal(mod.name, 'dsh-facet') assert.deepEqual(mod.inject, ['slots']) assert.equal(typeof mod.apply, 'function') }) test('the form binds to the profile entry id while the card registers under the package name', async () => { const { module: mod } = await loadClient() const { seen } = drive(mod) const card = registrationFor(seen, 'plugins.bundle.config') // These two are deliberately different strings. `facet-policy` is the `id` of // this bundle's row in cordis.patch.yml — what `Config` is filed under. The // card is dispatched by the BUNDLE's package name. assert.equal(seen.formNs, 'facet-policy') assert.equal(card.options.key, 'dsh-facet') assert.notEqual(seen.formNs, card.options.key, 'if these ever coincide, the trap this test guards has moved') assert.equal(card.slot, 'plugins.bundle.config') assert.deepEqual(seen.watched, ['facet-policy']) }) test('the context button rides the composer tool row, gated on the same namespace', async () => { const { module: mod } = await loadClient() const { seen } = drive(mod) const button = registrationFor(seen, 'conversation.input.left') // An id, not a key: `conversation.input.left` is a list, and the row shows one // entry per id. assert.equal(button.options.id, 'facet-context') assert.equal(typeof button.options.inject, 'function') // It has to receive the settings form, because the panel writes through the // very same document the plugin card does — and the browse face, because the // panel's two controls are directory listings. assert.equal(typeof button.options.inject().form, 'object') assert.equal(typeof button.options.inject().browse.list, 'function') }) test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => { const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8') const { module: mod } = await loadClient() const { seen } = drive(mod) const card = registrationFor(seen, 'plugins.bundle.config') // A rename in the patch without a rename here is a card that writes nowhere, // so the id is read out of the patch rather than trusted to the test. assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`)) assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, card.options.key) }) test('nothing is registered while the Host does not serve the namespace', async () => { const { module: mod } = await loadClient() const { seen } = drive(mod, { served: false }) assert.equal(seen.formNs, 'facet-policy', 'the form is still bound') // Neither the card nor the button: each would be a control that writes nowhere. assert.deepEqual(seen.registrations, []) }) test('the card stages exactly the deployment fields the host marks volatile', async () => { const { module: mod, staged } = await loadClient() drive(mod) const volatileFields = Object.entries(Config.dict) .filter(([, schema]) => schema.meta.volatile === true) .map(([key]) => key) // `scopes` is volatile too, but it is not a deployment field and must not be a // control on the card: it is per-session state, written by the context panel // and keyed by session id. Staging it here would render a dictionary blob a // user could not sensibly edit, and would put the panel's store on the one // surface that edits the deployment. assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it') const declared = volatileFields.filter((key) => key !== 'scopes') // A field served but never staged is invisible in the UI; a field staged but // never served makes `whileServed` withhold the whole card. assert.deepEqual( staged.map((spec) => spec.field).sort(), declared.sort(), 'the card and the host schema have drifted apart', ) }) test('the choice field refuses a value the schema would reject, and clears on empty', async () => { const { module: mod, staged } = await loadClient() drive(mod) const mode = staged.find((spec) => spec.field === 'mode') assert.equal(mode.parse('deny').value, 'deny') assert.equal(mode.parse(' DENY ').value, 'deny') assert.deepEqual(mode.parse(''), { kind: 'clear' }) // Undefined marks the draft invalid, which blocks the save rather than // coercing a typo into a mode the Host would reject anyway. assert.equal(mode.parse('maybe'), undefined) }) test('the list field clears rather than storing an empty array that shadows the default', async () => { const { module: mod, staged } = await loadClient() drive(mod) const roots = staged.find((spec) => spec.field === 'extraRoots') assert.deepEqual(roots.parse('a, b\nc').value, ['a', 'b', 'c']) assert.deepEqual(roots.parse(' '), { kind: 'clear' }) assert.equal(roots.format(['a', 'b']), 'a, b') assert.equal(roots.format(undefined), '') }) test('the card renders both views without reaching for anything it was not given', async () => { const { module: mod } = await loadClient() const { seen } = drive(mod) const { component: card } = registrationFor(seen, 'plugins.bundle.config') assert.equal(typeof card, 'function') assert.equal(card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.') const tree = card({ view: 'page', useFacetCard: (select) => select({ available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false, mode: { text: 'deny', overridden: false, invalid: false }, extraRoots: { text: 'src/shared', overridden: true, invalid: false }, }), edit: () => {}, resetField: () => {}, save: () => {}, discard: () => {}, }) // The form frame, with the segmented control reading the staged mode. Children // ride the `children` prop, which is how the platform's own cards pass them. assert.equal(tree.type, 'SettingsForm') const controls = tree.props.children.children assert.equal(controls[0].type, 'div') assert.equal(controls[1].type, 'SettingsValueField') assert.equal(controls[0].children[1].props.value, 'deny') assert.equal(controls[1].props.text, 'src/shared') }) test('the client half loads the packages whose slots it registers into', async () => { const pkg = (await import('../package.json', { with: { type: 'json' } })).default const inject = pkg.dsh.client.inject // Registering into a slot is a dependency on the package that declares it. // `slots.inject` waits for a slot rather than creating one, so a half that // names a slot belonging to a package the boot graph has not loaded yet // registers nothing at all — and silently, because nothing errors. assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config') assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left') assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms') // Not a slot this half registers into: the panel's directory listing drives // `uiWorkspace.listDirectory`, so the package that provides it has to be in // the boot graph before this half asks for it. assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-workspace'), 'browses through uiWorkspace') }) // ── the context button and panel ───────────────────────────────────────────── test('the context button appears for a facet session and nowhere else', async () => { const { module: mod } = await loadClient() const { seen } = drive(mod) const { component: button, options } = registrationFor(seen, 'conversation.input.left') const { form } = options.inject() const render = (preset) => button({ sessionId: 'session-1', useSessions: (select) => select({ byId: { 'session-1': { projectionValues: { agentPreset: preset } } } }), locked: false, form, }) assert.notEqual(render('facet'), null) // Every other preset in the deployment is untouched: a control for a plugin // that is not running would be worse than no control. assert.equal(render('standard'), null) assert.equal(render(undefined), null) }) test('declaredFor normalises whatever the document holds', async () => { const { module: mod } = await loadClient() assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 'src/x', extras: ['p'] } } } }, 'a'), { focus: 'src/x', extras: ['p'], }) // A session with no entry, a document with no scopes, and junk in either all // answer with the same empty declaration rather than throwing in a render. const empty = { focus: '', extras: [] } assert.deepEqual(mod.declaredFor({ value: {} }, 'a'), empty) assert.deepEqual(mod.declaredFor({}, 'a'), empty) assert.deepEqual(mod.declaredFor({ value: { scopes: { a: 'nonsense' } } }, 'a'), empty) assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 7, extras: ['ok', 3, null] } } } }, 'a'), { focus: '', extras: ['ok'], }) }) test('writeDeclaration writes one whole declaration, and removes an empty one', async () => { const { module: mod } = await loadClient() const calls = [] const form = { mutate: (ops) => (calls.push(ops), true) } mod.writeDeclaration(form, 'session-1', { focus: ' src/x ', extras: ['p', ' ', 'q'] }) mod.writeDeclaration(form, 'session-1', { focus: '', extras: [] }) // One path, one write: a queued edit can never half-apply. assert.deepEqual(calls[0], [{ op: 'set', path: ['scopes', 'session-1'], value: { focus: 'src/x', extras: ['p', 'q'] } }]) // Both fields empty means "the session directory", which is the schema default // — so it is removed rather than stored as an empty override. assert.deepEqual(calls[1], [{ op: 'unset', path: ['scopes', 'session-1'] }]) }) test('the panel renders the scope, the guidance, and a way to add and remove', async () => { const { module: mod } = await loadClient() const written = [] const form = { getSnapshot: () => ({ status: 'ready', writable: true, revision: 4, value: { mode: 'deny', scopes: { 'session-1': { focus: 'src/room', extras: ['CONTROLLER.md'] } } }, }), subscribe: () => () => {}, mutate: (ops) => (written.push(ops), true), } const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', cwd: '/work/root', browse: { available: () => true, list: () => Promise.resolve() }, onClose: () => {}, }) assert.equal(tree.type, 'Modal') // The class is the whole width fix: the primitive's own card is 380px and it // takes only a class name, so the stylesheet this bundle installs outranks it. assert.equal(tree.props.className, mod.PANEL_CLASS) // Walk the tree for the text and controls a user would see. const texts = textsOf(tree) const buttons = buttonsOf(tree) const inputs = [] walkTree(tree, (node) => { if (node.type === 'input') inputs.push(node) }) const labelOf = (node) => (node.children ?? [])[0] const prose = texts.join('\n') assert.match(prose, /What this conversation may see/) // The two tabs the preset picker cannot show for a custom preset live here, // folded away so the controls are not buried under them. assert.match(prose, /Mode details/) assert.match(prose, /How to use/) // The honest bit about this deployment, so the panel does not promise a // question no approval channel can carry. assert.match(prose, /no approval channel/) assert.match(prose, /danger-full-access/) // The declared scope is on screen, and the existing extra has its own control. assert.ok(texts.includes('src/room'), 'the focus is shown as the declared path') assert.ok(texts.some((text) => text.includes('/work/root')), 'the session folder relative to what is named') assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1) // Both controls are the listing. `Set` is gone with the box it committed: // picking writes the declaration straight away, as every other control here // already did. assert.ok(buttons.some((button) => labelOf(button) === 'Browse\u2026')) assert.ok(buttons.some((button) => labelOf(button) === 'Clear')) assert.ok(buttons.some((button) => labelOf(button) === '+ Add path or file\u2026')) assert.ok(!buttons.some((button) => labelOf(button) === 'Set')) assert.deepEqual(inputs, [], 'the panel itself has no free-text box left') }) test('a deployment with no directory browser keeps the panel and says so', async () => { const { module: mod } = await loadClient() const form = { getSnapshot: () => ({ status: 'ready', writable: true, revision: 1, value: { mode: 'deny', scopes: {} } }), subscribe: () => () => {}, mutate: () => true, } const tree = mod.FacetContextPanel({ form, sessionId: 's', cwd: '', browse: mod.directoryBrowse({ get: () => undefined }), onClose: () => {}, }) const texts = textsOf(tree) const label = (button) => (button.children ?? [])[0] // The panel is the only way to declare scope, so it renders without a // browser: the listing affordances go dead, the explanation stays. assert.deepEqual( buttonsOf(tree) .filter((button) => label(button) === 'Browse\u2026') .map((button) => button.props.disabled), [true], ) assert.deepEqual( buttonsOf(tree) .filter((button) => label(button) === '+ Add path or file\u2026') .map((button) => button.props.disabled), [true], ) assert.ok(texts.some((text) => text.includes('no directory browser'))) // The mode is read from the document rather than from a slot prop that never // carried it, so the line does not claim "ask" whatever the setting is. assert.ok(texts.some((text) => text.includes('Deployment mode: deny'))) }) // ── the directory listing ──────────────────────────────────────────────────── /** A listing shaped the way the Host's browse primitive answers. */ function listingFixture() { return { path: '/work/root/src', home: '/home/dev', crumbs: [ { name: '/', path: '/', hidden: false }, { name: 'work', path: '/work', hidden: false }, { name: 'root', path: '/work/root', hidden: false }, { name: 'src', path: '/work/root/src', hidden: false }, ], entries: [ { name: 'shared', path: '/work/root/src/shared', hidden: false }, { name: '.git', path: '/work/root/src/.git', hidden: true }, ], truncated: false, } } /** Let the model's own promise chain settle. */ function settle() { return new Promise((resolve) => setTimeout(resolve, 0)) } /** Every button in a rendered tree, in document order. */ function buttonsOf(tree) { const found = [] walkTree(tree, (node) => { if (node.type === 'button') found.push(node) }) return found } /** Every string in a rendered tree, in document order. */ function textsOf(tree) { const found = [] walkTree(tree, (node) => { if (typeof node === 'string') found.push(node) }) return found } /** Walk a rendered tree the way it was built: children as arguments or as a prop. */ function walkTree(node, visit) { if (typeof node === 'string') return visit(node) if (node === null || node === undefined || typeof node !== 'object') return if (Array.isArray(node)) { for (const child of node) walkTree(child, visit) return } visit(node) if (Array.isArray(node.children)) node.children.forEach((child) => walkTree(child, visit)) // `footer` is a prop rather than a child, and it is rendered: a control there // is as much part of the tree as one in the body. walkTree(node.props?.children, visit) walkTree(node.props?.footer, visit) } test('a path is declared relative to the session folder wherever it can be', async () => { const { module: mod } = await loadClient() assert.equal(mod.relativeTo('/work/root', '/work/root'), '', 'the session folder itself has no name of its own') assert.equal(mod.relativeTo('/work/root', '/work/root/src/shared'), 'src/shared') assert.equal(mod.relativeTo('/work/root/dsh-facet', '/work/root'), '..') assert.equal(mod.relativeTo('/work/root/dsh-facet/lib', '/work/root'), '../..') // Nothing shared: another tree, or another drive. Absolute is the only // spelling that still names it. assert.equal(mod.relativeTo('/work/root', '/srv/other'), '/srv/other') assert.equal(mod.relativeTo('C:\\work\\root', 'D:\\work\\root'), 'D:\\work\\root') assert.equal(mod.relativeTo('', '/srv/other'), '/srv/other') // Windows separators split and join like POSIX ones. assert.equal(mod.relativeTo('C:\\work\\root', 'C:\\work\\other'), '../other') assert.equal(mod.absoluteFrom('/work/root', 'src/shared'), '/work/root/src/shared') assert.equal(mod.absoluteFrom('/work/root', '/srv/other'), '/srv/other') assert.equal(mod.absoluteFrom('/work/root', ''), '', 'empty stays empty: it means the session folder') assert.equal(mod.absoluteFrom('', 'src/shared'), 'src/shared', 'with no folder to join, a relative path is left alone') assert.equal(mod.isAbsolutePath('C:\\x'), true) assert.equal(mod.isAbsolutePath('\\\\server\\share'), true) assert.equal(mod.isAbsolutePath('src/x'), false) }) test('the browse face is read from uiWorkspace at the call, not at registration', async () => { const { module: mod } = await loadClient() // Absent service: the probe answers false and the call rejects with something // the picker can show rather than a TypeError from an undefined property. const missing = mod.directoryBrowse({ get: () => undefined }) assert.equal(missing.available(), false) await assert.rejects(missing.list('/tmp'), /no directory browser/) // The file lister is a second service and probes separately: a deployment can // have the folder browser without the workspace file service. assert.equal(missing.files.available(), false) await assert.rejects(missing.files.list('session-1', '/tmp'), /cannot list files/) const calls = [] const present = mod.directoryBrowse({ get: (name) => ({ uiWorkspace: { listDirectory: (path, signal) => { calls.push(['folders', path, signal]) return Promise.resolve('listing') }, }, 'remote.workspaceFiles': { list: (sessionId, path, signal) => { calls.push(['files', sessionId, path, signal]) return Promise.resolve({ ok: true, value: 'workspace listing' }) }, }, })[name], }) assert.equal(present.available(), true) assert.equal(await present.list('/tmp', 'signal'), 'listing') assert.deepEqual(calls, [['folders', '/tmp', 'signal']]) // The remote answers with a result envelope rather than rejecting, so its // failure branch is turned into one here — the model's catch expects a throw. assert.equal(present.files.available(), true) assert.equal(await present.files.list('session-1', '/work/root', 'signal'), 'workspace listing') assert.deepEqual(calls[1], ['files', 'session-1', '/work/root', 'signal']) const refusing = mod.directoryBrowse({ get: (name) => name === 'remote.workspaceFiles' ? { list: () => Promise.resolve({ ok: false, error: new Error('workspace files are unavailable') }) } : undefined, }) await assert.rejects(refusing.files.list('session-1', '/work/root'), /workspace files are unavailable/) }) test('the picker model lists a level, supersedes a stale one, and keeps a typed path on failure', async () => { const { module: mod } = await loadClient() const asked = [] const model = new mod.DirectoryPickerModel({ folders: (path) => { asked.push(path) return Promise.resolve(listingFixture()) }, root: '/work/root', start: '/work/root/src', }) await settle() assert.deepEqual(asked, ['/work/root/src'], 'the opening level is listed, not guessed at') assert.equal(model.getSnapshot().listing.base, '/work/root/src') assert.equal(model.getSnapshot().draft, '/work/root/src', 'the field follows a successful listing') assert.equal(model.getSnapshot().loading, false) // A second navigation wins even when the first is still in flight: the // sequence number is what stops an early listing landing on a later view. model.open('/work/root/other') model.setDraft('notes/CONTROLLER.md') // A failure keeps the level on screen and the path as typed: a listing can // fail on a path this panel may still legitimately declare. const failing = new mod.DirectoryPickerModel({ folders: () => Promise.reject(Object.assign(new Error('transport'), { rpcError: { message: 'directory-unreadable' } })), root: '/work/root', start: 'notes/CONTROLLER.md', }) await settle() assert.equal(failing.getSnapshot().error, 'directory-unreadable', 'the Host business message is preferred') assert.equal(failing.getSnapshot().draft, 'notes/CONTROLLER.md') }) test('the picker lists folders, hides hidden ones until asked, and answers in declared form', async () => { const { module: mod } = await loadClient() const model = new mod.DirectoryPickerModel({ folders: () => Promise.resolve(listingFixture()), root: '/work/root', start: '/work/root/src', }) await settle() const picked = [] const render = () => mod.DirectoryPicker({ model, root: '/work/root', target: 'focus', disabled: false, onClose: () => {}, onPick: (path) => picked.push(path), }) const tree = render() assert.equal(tree.type, 'Modal') assert.equal(tree.props.className, mod.PICKER_CLASS) assert.equal(tree.props.title, 'Choose the focus folder') // The level is the rows, one per listed folder; the trail above them is the // ancestry, and a hidden entry stays hidden until the toggle asks for it. const rowTitles = (node) => buttonsOf(node) .filter((button) => button.props.className === mod.ROW_CLASS) .map((button) => button.props.title) assert.deepEqual(rowTitles(tree), ['/work/root/src/shared']) assert.ok( buttonsOf(tree).some((button) => button.props.title === '/work/root' && (button.children ?? [])[0] === 'root'), 'the trail offers the ancestry as jump targets', ) // The footer's own action takes the field's value and hands it back in the // declared form, so the workspace can move without the grant meaning // something else. const footer = tree.props.footer const pickButton = footer.find((entry) => (entry.children ?? [])[0] === 'Set focus') assert.notEqual(pickButton, undefined, 'the footer commits the picked path') pickButton.props.onClick() assert.deepEqual(picked, ['src']) model.setShowHidden(true) assert.deepEqual(rowTitles(render()), ['/work/root/src/shared', '/work/root/src/.git']) // A row is a way down, not a pick: standing in the folder is what chooses it. model.open('/work/root/other') await settle() }) test('the extras picker names what it accepts and says what the list holds', async () => { const { module: mod } = await loadClient() const model = new mod.DirectoryPickerModel({ folders: () => Promise.resolve(listingFixture()), root: '/work/root', start: '/work/root/src', }) await settle() const tree = mod.DirectoryPicker({ model, root: '/work/root', target: 'extra', disabled: false, onClose: () => {}, onPick: () => {}, }) // "folder or path" promised one thing and offered another. The rows now carry // files, and the copy says what a row of each kind does. assert.equal(tree.props.title, 'Add a path or a file') const texts = textsOf(tree) assert.ok(texts.includes('Path or file'), 'the field is named for what it accepts') assert.ok( texts.some((text) => text.includes('Folders open; a file is taken')), 'the copy explains what a file row does', ) assert.ok( buttonsOf(tree).some((button) => (button.children ?? [])[0] === 'Add'), 'the action is Add for an extra, not Set focus', ) }) // ── files in the extras picker ─────────────────────────────────────────────── // // Two Host listers, one of which carries files. The folder primitive browses // anywhere and returns folders; the workspace file listing returns files and // folders but confines itself to the session's workspace. The extras picker // takes the file listing wherever the Host allows it, and the folder listing // everywhere else, so a path above the session folder is still reachable. /** A level shaped the way `remote.workspaceFiles.list` answers. */ function workspaceFixture() { return { path: 'src', entries: [ { name: 'shared', type: 'directory' }, { name: '.git', type: 'directory' }, { name: 'index.ts', type: 'file', size: 120 }, { name: 'CONTROLLER.md', type: 'file', size: 900 }, { name: 'socket', type: 'other' }, ], truncated: false, } } test('the model takes the file listing inside the workspace and the folder listing above it', async () => { const { module: mod } = await loadClient() const asked = [] const model = new mod.DirectoryPickerModel({ folders: (path) => { asked.push(['folders', path]) return Promise.resolve(listingFixture()) }, files: (sessionId, path) => { asked.push(['files', sessionId, path]) return Promise.resolve(workspaceFixture()) }, sessionId: 'session-1', root: '/work/root', start: '/work/root/src', }) await settle() // Inside the workspace: the file listing, normalised into the picker's own // shape, with the workspace-relative paths joined onto the session folder. assert.deepEqual(asked, [['files', 'session-1', '/work/root/src']]) const level = model.getSnapshot().listing assert.equal(level.base, '/work/root/src') assert.equal(level.files, true, 'this level could carry file rows') assert.deepEqual( level.entries.map((entry) => [entry.name, entry.path, entry.directory, entry.hidden]), [ ['shared', '/work/root/src/shared', true, false], ['.git', '/work/root/src/.git', true, true], ['index.ts', '/work/root/src/index.ts', false, false], ['CONTROLLER.md', '/work/root/src/CONTROLLER.md', false, false], ['socket', '/work/root/src/socket', false, false], ], ) // The trail is the workspace's own, root first: the file listing stops at the // session folder, so there is nothing above it to offer. assert.deepEqual( level.crumbs.map((crumb) => crumb.path), ['/work/root', '/work/root/src'], ) // Above the workspace root: the Host confines the file listing to the // workspace, so the level falls back to folders — which browse anywhere. model.open('/work') await settle() assert.deepEqual(asked[1], ['folders', '/work']) assert.equal(model.getSnapshot().listing.files, false) }) test('a file listing that fails still leaves a browsable level', async () => { const { module: mod } = await loadClient() // The comparison the model makes is a prefix test on a path the Host may // resolve differently — a symlinked session folder, say — so a refusal from // the file listing is answered rather than shown: the folder listing runs. const model = new mod.DirectoryPickerModel({ folders: () => Promise.resolve(listingFixture()), files: () => Promise.reject(new Error('outside the workspace')), sessionId: 'session-1', root: '/work/root', start: '/work/root/src', }) await settle() assert.equal(model.getSnapshot().error, '') assert.equal(model.getSnapshot().listing.files, false) assert.equal(model.getSnapshot().listing.base, '/work/root/src') }) test('a file row is taken as the path to add rather than walked into', async () => { const { module: mod } = await loadClient() // Both listers refuse a path that names a file, which is what the Host does: // the file listing because a file has no children, the folder listing because // the same. Only a level can be listed. const isFile = (path) => /\.(md|ts)$/.test(String(path)) const refuseFile = (path, answer) => isFile(path) ? Promise.reject(new Error('not a directory')) : Promise.resolve(answer) const model = new mod.DirectoryPickerModel({ folders: (path) => refuseFile(path, listingFixture()), files: (sessionId, path) => refuseFile(path, workspaceFixture()), sessionId: 'session-1', root: '/work/root', start: '/work/root/src', }) await settle() const picked = [] const render = () => mod.DirectoryPicker({ model, root: '/work/root', target: 'extra', disabled: false, onClose: () => {}, onPick: (path) => picked.push(path), }) const rowAt = (tree, path) => buttonsOf(tree) .filter((button) => button.props.className === mod.ROW_CLASS) .find((button) => button.props.title === path) const fileRow = rowAt(render(), '/work/root/src/CONTROLLER.md') assert.notEqual(fileRow, undefined, 'the file is a row in the listing') assert.equal(fileRow.props.style.background, 'transparent', 'an unpicked file row is not marked') // A folder row carries the folder glyph and a file row the file-type one, which // is the only thing telling the two apart before the click. const glyphOf = (row) => (row.children ?? [])[0].children?.[0]?.type assert.equal(glyphOf(fileRow), 'FileTypeIcon') assert.equal(glyphOf(rowAt(render(), '/work/root/src/shared')), 'IconFolderCloseRegular') // Clicking it fills the field instead of listing it, and marks the row: a file // is taken, not entered. fileRow.props.onClick() assert.equal(model.getSnapshot().draft, '/work/root/src/CONTROLLER.md') assert.notEqual(rowAt(render(), '/work/root/src/CONTROLLER.md').props.style.background, 'transparent') // Enter on a file the level lists asks for a listing of a file, which is not a // failure: the level stays and nothing is reported. model.open(model.getSnapshot().draft) await settle() assert.equal(model.getSnapshot().error, '') assert.equal(model.getSnapshot().listing.base, '/work/root/src', 'the level survives it') // The footer then adds exactly that, in the declared form. const addButton = render().props.footer.find((entry) => (entry.children ?? [])[0] === 'Add') addButton.props.onClick() assert.deepEqual(picked, ['src/CONTROLLER.md']) }) test('files are offered to the extras control only, and only where the Host has them', async () => { const { module: mod } = await loadClient() const browse = mod.directoryBrowse({ get: (name) => (name === 'remote.workspaceFiles' ? { list: () => {} } : undefined), }) assert.equal(mod.fileLister(browse, 'extra'), browse.files.list, 'an extra can name a file') // A focus is a folder, and file rows there would only offer something that // cannot be chosen. assert.equal(mod.fileLister(browse, 'focus'), undefined) // No file service, or no browse face at all: the folder listing stands. assert.equal(mod.fileLister(mod.directoryBrowse({ get: () => undefined }), 'extra'), undefined) assert.equal(mod.fileLister({}, 'extra'), undefined) assert.equal(mod.fileLister(undefined, 'extra'), undefined) }) test('the picker says so when a level is folders only, and not when it is not', async () => { const { module: mod } = await loadClient() const model = new mod.DirectoryPickerModel({ folders: () => Promise.resolve(listingFixture()), root: '/work/root', start: '/work/root/src', }) await settle() const tree = mod.DirectoryPicker({ model, root: '/work/root', target: 'extra', disabled: false, onClose: () => {}, onPick: () => {}, }) assert.ok( textsOf(tree).some((text) => text.includes('Files are listed inside the session folder only')), 'a folders-only level explains itself', ) // A level that does carry files needs no note about it. const withFiles = new mod.DirectoryPickerModel({ folders: () => Promise.resolve(listingFixture()), files: () => Promise.resolve(workspaceFixture()), sessionId: 'session-1', root: '/work/root', start: '/work/root/src', }) await settle() assert.equal( textsOf( mod.DirectoryPicker({ model: withFiles, root: '/work/root', target: 'extra', disabled: false, onClose: () => {}, onPick: () => {}, }), ).some((text) => text.includes('Files are listed inside the session folder only')), false, ) })