dsh-molbio-tools 0.22.0 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +121 -0
- package/README.md +21 -9
- package/build/client-entry.mjs +159 -0
- package/build/panel-core.mjs +38 -0
- package/docs/client-panel.md +21 -5
- package/docs/history.md +21 -0
- package/docs/maintainer.md +1 -1
- package/docs/roadmap.md +12 -2
- package/docs/store-evidence.md +29 -18
- package/docs/workflow.md +1 -1
- package/index.mjs +39 -8
- package/lib/client.js +198 -1
- package/package.json +5 -3
- package/test/client.mjs +62 -3
- package/test/contract.mjs +139 -17
- package/test/map-card.mjs +4 -2
- package/test/panel-render.mjs +77 -1
- package/test/smoke.mjs +49 -0
package/test/contract.mjs
CHANGED
|
@@ -102,6 +102,70 @@ function hostHalf(pkg) {
|
|
|
102
102
|
return existsSync(path) ? { path, text: readFileSync(path, 'utf8') } : undefined;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The declaration evidence for a contract check, from whichever copy this
|
|
107
|
+
* installation carries.
|
|
108
|
+
*
|
|
109
|
+
* The npm packages ship `lib/types/*.d.ts`; the DESKTOP build (app.asar)
|
|
110
|
+
* strips them to save space (`dsh-fs` ships no `lib/types` at all and
|
|
111
|
+
* `dsh-llm` keeps only the declarations `lib/typert.host.js` embeds for its
|
|
112
|
+
* type registry). The contract is the same in both builds, so a desktop
|
|
113
|
+
* verification must not report the packaging difference as a contract change.
|
|
114
|
+
* The fallbacks therefore read the same declarations where the build kept them;
|
|
115
|
+
* `\n`/`\"` escapes in the embedded copy are unescaped so one regex serves
|
|
116
|
+
* both.
|
|
117
|
+
*
|
|
118
|
+
* This is still a guard: when NO candidate exists the check fails loudly
|
|
119
|
+
* instead of skipping, and `the declaration guard still fails when a
|
|
120
|
+
* declaration moves` below drives the same assertions with mutated text.
|
|
121
|
+
*
|
|
122
|
+
* @param pkg the `@deepseek-ai/*` package name.
|
|
123
|
+
* @param candidates package-relative paths, in preference order.
|
|
124
|
+
* @returns the first existing file's text and path.
|
|
125
|
+
*/
|
|
126
|
+
function declarationEvidence(pkg, candidates) {
|
|
127
|
+
const dir = packageDir(pkg);
|
|
128
|
+
for (const relative of candidates) {
|
|
129
|
+
const path = join(dir, relative);
|
|
130
|
+
if (!existsSync(path)) continue;
|
|
131
|
+
const text = readFileSync(path, 'utf8').replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\'/g, "'");
|
|
132
|
+
return { text, path };
|
|
133
|
+
}
|
|
134
|
+
assert.fail(`${pkg} ships none of ${candidates.join(', ')} — the declaration guard cannot read its evidence`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The ImageBlock half of the picture contract, over a declaration text. */
|
|
138
|
+
function assertImageBlockDeclared(types) {
|
|
139
|
+
const imageBlock = /interface ImageBlock \{[\s\S]*?\n\}/.exec(types)?.[0] ?? '';
|
|
140
|
+
assert.match(imageBlock, /type: 'image'/, 'ImageBlock still declares type "image"');
|
|
141
|
+
assert.match(imageBlock, /attachment: ImageAttachmentRef/, 'and still carries a durable attachment reference');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The ImageAttachmentRef fields our output value mirrors. */
|
|
145
|
+
function assertImageRefDeclared(types) {
|
|
146
|
+
const ref = /interface ImageAttachmentRef \{[\s\S]*?\n\}/.exec(types)?.[0] ?? '';
|
|
147
|
+
for (const field of ['attachmentId', 'mediaType', 'bytes', 'width', 'height']) {
|
|
148
|
+
assert.ok(ref.includes(`${field}:`), `ImageAttachmentRef still carries ${field}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The store method the picture hand-off calls: the TypeScript declaration on a
|
|
154
|
+
* build that ships types, or the runtime method that implements it on the
|
|
155
|
+
* desktop build, which strips `lib/types`.
|
|
156
|
+
*/
|
|
157
|
+
function assertSaveImageDeclared(indexTypes) {
|
|
158
|
+
const declared = /saveImage\(input: SaveImageAttachment\): Promise<ImageAttachmentRef>/.test(indexTypes);
|
|
159
|
+
const implemented = /async saveImage\(input\)/.test(indexTypes);
|
|
160
|
+
assert.ok(declared || implemented, 'the store still exposes saveImage');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The text-only filesystem seam the picture workaround rests on. */
|
|
164
|
+
function assertFsSeamDeclared(fsText, fsIndexText) {
|
|
165
|
+
assert.ok(fsText.includes('FS_NOT_TEXT'), 'the filesystem seam still carries FS_NOT_TEXT');
|
|
166
|
+
assert.ok(!/\bwriteBytes\b/.test(fsIndexText), 'no binary write primitive appeared on the fs seam');
|
|
167
|
+
}
|
|
168
|
+
|
|
105
169
|
/** Every file under a directory, bounded (the shell bundle ships under dist/). */
|
|
106
170
|
function filesUnder(dir, limit = 400) {
|
|
107
171
|
const out = [];
|
|
@@ -277,6 +341,24 @@ check('the built bundle registers the seats and services the framework expects',
|
|
|
277
341
|
// The inject factory must hand over exactly the services the framework does
|
|
278
342
|
// NOT provide, so it can never shadow sessionId or a synthesised hook.
|
|
279
343
|
assert.ok(/inject: \(\) => \(\{ remote \}\)/.test(bundle), 'the body injects only the Remote face');
|
|
344
|
+
|
|
345
|
+
// The plugin-detail key section depends on TWO upstream facts: the plugin
|
|
346
|
+
// manager still declares the seat, and the settings controller still exposes
|
|
347
|
+
// the credentials Remote namespace with the argument order we call. Both are
|
|
348
|
+
// silent if they move (the section simply never renders), so pin them here.
|
|
349
|
+
assert.ok(bundle.includes('plugins.detail.section'), 'the bundle waits for the plugin-manager detail seat');
|
|
350
|
+
for (const call of ['credentials.describe(', 'credentials.set(', 'credentials.unset(']) {
|
|
351
|
+
assert.ok(bundle.includes(call), `the key section calls ${call}…)`);
|
|
352
|
+
}
|
|
353
|
+
assert.ok(bundle.includes("ctx.inject(['remote.credentials']"), 'the key section declares the credentials FIBER INJECTION (a plain property is undefined)');
|
|
354
|
+
const manager = clientBundle('@deepseek-ai/dsh-client-ui-plugin-manager');
|
|
355
|
+
assert.ok(manager !== undefined && manager.text.includes('plugins.detail.section'), 'the plugin manager still declares plugins.detail.section');
|
|
356
|
+
const settings = hostHalf('@deepseek-ai/dsh-api-settings-controller');
|
|
357
|
+
assert.ok(settings !== undefined, 'the settings controller is installed');
|
|
358
|
+
assert.ok(settings.text.includes('namespace: "credentials"'), 'the credentials Remote namespace still exists');
|
|
359
|
+
assert.match(settings.text, /async describe\(refs\)/, 'describe takes the reference list first');
|
|
360
|
+
assert.match(settings.text, /async set\(ref, value\)/, 'set takes (ref, value)');
|
|
361
|
+
assert.match(settings.text, /async unset\(ref\)/, 'unset takes the reference');
|
|
280
362
|
});
|
|
281
363
|
|
|
282
364
|
check('the package manifest keeps the client declaration the scan keys on', () => {
|
|
@@ -460,10 +542,12 @@ check('every seat claim waits for the declaration (slots.inject, not a bare regi
|
|
|
460
542
|
|
|
461
543
|
// Our artifact: every `register` must sit inside a wait for that same seat.
|
|
462
544
|
const bundle = readFileSync(join(packageRoot, 'lib', 'client.js'), 'utf8');
|
|
463
|
-
|
|
545
|
+
// The receiver may be a fiber context (`keyCtx.slots`), not only `ctx.slots`:
|
|
546
|
+
// the key section registers inside a ctx.inject gate.
|
|
547
|
+
const waited = [...bundle.matchAll(/slots\.inject\('([^']+)', \(\) => (?:[A-Za-z_$][\w$]*\.)?slots\.register\(\{\s*name: '([^']+)'/g)];
|
|
464
548
|
assert.ok(waited.length > 0, 'the bundle claims its seats through slots.inject');
|
|
465
549
|
for (const [, seat, name] of waited) assert.equal(seat, name, `the claim of ${name} waits for the seat it registers into`);
|
|
466
|
-
const registers = (bundle.match(/(?:
|
|
550
|
+
const registers = (bundle.match(/(?:[A-Za-z_$][\w$]*\.)?slots\.register\(/g) ?? []).length;
|
|
467
551
|
assert.equal(registers, waited.length, 'and no registration exists outside such a wait');
|
|
468
552
|
});
|
|
469
553
|
|
|
@@ -607,20 +691,13 @@ check('a tool may show the model a picture the way read_image does', () => {
|
|
|
607
691
|
// Our output.render builds `{ type: 'image', attachment: {...} }`; that shape
|
|
608
692
|
// must stay the harness's own ImageBlock, and the reference fields must stay
|
|
609
693
|
// the ones our output value mirrors.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const attachmentTypes = readFileSync(join(packageDir('@deepseek-ai/dsh-attachment'), 'lib', 'types', 'types.d.ts'), 'utf8');
|
|
616
|
-
const ref = /interface ImageAttachmentRef \{[\s\S]*?\n\}/.exec(attachmentTypes)?.[0] ?? '';
|
|
617
|
-
for (const field of ['attachmentId', 'mediaType', 'bytes', 'width', 'height']) {
|
|
618
|
-
assert.ok(ref.includes(`${field}:`), `ImageAttachmentRef still carries ${field}`);
|
|
619
|
-
}
|
|
694
|
+
assertImageBlockDeclared(declarationEvidence('@deepseek-ai/dsh-llm', ['lib/types/types.d.ts', 'lib/typert.host.js']).text);
|
|
695
|
+
// The desktop build strips dsh-attachment's own `lib/types`, so the durable
|
|
696
|
+
// reference is read from the copy `dsh-llm/lib/typert.host.js` embeds.
|
|
697
|
+
assertImageRefDeclared(declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/types.d.ts', 'lib/types/index.d.ts', '../dsh-llm/lib/typert.host.js']).text);
|
|
620
698
|
|
|
621
699
|
// The commit call, and that this deployment accepts what we encode.
|
|
622
|
-
|
|
623
|
-
assert.match(attachmentIndex, /saveImage\(input: SaveImageAttachment\): Promise<ImageAttachmentRef>/, 'the store still exposes saveImage');
|
|
700
|
+
assertSaveImageDeclared(declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/index.d.ts', '../dsh-attachment-local/lib/index.js']).text);
|
|
624
701
|
const attachmentLocal = readFileSync(join(packageDir('@deepseek-ai/dsh-attachment-local'), 'lib', 'index.js'), 'utf8');
|
|
625
702
|
assert.ok(attachmentLocal.includes('"image/png"'), 'this deployment still accepts image/png');
|
|
626
703
|
|
|
@@ -643,12 +720,57 @@ check('the workspace still cannot hold a binary file (the reason the picture rid
|
|
|
643
720
|
// the filesystem seam is text-only. If DSH grows a binary write, this check
|
|
644
721
|
// fails on purpose: revisit the design (png_path becomes possible) rather
|
|
645
722
|
// than silently keeping the workaround.
|
|
646
|
-
|
|
647
|
-
|
|
723
|
+
// `dsh-fs`'s own base class carries no code list; the refusal code lives in
|
|
724
|
+
// the local backend (the base is consulted last, only as a presence fallback).
|
|
725
|
+
const fsTypes = declarationEvidence('@deepseek-ai/dsh-fs', ['lib/types/types.d.ts', '../dsh-fs-local/lib/index.js', 'lib/index.js']).text;
|
|
648
726
|
const fsReadme = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'README.md'), 'utf8');
|
|
649
727
|
assert.match(fsReadme, /binary-safe mutations remain deferred/i, 'and still documents binary mutations as deferred');
|
|
650
728
|
const fsIndex = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'lib', 'index.js'), 'utf8');
|
|
651
|
-
|
|
729
|
+
assertFsSeamDeclared(fsTypes, fsIndex);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
check('the declaration guard still fails when a declaration moves (the build fallback is not vacuous)', () => {
|
|
733
|
+
// 0.22.2 taught the two checks above to read the declarations a DESKTOP build
|
|
734
|
+
// embeds instead of the `lib/types` it strips. A fallback that cannot fail
|
|
735
|
+
// would turn those checks into decoration, so drive every predicate with a
|
|
736
|
+
// mutated copy of the SAME evidence the installation supplied: each one must
|
|
737
|
+
// reject the mutation. (rules.md rule 9.)
|
|
738
|
+
const llm = declarationEvidence('@deepseek-ai/dsh-llm', ['lib/types/types.d.ts', 'lib/typert.host.js']).text;
|
|
739
|
+
const attachment = declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/types.d.ts', 'lib/types/index.d.ts', '../dsh-llm/lib/typert.host.js']).text;
|
|
740
|
+
const attachmentIndex = declarationEvidence('@deepseek-ai/dsh-attachment', ['lib/types/index.d.ts', '../dsh-attachment-local/lib/index.js']).text;
|
|
741
|
+
const fsText = declarationEvidence('@deepseek-ai/dsh-fs', ['lib/types/types.d.ts', '../dsh-fs-local/lib/index.js', 'lib/index.js']).text;
|
|
742
|
+
|
|
743
|
+
const imageBlock = /interface ImageBlock \{[\s\S]*?\n\}/.exec(llm)?.[0] ?? '';
|
|
744
|
+
assert.notEqual(imageBlock, '', 'the mutation experiment found a real ImageBlock declaration to mutate');
|
|
745
|
+
assert.throws(
|
|
746
|
+
() => assertImageBlockDeclared(llm.replace(imageBlock, imageBlock.replace("type: 'image'", "type: 'picture'"))),
|
|
747
|
+
'a moved ImageBlock type is caught',
|
|
748
|
+
);
|
|
749
|
+
assert.throws(
|
|
750
|
+
() => assertImageBlockDeclared(llm.replace(imageBlock, imageBlock.replace('ImageAttachmentRef', 'SomeOtherRef'))),
|
|
751
|
+
'a moved attachment reference is caught',
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
const refBlock = /interface ImageAttachmentRef \{[\s\S]*?\n\}/.exec(attachment)?.[0] ?? '';
|
|
755
|
+
assert.notEqual(refBlock, '', 'the mutation experiment found a real ImageAttachmentRef declaration to mutate');
|
|
756
|
+
assert.throws(
|
|
757
|
+
() => assertImageRefDeclared(attachment.replace(refBlock, refBlock.replace('attachmentId:', 'id:'))),
|
|
758
|
+
'a renamed attachment field is caught',
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
assert.throws(
|
|
762
|
+
() => assertSaveImageDeclared(attachmentIndex.split('saveImage(').join('putImage(')),
|
|
763
|
+
'a renamed saveImage is caught',
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
assert.throws(
|
|
767
|
+
() => assertFsSeamDeclared(fsText.split('FS_NOT_TEXT').join('FS_BINARY_REFUSED'), ''),
|
|
768
|
+
'a renamed FS error code is caught',
|
|
769
|
+
);
|
|
770
|
+
assert.throws(
|
|
771
|
+
() => assertFsSeamDeclared(fsText, 'function writeBytes() {}'),
|
|
772
|
+
'a binary write primitive is caught',
|
|
773
|
+
);
|
|
652
774
|
});
|
|
653
775
|
|
|
654
776
|
check('the harness decodes the PNG our rasterizer produces (and refuses a corrupt one)', async () => {
|
package/test/map-card.mjs
CHANGED
|
@@ -253,12 +253,14 @@ function mount(Component) {
|
|
|
253
253
|
const registrations = [];
|
|
254
254
|
const module = await import(entryPath.href);
|
|
255
255
|
const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview']);
|
|
256
|
-
|
|
256
|
+
const cardCtx = {
|
|
257
257
|
remote: {},
|
|
258
258
|
effect: (factory) => factory(),
|
|
259
|
+
inject: (_deps, callback) => ({ dispose() {}, value: callback(cardCtx) }),
|
|
259
260
|
sidebarRightTabs: { register: (definition) => registrations.push(definition) },
|
|
260
261
|
slots,
|
|
261
|
-
}
|
|
262
|
+
};
|
|
263
|
+
module.apply(cardCtx);
|
|
262
264
|
const MapCard = slots.registrations.find(
|
|
263
265
|
(entry) => entry.registration.name === 'tool.call.toolview' && entry.registration.key === 'molbio_plasmid_map',
|
|
264
266
|
).component;
|
package/test/panel-render.mjs
CHANGED
|
@@ -308,12 +308,17 @@ const module = await (async () => {
|
|
|
308
308
|
}
|
|
309
309
|
})();
|
|
310
310
|
|
|
311
|
-
const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview']);
|
|
311
|
+
const slots = createSlotsStub(['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview', 'plugins.detail.section']);
|
|
312
312
|
const ctx = {
|
|
313
313
|
remote: remote({}),
|
|
314
314
|
effect(factory) {
|
|
315
315
|
return factory();
|
|
316
316
|
},
|
|
317
|
+
// `apply` gates the key section on the credentials Remote namespace; this
|
|
318
|
+
// file supplies every dependency it asks for, like the default composition.
|
|
319
|
+
inject(_deps, callback) {
|
|
320
|
+
return { dispose() {}, value: callback(ctx) };
|
|
321
|
+
},
|
|
317
322
|
sidebarRightTabs: { register: (definition) => registrations.push(definition) },
|
|
318
323
|
slots,
|
|
319
324
|
};
|
|
@@ -594,6 +599,77 @@ const library = {
|
|
|
594
599
|
harness.unmount();
|
|
595
600
|
}
|
|
596
601
|
|
|
602
|
+
// ── the plugin-detail key section (write-only credential form) ─────────────
|
|
603
|
+
|
|
604
|
+
{
|
|
605
|
+
const claim = slots.registrations.find((entry) => entry.registration.name === 'plugins.detail.section');
|
|
606
|
+
assert.ok(claim, 'the key section registered once its seat was declared');
|
|
607
|
+
const store = { value: undefined, describe: [], set: [], unset: [], refuse: '' };
|
|
608
|
+
ctx.remote.credentials = {
|
|
609
|
+
async describe(refs) {
|
|
610
|
+
store.describe.push(refs);
|
|
611
|
+
return {
|
|
612
|
+
ok: true,
|
|
613
|
+
value: {
|
|
614
|
+
NCBI_API_KEY: {
|
|
615
|
+
configured: store.value !== undefined,
|
|
616
|
+
writable: true,
|
|
617
|
+
...store.value === undefined ? {} : { source: 'file' },
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
async set(ref, value) {
|
|
623
|
+
store.set.push([ref, value]);
|
|
624
|
+
if (store.refuse !== '') return { ok: false, error: { message: store.refuse } };
|
|
625
|
+
store.value = value;
|
|
626
|
+
return { ok: true, value: null };
|
|
627
|
+
},
|
|
628
|
+
async unset(ref) {
|
|
629
|
+
store.unset.push(ref);
|
|
630
|
+
store.value = undefined;
|
|
631
|
+
return { ok: true, value: null };
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
const subject = { kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } };
|
|
635
|
+
const wrapper = claim.component({ subject });
|
|
636
|
+
const harness = mount(wrapper.type);
|
|
637
|
+
globalThis.__molbioTestHooks = harness;
|
|
638
|
+
harness.setProps({ subject, remote: ctx.remote });
|
|
639
|
+
await harness.render();
|
|
640
|
+
assert.deepEqual(store.describe[0], ['NCBI_API_KEY'], 'the section asks only about its own reference');
|
|
641
|
+
assert.match(markup(harness.tree), /Not configured/, 'an empty store reads as not configured');
|
|
642
|
+
const input = elements(harness.tree, 'input')[0];
|
|
643
|
+
assert.equal(input.props.type, 'password', 'the key is typed into a password field');
|
|
644
|
+
input.props.onChange({ target: { value: ' key-123 ' } });
|
|
645
|
+
await harness.render();
|
|
646
|
+
const save = elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Save');
|
|
647
|
+
await save.props.onClick();
|
|
648
|
+
await flush();
|
|
649
|
+
await harness.render();
|
|
650
|
+
assert.deepEqual(store.set, [['NCBI_API_KEY', 'key-123']], 'Save trims and stores under NCBI_API_KEY');
|
|
651
|
+
assert.ok(store.describe.length >= 2, 'the status is re-read after the write');
|
|
652
|
+
assert.match(markup(harness.tree), /Configured \(source: file\)/, 'the status names the source, never the value');
|
|
653
|
+
assert.equal(elements(harness.tree, 'input')[0].props.value, '', 'the draft is cleared after saving');
|
|
654
|
+
|
|
655
|
+
const remove = elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Remove');
|
|
656
|
+
await remove.props.onClick();
|
|
657
|
+
await flush();
|
|
658
|
+
await harness.render();
|
|
659
|
+
assert.deepEqual(store.unset, ['NCBI_API_KEY'], 'Remove deletes the reference');
|
|
660
|
+
assert.match(markup(harness.tree), /Not configured/, 'the status follows the removal');
|
|
661
|
+
|
|
662
|
+
// A provider refusal is shown verbatim instead of being swallowed.
|
|
663
|
+
store.refuse = 'credential/rejected: the credentials file is unreadable';
|
|
664
|
+
elements(harness.tree, 'input')[0].props.onChange({ target: { value: 'x' } });
|
|
665
|
+
await harness.render();
|
|
666
|
+
await elements(harness.tree, 'button').find((node) => texts(node).join('') === 'Save').props.onClick();
|
|
667
|
+
await flush();
|
|
668
|
+
await harness.render();
|
|
669
|
+
assert.ok(markup(harness.tree).includes('credential/rejected: the credentials file is unreadable'), 'a refused write keeps the host message');
|
|
670
|
+
harness.unmount();
|
|
671
|
+
}
|
|
672
|
+
|
|
597
673
|
// ── effects are disposed on unmount ─────────────────────────────────────────
|
|
598
674
|
|
|
599
675
|
{
|
package/test/smoke.mjs
CHANGED
|
@@ -1989,6 +1989,55 @@ delete process.env.MOLBIO_NCBI_API_KEY;
|
|
|
1989
1989
|
}
|
|
1990
1990
|
}
|
|
1991
1991
|
|
|
1992
|
+
{
|
|
1993
|
+
// the harness credential store outranks the environment without ever
|
|
1994
|
+
// materializing the key in process.env; an explicit argument still wins.
|
|
1995
|
+
const saved = process.env.NCBI_API_KEY;
|
|
1996
|
+
process.env.NCBI_API_KEY = 'env-loses';
|
|
1997
|
+
extraServices.set('credentials', {
|
|
1998
|
+
async resolve(ref) {
|
|
1999
|
+
return ref === 'NCBI_API_KEY' ? { value: 'store-key', source: 'store' } : undefined;
|
|
2000
|
+
},
|
|
2001
|
+
});
|
|
2002
|
+
try {
|
|
2003
|
+
mockWeb.calls.length = 0;
|
|
2004
|
+
const stored = await run('molbio_pubmed_search', { query: 'stored key' });
|
|
2005
|
+
assert.equal(stored.backend, 'pubmed-eutils');
|
|
2006
|
+
assert.ok(mockWeb.calls.length === 2 && mockWeb.calls.every((url) => url.includes('api_key=store-key')));
|
|
2007
|
+
|
|
2008
|
+
mockWeb.calls.length = 0;
|
|
2009
|
+
const explicit = await run('molbio_pubmed_search', { query: 'explicit key', api_key: 'arg-key' });
|
|
2010
|
+
assert.equal(explicit.backend, 'pubmed-eutils');
|
|
2011
|
+
assert.ok(mockWeb.calls.every((url) => url.includes('api_key=arg-key')));
|
|
2012
|
+
} finally {
|
|
2013
|
+
extraServices.delete('credentials');
|
|
2014
|
+
delete process.env.NCBI_API_KEY;
|
|
2015
|
+
if (saved !== undefined) process.env.NCBI_API_KEY = saved;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
{
|
|
2020
|
+
// a credential provider that refuses the ref must degrade to the environment,
|
|
2021
|
+
// not break the tool (a composition may have no store at all).
|
|
2022
|
+
const saved = process.env.NCBI_API_KEY;
|
|
2023
|
+
process.env.NCBI_API_KEY = 'env-fallback';
|
|
2024
|
+
extraServices.set('credentials', {
|
|
2025
|
+
async resolve() {
|
|
2026
|
+
throw new Error('no credential store in this composition');
|
|
2027
|
+
},
|
|
2028
|
+
});
|
|
2029
|
+
try {
|
|
2030
|
+
mockWeb.calls.length = 0;
|
|
2031
|
+
const out = await run('molbio_pubmed_search', { query: 'fallback' });
|
|
2032
|
+
assert.equal(out.backend, 'pubmed-eutils');
|
|
2033
|
+
assert.ok(mockWeb.calls.length === 2 && mockWeb.calls.every((url) => url.includes('api_key=env-fallback')));
|
|
2034
|
+
} finally {
|
|
2035
|
+
extraServices.delete('credentials');
|
|
2036
|
+
delete process.env.NCBI_API_KEY;
|
|
2037
|
+
if (saved !== undefined) process.env.NCBI_API_KEY = saved;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1992
2041
|
{
|
|
1993
2042
|
// an invalid key is reported as such, not presented as "no results"
|
|
1994
2043
|
await assert.rejects(() => run('molbio_pubmed_search', { query: 'x', api_key: 'bad-key' }), /API key invalid/);
|