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/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
- const waited = [...bundle.matchAll(/slots\.inject\('([^']+)', \(\) => (?:ctx\.)?slots\.register\(\{\s*name: '([^']+)'/g)];
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(/(?:ctx\.)?slots\.register\(/g) ?? []).length;
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
- const llmTypes = readFileSync(join(packageDir('@deepseek-ai/dsh-llm'), 'lib', 'types', 'types.d.ts'), 'utf8');
611
- const imageBlock = /interface ImageBlock \{[\s\S]*?\n\}/.exec(llmTypes)?.[0] ?? '';
612
- assert.match(imageBlock, /type: 'image'/, 'ImageBlock still declares type "image"');
613
- assert.match(imageBlock, /attachment: ImageAttachmentRef/, 'and still carries a durable attachment reference');
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
- const attachmentIndex = readFileSync(join(packageDir('@deepseek-ai/dsh-attachment'), 'lib', 'types', 'index.d.ts'), 'utf8');
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
- const fsTypes = readFileSync(join(packageDir('@deepseek-ai/dsh-fs'), 'lib', 'types', 'types.d.ts'), 'utf8');
647
- assert.ok(fsTypes.includes('FS_NOT_TEXT'), 'the filesystem seam still types FS_NOT_TEXT');
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
- assert.ok(!/\bwriteBytes\b/.test(fsIndex), 'no binary write primitive appeared on the fs seam');
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
- module.apply({
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;
@@ -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/);