"Add folder or path" promised one thing and offered another: the listing is folders only, because the Host's browse primitive has no file rows, while the extras legitimately name files — `../../CONTROLLER.md` is the example the panel itself gives. The action is "Add path or file", its field is "Path or file", and the hint says which of the two the list can show and how the other one is added. Nothing about the interaction moved; the copy was the part that was wrong.
701 lines
28 KiB
JavaScript
701 lines
28 KiB
JavaScript
/**
|
|
* 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 glyph; a client half may only use what the primitives
|
|
// actually export, so the stand-in is named the same way.
|
|
IconFolderCloseRegular: 'IconFolderCloseRegular',
|
|
/** 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/)
|
|
|
|
const calls = []
|
|
const present = mod.directoryBrowse({
|
|
get: () => ({
|
|
listDirectory: (path, signal) => {
|
|
calls.push([path, signal])
|
|
return Promise.resolve('listing')
|
|
},
|
|
}),
|
|
})
|
|
assert.equal(present.available(), true)
|
|
assert.equal(await present.list('/tmp', 'signal'), 'listing')
|
|
assert.deepEqual(calls, [['/tmp', 'signal']])
|
|
})
|
|
|
|
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({
|
|
list: (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.path, '/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: the Host lists
|
|
// folders, and the extras legitimately name a file.
|
|
const failing = new mod.DirectoryPickerModel({
|
|
list: () => 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({
|
|
list: () => 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({
|
|
list: () => 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 are
|
|
// folders, and a file is added by naming it. The copy says both.
|
|
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('The list holds folders')),
|
|
'the copy explains why no file rows are shown',
|
|
)
|
|
assert.ok(
|
|
buttonsOf(tree).some((button) => (button.children ?? [])[0] === 'Add'),
|
|
'the action is Add for an extra, not Set focus',
|
|
)
|
|
})
|
|
|