DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser of its own. Both were things this plugin had built from scratch, so keeping them meant two implementations of one surface plus a plugin that shadows a host type the product is actively developing. Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the agent-owned registry, the lazy node-pty loader, the eight model-facing terminal_* tools, the xterm view and its font/link/scrollback helpers, and the cross-session 'pinned terminal' rail that existed only to keep a terminal alive across a session switch. The host's terminal kind becomes the only provider, which also retires the node-pty dependency, its pnpm allowBuilds entry, and the installer's -Repair mode (issue #140's whole class of 'node-pty failed to load' support). Model-facing capability note: the plugin used to inject terminal_* tools (off by default). Those are the model's only cross-call persistent terminal, and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by any shipped bundle, so removing them leaves the model with the one-shot bash/pwsh tools only. That is the accepted cost of not maintaining a second PTY surface; the README documents how to enable the upstream toolset. Browser: the plugin's browser tab type is dropped, so the host's own browser kind owns embedded pages. The DOM-level link takeover STAYS (it is the only thing that can route an https link to the system browser by protocol, which the host does not do) but now delegates to the host's browser tab via ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the two settings that only ever configured the plugin's own iframe (sandbox escape hatch, loopback allowlist) go with it — along with the host-side browser.probe route they fed. Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe (0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding) so the plugin's 'pick what opens here' surface reads as the same design as DSH's own. A theme.spec guard pins the geometry and the token-only paint — including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined nowhere, so their declarations are silently dropped upstream) never appear here. Also drops the dead state the removed features left behind (SidebarTab.pin, nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the pinned-tab memo key), re-points every test fixture that used them at surviving equivalents, and prunes the specs of the deleted features.
138 lines
5.7 KiB
TypeScript
138 lines
5.7 KiB
TypeScript
/**
|
|
* Registry manifest consistency spec: guards the plugin-registry release form
|
|
* (`dsh.plugin.json`) against the build output and package.json, so the repo
|
|
* cannot drift into an uninstallable / uneable state:
|
|
* - `id` passes the registry's strict two-segment validation (native form),
|
|
* - `version` stays in sync with package.json,
|
|
* - the manifest entry files exist in the build output,
|
|
* - the registry client bundle registers exactly the manifest id (the
|
|
* browser-side `arrive()` contract), and the official client bundle keeps
|
|
* the package-name id (the client-modules compose contract) — the two
|
|
* bundles must never be swapped.
|
|
*
|
|
* Reads the built lib/ output, so run `pnpm build` first (the registry
|
|
* install validates main/client.main existence the same way). A missing
|
|
* lib/ (fresh clone before the first build) skips the whole suite instead
|
|
* of crashing on ENOENT.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
|
|
|
/** The build output is present (tsdown emits the whole lib/ in one run). */
|
|
const libBuilt = existsSync(resolve(ROOT, 'lib/client.js'))
|
|
|
|
if (!libBuilt) {
|
|
console.warn('[manifest-consistency] lib/ build output missing — run `pnpm build` first; skipping this suite')
|
|
}
|
|
|
|
/** The registry manifest face this spec touches (mirror of the registry's PluginManifest). */
|
|
interface PluginManifest {
|
|
id: string
|
|
version: string
|
|
main: string
|
|
description?: string
|
|
engines?: { dsh?: string }
|
|
contributes?: { tools?: string[]; skills?: string[] }
|
|
client?: { main?: string; inject?: string[]; immediately?: boolean }
|
|
}
|
|
|
|
interface PackageJson {
|
|
name: string
|
|
version: string
|
|
}
|
|
|
|
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'dsh.plugin.json'), 'utf8')) as PluginManifest
|
|
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as PackageJson
|
|
|
|
/**
|
|
* The THIRD-PARTY registry channel's id rule (the community market validates
|
|
* `dsh.plugin.json`'s id itself): exactly two lowercase slash-separated
|
|
* segments.
|
|
*
|
|
* `dsh.plugin.json` is NOT a DSH contract — no DSH version reads it, and the
|
|
* official channel is `package.json`'s `dsh.bundle.patch` + `dsh.client` +
|
|
* `exports["./client"]` (asserted in tests/plugin-shape.spec.ts). This spec
|
|
* guards the registry channel's own shape so publishing there cannot break
|
|
* silently.
|
|
*/
|
|
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
|
|
|
|
/** Literal require() specifiers the frozen browser module table can answer. */
|
|
const CLIENT_REQUIRE_ALLOWED = new Set([
|
|
'react',
|
|
'react/jsx-runtime',
|
|
'react-dom',
|
|
'react-dom/client',
|
|
'cordis',
|
|
'@deepseek-ai/dsh-client-ui-slots',
|
|
'@deepseek-ai/dsh-client-ui-primitives',
|
|
])
|
|
|
|
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
|
|
function bundleId(file: string): string {
|
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
|
const match = /load\(\{\s*id:\s*"([^"]+)"/.exec(source)
|
|
if (match === null) throw new Error(`${file} registers no __ModuleLoader__.load id — run \`pnpm build\` first`)
|
|
return match[1]!
|
|
}
|
|
|
|
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
|
|
const CHUNK_FILES = ['editor', 'mermaid'].map(name => `lib/client-${name}.js`)
|
|
|
|
/** The global registry slot a built chunk script assigns (its factory key). */
|
|
function chunkSlot(file: string): string {
|
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
|
const match = /globalThis\.__dshChunks__\["([a-z0-9-]+)"\]/.exec(source)
|
|
if (match === null) throw new Error(`${file} assigns no globalThis.__dshChunks__ slot — run \`pnpm build\` first`)
|
|
return match[1]!
|
|
}
|
|
|
|
describe.skipIf(!libBuilt)('registry manifest consistency (dsh.plugin.json)', () => {
|
|
it('id is a valid two-segment registry id (native form)', () => {
|
|
expect(manifest.id).toMatch(ID_PATTERN)
|
|
})
|
|
|
|
it('version matches package.json', () => {
|
|
expect(manifest.version).toBe(pkg.version)
|
|
})
|
|
|
|
it('main and client.main exist in the build output', () => {
|
|
expect(existsSync(resolve(ROOT, manifest.main))).toBe(true)
|
|
expect(manifest.client?.main).toBeDefined()
|
|
expect(existsSync(resolve(ROOT, manifest.client!.main!))).toBe(true)
|
|
})
|
|
|
|
it('the registry bundle registers exactly the manifest id and the official bundle the package name (no swap)', () => {
|
|
const registryId = bundleId(manifest.client!.main!)
|
|
expect(registryId).toBe(manifest.id)
|
|
expect(bundleId('lib/client.js')).toBe(pkg.name)
|
|
expect(registryId).not.toBe(pkg.name)
|
|
})
|
|
|
|
it('the lazy chunk bundles exist and assign their global registry slots (served by /sidebar/bundle)', () => {
|
|
for (const file of CHUNK_FILES) {
|
|
expect(existsSync(resolve(ROOT, file)), file).toBe(true)
|
|
expect(chunkSlot(file), file).toBe(file.slice('lib/client-'.length, -'.js'.length))
|
|
}
|
|
})
|
|
|
|
it('client bundles require only frozen module-table entries', () => {
|
|
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
|
|
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
|
// Exclude method calls such as `freeModule.require("util")`; only the
|
|
// loader factory's lexical require() is constrained by the module table.
|
|
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
|
|
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id)), file).toEqual([])
|
|
}
|
|
})
|
|
|
|
it('contributes declares no tools or skills (the host half registers none)', () => {
|
|
expect(manifest.contributes?.tools).toEqual([])
|
|
expect(manifest.contributes?.skills).toEqual([])
|
|
})
|
|
})
|