@tsxapp/dsh-plugin
简体中文 | English
A DeepSeek Harness (DSH) plugin that wires tsxapp into three places in DSH: a skill,
a sidebar tab, and a tool.
- Skill injection: the host half registers a
tsxapp skill, so the agent knows the
.app.tsx contract: default-export a component that takes no props, import capabilities
from @tsxapp/runtime, the permission model, and Tailwind. Installing the plugin gives you
the skill; uninstalling it removes it — nothing is written into ~/.dsh/skills/.
- Sidebar: the browser half registers a tab type with
patterns: ['*.app.tsx']. Opening
any *.app.tsx from the file tree does not show plain text — it shows a tab for that app:
the page is drawn right inside the tab (embedded iframe, no system browser), with a
launch animation, run status, live logs and source with line numbers.
The top-right corner has two tools: "Restart & reload" (stop the service, start it again
and swap the whole iframe — for when you don't want to wait for HMR after an edit, or the
page state has gone haywire) and "Open in browser". Close the tab and the service stops
with it.
When it fails to start, the Fix it for me button opens a new session in the current
workspace so the agent fixes it with the whole project as context (not just the
foo.app.tsx subdirectory); a runtime error reports itself as a non-blocking
banner (the host's own Toast) — expand it only if you want the stack trace.
- Copy follows the host language setting: the dictionary is registered on DSH's locale
service (namespace
tsxapp, one Chinese and one English table), so the UI follows the
host's current language and changes live when you switch — it does not look at the
browser language.
- Tool:
tsxapp_preview — the agent calls it once after editing: type check → serve →
screenshot. The screenshot comes back as an image inside the tool result (the model
sees the page itself) and the report carries the service URL and dimensions; it also opens
the tab in the sidebar, so the human and the model look at the same page. If the type check
fails it just returns the report (no service, no screenshot). (The separate tsxapp_check
from the previous version has been folded in here: for an agent the action is always
"edit → take a look".)
Source and build
The source lives in src/: the host half is a handful of .ts files, the browser half is
tsx components under src/client/. Each half has its own tsconfig, because they live in
two different worlds (Node vs DOM + JSX):
| Entry | Output | Runs in | tsconfig |
|---|
src/index.ts | dist/index.js | Node (DSH host process) | tsconfig.host.json |
src/client/index.tsx | dist/client.js | Browser (DSH client module table) | tsconfig.client.json |
tsconfig.json is now just the base both share (files: [], it compiles nothing itself).
pnpm dsh:build # tsdown -c packages/dsh/tsdown.config.ts (two configs, host then client)
pnpm dsh:typecheck # tsc -p tsconfig.host.json && tsc -p tsconfig.client.json
pnpm dsh:test # run the unit tests (vitest, builds first)
Four things worth knowing:
- The skill body is imported statically.
src/skill.ts does import '../skill/SKILL.md'
directly, and the markdownText plugin in tsdown.config.ts turns it into a string
constant at build time — nothing is read from disk at runtime, so there is one less
"file isn't where we thought" failure mode. The skill/ directory still ships with the
package, because resourceBase points at it: when the model reads references/*.md on
demand it goes through real files.
client.js has to be a classic script. The source is plain ESM
(import { useState } from 'react'); the build uses the cjs format so rolldown turns the
imports into require(...), and the loaderEntry plugin then wraps it in
window.__ModuleLoader__.load({ id, factory }). React stays external via
deps.neverBundle — it only exists in the host's module table, and the @types/react in
the package is only for types. JSX uses the production runtime
(react/jsx-runtime): react/jsx-dev-runtime is not in the module table, and switching
to it means a blank page — the require stub in the self-tests blocks that path.
- The browser half's types also come from the official packages. The slot name,
useTabInfo, and the shape of the tab definition are all merged from
@deepseek-ai/dsh-client-ui-{sidebar-right,renderer,slots}:
PropsRuntime<'sidebar.right.pane.tab'> is exactly the props of the pane body, and
priority can only be 'extension' | 'builtin' | 'fallback' — hand-copying these would
have gotten those string literals wrong long ago.
- Same for the host half. There is not a single hand-written service declaration in
src/types.ts: import type on the official packages triggers cordis's declaration
merging (declare module '@deepseek-ai/cordis'), and the few methods the plugin uses are
picked off Context. If the official packages change a signature, dsh:typecheck fails
immediately. Those packages are in devDependencies and are all import type — not a
single line of @deepseek-ai shows up in the host output. The browser half has one
exception: @deepseek-ai/dsh-client-ui-primitives really is required — the Toast /
Modal / Button used by the error banner must be the host's (they
live in DSH's frozen module table, see in ).
Bundling our own copy would mean standing up a second design language next to DSH's, plus
carrying another and another focus manager.
Install
# Build first (DSH loads dist/)
pnpm dsh:build
# Inside DSH (only the web profile gives you a browser UI)
dsh plugin --profile web add /abs/path/to/tsxapp/packages/dsh
# Restart dsh web and refresh the page
Confirm it is mounted:
dsh --profile web --dump-config | grep -A2 tsxapp-dsh
While developing, run pnpm dsh:build after each change and restart DSH — DSH locks the
plugin set at startup, so hot-editing the plugin itself has no effect.
How the service starts: call the core, no subprocess
The plugin does not spawn the tsxapp command; it calls the core inside the DSH host
process — the top of src/runs.ts is a single static import:
import { chooseLocale, startApp } from '@tsxapp/core'
const handle = await startApp({ file, cwd, version, options, logger, banner: false })
handle.url // host page URL carrying the one-time token
await handle.close()
Splitting the CLI from the core was done exactly for this: the CLI shell (argument parsing,
printing the banner, installing SIGINT hooks) is all dead weight for an embedder — the
banner would scribble over the host's terminal, and process.exit(0) would take DSH down
with it. The plugin only gets the AppHandle the core hands back: no pid, no exit code.
startApp lives in packages/core/src/run.ts; the CLI's runApp is just that plus the
signal hooks.
Who cleans up: one effect per app
Starting is asynchronous (there is a compile in the middle), and "stop" may arrive first at
any moment. There is no hand-written state machine for this — each file corresponds to one
cordis effect:
run.release = ctx.effect(() => () => {
ac.abort('stopped') // make an in-flight start give up immediately
return slot.close() // close the handle; a handle that arrives after teardown is closed on entry
}, `tsxapp-dsh: ${basename(absolute)}`)
So "the user clicks stop" (calling release() by hand) and "the plugin is unloaded" (the
fiber calls it in reverse order) are the same code. That is why the plugin has no loop over
"services still running at unload time", and no generation counter to decide whether a
just-returned handle is already stale — the handle slot's retired flag says it in one place
(src/runs.ts).
In ctx.getEffects(), every running app is an effect tagged with its file name.
Where the core lives
The core is a normal dependency of this package — package.json says
"@tsxapp/core": "workspace:*" and pnpm links it to packages/core. The code is a single
static import … from '@tsxapp/core': developing in the repo, installing into a DSH profile,
and npm install on someone else's machine all go through the same Node resolution rules.
There is no config.core, no candidate path list, and no "if not found, try the
next one" fallback — so there is also no "core is too old / guessed the wrong location" branch
that only blows up at runtime.
The price is a different failure shape: when the core cannot be installed, dist/index.js
itself fails to import and the whole plugin fails to activate (DSH reports
1 entry did not activate), instead of coming up halfway and then answering "core not
found". That is much easier to diagnose than a silent degradation — one look tells you a
dependency is missing.
Remember to run pnpm build first: the core's entry point is dist/index.js, which is also
where the type declarations come from.
Configuration
The one line in cordis.patch.yml is the configuration, and config uses line
replacement semantics (not a deep merge). The keys map one-to-one onto tsxapp's CLI
flags:
- insert:
- id: tsxapp-dsh
name: '@tsxapp/dsh-plugin'
config:
port: 5173 # starting port; the core increments if it is taken
allow: [] # rules allowed from the start, same syntax as --allow
deny: []
startTimeoutMs: 20000 # upper bound for waiting on the core
Available keys: port host hmr accessControl typecheck persistPermissions
allow deny title verbose locale startTimeoutMs.
There is no core (the core is a static dependency, its location is not configurable) and no
open (opening a browser is the top bar's link; the core does not do it itself).
typecheck (default true) is the type/syntax check before startup: if there are errors the
start fails and the sidebar logs show that report (same wording as the CLI), and you click
again once fixed; pass typecheck: false to bypass it (equivalent to the CLI's
--no-typecheck).
allow is for capabilities that should "just work when opened", for example:
config:
allow: ['fs.list:.', 'fs.read:./data/**']
Capabilities not listed in allow are still authorized by a runtime prompt (in the host
page's Monitor panel you can click "Always allow" / "Always deny" to turn this one choice
into a rule).
What is in the sidebar
Opening any .app.tsx starts it automatically and then draws the page right inside the
tab — no system browser anymore.
While starting, the whole tab is one loading animation: a ring with a fading tail is
spinning, with no copy, no buttons, no top bar and no panel — there is nothing worth the
space during those few seconds of compiling. Once it is up you get the shell below:
| Area | Content |
|---|
| Top bar | A file path on the left (…/sales/demo.app.tsx, truncated when too long, hover for the full path) and three icons on the right — "Restart & reload" / "Screenshot" / "Open in browser" (space-between) |
| Stage | The app page (an <iframe> pointing at the host page); when it fails to start (timeout / failure) this is the title + the full error reason + "Fix it for me" / "Retry" |
| Bottom panel | Two pills, "Source" (the host's CodeBlock: syntax highlighting + line numbers + copy, first 512 KB) and "Run log" (the core's logger, a 400-line ring buffer showing 200 lines), plus one expand / collapse toggle, collapsed by default |
There is no status pill and no start/stop button group: starting happens when the tab opens,
stopping happens when it closes. On the normal startup path there is not a single button —
only the failure screen has "Fix it for me" and "Retry" (the former solid, the latter
outlined).
The UI has exactly three states (the Phase in use-run.ts): starting (the whole tab is
just a spinner ring — opening a tab, clicking "Restart & reload", and auto-restarting after
being stopped all land here), has a page, and cannot start. When the service is
stopped (another tab with the same address was closed, the host reloaded the plugin) it does
not show "service stopped, click below to restart" — the tab brings it back up by itself.
The failure screen
One title line ("Cannot start" / "The core never came up"), and below it the error reason
itself: the core compresses the full report (that wall of type-check output, or a single
file not found) into one log line, and this screen takes the last one and lays it out
verbatim — left aligned, monospace, scrolling when too long — instead of sending the user
down to the log panel to dig.
Below the reason are two buttons: Fix it for me (the host's primary fill + four-point star
icon) packages the path, status, failure reason and the log tail into one message and opens a
new session in the current workspace for the agent; Retry (outlined + back arrow) sends
the start request again.
Two "Screenshot" buttons: measure the chain segment by segment
"Screenshot" involves three hops, and any broken hop turns into the tool's
no browser answered within 20s. So there is a "Screenshot" button in the top bar of the
pane body, and another at the right of the host page's Monitor panel header:
| Button | Where | Which hop it measures |
|---|
| The sidebar one | Pane body top bar, right of "Restart & reload" | Sidebar → host page → app page |
| The host page one | Host page top bar, right of "Reload" (left of the Monitor one) | Host page → app page (not through the parent window) |
Neither goes through the tool's mailbox (the tool sits on the host side and can only receive
via a 2.5-second poll). Therefore:
sidebar works but the tool doesn't ⇒ the problem is the mailbox / polling / "someone has
this tab open";
neither works ⇒ the problem is "host page ⟷ app page" (look for the red screenshot line
in the host page's Monitor log tab).
On success it downloads a PNG; on failure the sidebar shows a banner and the host page writes
an error into the log tab and brings that tab to the front.
Both buttons live in the top bar; do not put them in the default-collapsed Monitor panel —
a button you have to expand a panel to see is a button that does not exist (the first version
made exactly this mistake: it sat on the right of the panel header and nobody found it).
To look at the app outside the sidebar, use "Open in browser" at the top right — it is a
real link (<a target="_blank">) handed to the host's "new window" handling. On desktop
the page lives on file://, where window.open is often blocked into null (the click does
nothing); the link is the only path to "open in the system browser". While the app is not up
yet it has no href and cannot be clicked.
Autofix: hand it to the agent when it cannot start
The failure screen has an Autofix button: clicking it creates a new session in the
current workspace (falling back to the app's directory when no workspace is available),
packages "file, status, failure reason, log tail" into one message for the agent, and switches
the main view to that session — so you can watch both the fix and the result.
- It goes through DSH's public client services, never touching host internals:
ctx.sessions.create() → inside sessions.using(id, { source: 'tsxappAutofix' }, …) session.prompt(content, 'queue') → ctx.uiWorkspace.openSession(id) (see
src/client/autofix.ts).
- A new session, not a message shoved into the current one: debugging this round has its
own context, and it is not affected by whether the current session is busy.
- When the host has no
sessions / uiWorkspace (older versions, trimmed distributions) the
button does not appear, rather than erroring when clicked.
Runtime errors: a banner, not a roadblock
Once the app is running and crashes (uncaught exception, unhandled promise, React render
error), the host page relays it up and the sidebar reports a DSH Toast
(@deepseek-ai/dsh-client-ui-primitives, createPortal into document.body): one line at
the top "App error: …" followed by two inline actions, "or fix it for me" and "· Error
details", which fades out on its own after a few seconds. It does not block the app —
an error report is a notification, not an interception.
Clicking "fix it for me" takes the same autofix.ts path, except the message says
kind: 'runtime' — "the app runs but errors" and "it cannot start" are two different things,
and the agent needs to look at different information. Clicking "Error details" (or when the
fix did not come up) expands a Modal: error summary + stack trace, the card allowing full
width, the content area scrolling on its own, and two buttons at the bottom, "Close" / "Fix it
for me" — you fix it right there after reading the stack, without closing the details and
waiting for the banner to slide back in. That step is user-initiated, so a modal is
appropriate.
Where the boundaries are:
- Only the "crashed" kinds count (
uncaught / rejection / render). A console.error
line from the app itself, or a 404 on an image, is not reported: during development Vite
also writes errors to the console (that --no-hmr [vite] failed to connect to websocket
is one), and asking "should I fix this?" about them is just noise;
- Every one is reported, even if the same line shows up again — noise reduction happens
on the host side (
#pushLog merges repeats with the same signature within 3 seconds and
does not relay the merged ones), so reporting the same line every frame still lands as one
notification;
- The "fix it for me" action only appears when someone can fix it (
sessions /
uiWorkspace both present); otherwise only "Error details" is left;
- When the banner fades out, nothing happens — the user is not interrupted while looking at
the app.
The tool: tsxapp_preview
The hook registers a tool the agent can call; it does three things in one go, strictly ordered
from cheap to expensive:
tsxapp_preview({ file: 'examples/demo.app.tsx', limit: 30 })
- Type check — calls the core's
typecheckApp() directly, the same yardstick as the
pre-startup check (it honors the project's tsconfig, the JSX automatic runtime, and
module resolution fallbacks). On errors it returns core.formatReport()'s human-readable
report (file:line:col TS#### reason + a code frame). Errors stop it here: the tool
returns normally, but does not start the service and does not take a screenshot — see
"why the type check comes first" below;
- Serve (reused if already running, no recompile);
- Screenshot — asks the sidebar to open the tab so the app page draws its own DOM into a
PNG; the image is returned as an image block in the tool result, and the report only
keeps the dimensions and size.
Why the type check comes first: it is the only step that "spawns nothing, touches no
browser, and answers in tens of milliseconds", and its conclusion decides whether the next two
steps are worth doing. Doing it the other way round runs into two problems: an app with type
errors still starts (Vite does not type check), but the screenshot is a full-screen error
overlay — and an agent easily mistakes the overlay for the page and concludes "rendering is
fine"; and every wrong edit costs a wasted compile plus a wasted screenshot.
The report's order is the actual order of events: file: → type check: → status: /
preview: → screenshot:. When the type check fails the last two segments do not appear (if
this app happened to be running already, one extra line
preview: … (already running — not restarted) shows up so it is not mistaken for the result
of this call).
Why fold the type check in here instead of keeping a separate tool: the agent's real action is
always "edit → take a look at whether it is right", and a type error is just one kind of "not
right". Split into two tools, it has to remember which one comes first; merged into one, it
only remembers one thing.
The image goes back to the model: the canonical value is a report plus (when a capture
happened) a reference to the image, and output.render turns the latter into an ImageBlock
in the same content. An image block only accepts a durable attachment reference — there is
no inline base64 path — so execute hands the PNG to ctx.attachments.saveImage() first and
builds the block from the attachmentId that comes back. The screenshot is not written to
disk: piling PNGs into %TEMP% just leaves litter on the user's machine. That is also why
tools and attachments are requested in the same ctx.inject([…]) — without attachment
storage this tool has no way to let the model see anything.
A failed screenshot does not fail the tool: when the sidebar is not open or the browser
does not support it, the type check and the service URL are still valid, the report says
screenshot: unavailable — …, and no image block is attached (no empty reference to fool
the model). Turning the whole call into an error would just waste a round trip for the agent.
How the screenshot gets through
The tool lives in the host process, screenshots can only be taken in the browser, and
the two have no persistent connection — a mailbox (src/shots.ts) connects them:
tool request(path) ──► mailbox (id, path, promise)
│
client long-polls GET /tsxapp/shots ──► sees the id ⇒ sidebarRight.openResource(url) opens the tab
│
pane body polls its snapshot, sees shots:[id] ──► postMessage to host page ──► app page domToPng
│
POST /tsxapp/shot { id, dataUrl } ──► settle(id) ──► the tool's promise resolves
- There must be a timeout (
previewTimeoutMs, default 20s): the tab may never be opened,
and then nobody will come for this letter;
- One screenshot per path: a later request reuses the one in flight;
- Cleared on unload: when the plugin is unloaded, everything waiting resolves as a
failure, so the tool's promise never hangs forever.
The capture itself lives in @tsxapp/runtime's captureScreenshot() (domToPng from
modern-screenshot, dynamically import()ed so it does not weigh on first paint): the longest
edge is capped at 1600, over 3 MB steps down progressively, and the background color is taken
from the root node's actual background (otherwise a dark theme screenshots into white
background with black text).
What the three window layers say to each other
DSH sidebar (parent window, possibly file://)
└─ iframe ─► host page localhost:P (the only holder of the token)
└─ iframe ─► app page app.localhost:P (cross-origin, runs user code)
- Sidebar ⇄ host page:
{ __tsxapp: 1, t: 'screenshot', id } goes down, screenshot-result
comes back; log / error go up (the host page's #postToParent in bridge.ts). Only
these three kinds flow, no token, no credentials;
- Host page ⇄ app page: the original RPC line, with the screenshot command and result as the
two newly added message types.
There is a deliberate trade-off in the implementation: defineTool() is not imported. It
is runtime code, and pulling it in would inline the whole schema compiler into
dist/index.js (60 KB → 400 KB), while its only contribution here is compiling the parameter
spec into a JSON Schema — writing it by hand is smaller. The price is that parameters are not
schema-validated, so limitOf / resolveTarget check every field themselves.
@deepseek-ai/dsh-tools is kept only as a type source (devDependency).
Registration goes through ctx.inject(['tools'], …), not a direct ctx.tools.register().
There is exactly one reason: cordis's ctx is a Proxy, and reading a service that is not in
the plugin's inject throws on the spot with cannot get property "tools" without inject —
?. does not catch it (the exception happens while fetching the property), and this throw
aborts the whole apply — the six routes and the sidebar all fail to activate together, and
DSH only reports "1 entry did not activate". Putting tools into the plugin's inject is too
heavy the other way: it is only an optional service, and its absence should not make the whole
plugin wait. ctx.inject wins on both counts: it runs the callback once the service is ready,
re-runs it if it arrives late, and if it never appears you just have one tool fewer.
Styles are injected once via <style>, and the test is content, not "is the element
there" — ensureStyles skips only when the copy in the page is byte-for-byte identical to the
current version. A "return if the id exists" version would mean that after a plugin upgrade
(re-mounting the plugin, no full page reload) not one new rule takes effect, the page keeps the
previous version's styles, the new class names have no styles and the buttons fall back to
browser defaults — which looks exactly like "the layout blew up".
The service lives with the tab, not with the tab's visibility: the tab type declares
keepMounted: true — by default the host only keeps visible pane bodies in the tree, so
switching to another tab, collapsing the sidebar or changing sessions unmounts the body, and
unmounting immediately fires a stop request (this is precisely the old "switch a tab and the
app is gone"). keepMounted keeps a visited body mounted while hidden; only really closing
the tab unmounts it.
Closing the tab stops the service: useRun sends a POST /tsxapp/stop on unmount.
Waiting 260 ms is to avoid React StrictMode's "mount → unmount → mount again" — sending
immediately would kill the service that was just started. When the body is hidden (not
unmounted) only status polling pauses; the service keeps running.
Why the page can be framed: the host page does not set frame-ancestors — who may
frame it is decided by the token. To render, the URL must carry ?t=…; no other page can
obtain or guess it, and framing without it gives you an empty shell saying "missing access
token". An allowlist would lock you out instead: browsers validate the whole ancestor chain
(DSH's localhost:3080 → host page → app page — miss one link and it is a blank page), and in
Electron the page origin is "null", which no host-source can match. So the plugin does not
need to report its own origin either.
Pressing "start" twice on the same file does not start a second service; closing the tab,
clicking stop and unloading the plugin are three paths that all close() the service. On a
start timeout (20s by default), if the core does come up later, the plugin closes it
immediately — no port left unclaimed.
Host routes
Six of them, all passing through the connection layer's trust fence
(ctx.connection.requestRejection) first; anything undeclared is refused:
| Method | Path | Purpose |
|---|
| POST | /tsxapp/start | { address } → start the service, return a status snapshot |
| POST | /tsxapp/stop | { address } → close the service |
| GET | /tsxapp/state?address=… | status + logs + pending screenshot ids (shots) |
| GET | /tsxapp/source?address=… | source (≤ 512 KB, with truncated) |
| GET | /tsxapp/shots | all screenshot requests the host is still waiting on (client long-polls this to open tabs) |
| POST | /tsxapp/shot | { id, dataUrl, width?, height? } → resolves the promise the tool is waiting on |
address is a DSH resource address, dsh-resource://file/session/<sessionId>/<path> or
…/absolute/<path>. The session scope is resolved by the host into an absolute path based
on that session's cwd — the browser half neither knows nor needs to know where the workspace
is. Only .tsx is accepted.
The client requests using relative paths (tsxapp/state, no leading slash): on desktop
the page runs on file:// and requests go through the IPC bridge, where a leading slash gets
parsed as a drive root.
Where the skill content comes from
The skill/ directory inside the package is the only copy; there is nothing to sync. It
plays two roles at once:
src/skill.ts statically imports these three .md files, inlined into string constants at
build time (the body has nothing to do with disk);
resourceBase points at this directory, so when the model reads references/*.md on demand
it goes through real files.
So to change the skill, edit skill/ directly — both the build and the published package read
from it.
The plugin appends a DSH-specific note after the generic skill body (save as *.app.tsx, let
the sidebar start it, do not run commands yourself), see SKILL_APPENDIX in src/skill.ts.
The skill body only covers how to write an app; how to run things (CLI flags, the panel,
debugging) stays out of the skill and lives in this document and the root README.
Dependencies
This package is an ordinary workspace member, and its two kinds of dependencies each do one
job:
| Dependency | Provided by | Used for |
|---|
@tsxapp/core ("workspace:*") | pnpm links it to packages/core | calling startApp() at runtime |
@deepseek-ai/cordis + @deepseek-ai/dsh-{host-webserver,skill,session,client-connection} (devDependencies) | this repo's pnpm install | types for the host half, all erased after compiling |
@deepseek-ai/dsh-client-ui-{sidebar-right,renderer,slots} + @types/react (devDependencies) | same | types for the browser half (slot names, useTabInfo, tab definitions), also compile-time only |
@deepseek-ai/dsh-client-ui-primitives (devDependencies) | same | Toast / Modal / Button for the error banner and details modal: types come from here, and at runtime require resolves to the copy in the host's module table |
The @deepseek-ai versions track the DSH desktop release (currently 0.2.0-rc.2, cordis
4.0.5). If a service signature changes after a DSH upgrade, pnpm dsh:typecheck reports it
first — at that point sync the versions or change the usage, rather than coming back and
copying the types to work around it.
These devDependencies do not travel when installing into a DSH profile: the host already has
its own copy, and installing another would just create a second instance. The same goes for
React: the package has no react, only its types, and the runtime require('react') is
answered by the host's module table.
Self-tests
pnpm dsh:test # = pnpm dsh:build && vitest run --project dsh
The unit tests are vitest (test/plugin.test.ts, TypeScript) with the config in the
package's own vitest.config.ts; running pnpm test inside the package runs just this one.
There is no real host; stubs bring both halves up:
- Host half: feed a fake
ctx and then really call those six routes. Starting is
swapped for the stub core in test/stub-core.ts, so the "start → get a handle →
running → stop closes the handle" chain really runs, and the failure / timeout behaviors of
the core can be played too. The tool section verifies the screenshot loop as well: hang a
request → see the id → POST /tsxapp/shot with a fake PNG → the report contains the path on
disk.
The plugin uses static imports and has no "swap the core" switch, so the stub is installed at
the module resolution layer: resolve.alias in vitest.config.ts points the package name
@tsxapp/core at the stub — test infrastructure, not one line of which is visible in the
output.
- Real core: the end-to-end case has the stub forward
startApp to the real
@tsxapp/core, really starting a Vite service, fetching the host page once to confirm 200,
then confirming the port is no longer reachable after stop; another case uses the real
tsc to check typecheckApp, and one more reads the output directly to pin down "it is a
top-level static import and there is no core entry in the config". This is the acceptance
point for "does not call the CLI".
- Browser half: stubs
window.__ModuleLoader__ to intercept the bundled dist/client.js
and check the registered kind / patterns / priority / title and the pane body slot; then
verifies "someone is waiting for a screenshot ⇒ open a tab at the session address".
What is tested is the dist output, not the source: those two files are what DSH actually
loads, and the case "types are fine in source but it does not run once bundled" can only be
caught at this layer.
Known limitations
- Only useful in the web profile (TUI / headless have no sidebar).
- A tab's title is frozen to the file name when it opens; renaming the file does not update
already-open tabs.
- One service per
.app.tsx, shared within the process; the same file opened in two tabs
shares one state.
- The port is incremented by the core (
port is only a starting value); the plugin just
displays the final address truthfully.
- The service runs in the same process as DSH: if DSH crashes the service goes with it (that
is the semantics of "embedded", but it also means no process isolation).