From 6cfe7a736fb0d402e76a874b5afb760d974c4ddc Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Fri, 28 Aug 2026 04:20:36 +0800 Subject: [PATCH] feat: adapt to DSH 0.1.2-alpha.1 (Remote gateway + token auth + MarkdownText labels) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-version compatibility: the plugin now works identically on 0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified against a source build of the dsh-v0.1.2-alpha.1 tag: mount smoke 14/14 + aggregate double-mount; 0.1.1-rc.2 re-verified 14/14). - MarkdownText: alpha.1 renamed the flat optional `codeLabels` prop to a required nested `labels: { code: {copyLabel, copiedLabel}, footnotes }`; the old prop alone crashes fence rendering with "reading 'code'". All four render sites (mermaid / MarkdownHtml / TextEditor / SideChatView) now go through src/client/markdown-labels.tsx (dual-shape object passed under both prop names). - e2e mount lanes speak both host dialects (tests/e2e/host-protocol.ts + host.ts, unit-locked by tests/e2e-host-protocol.spec.ts): - one-time-token browser auth: the launch URL grep extends past the `/?token=` query (truncating at `/` loses the token), goto navigates the token URL (browser cookie exchange), and stamped navigations pre-seed the context cookie via addCookies — the exchange 303 drops sibling query params; - Remote gateway: dot endpoints first, slash endpoints on 404 fallback, with args keyed by the controller parameter name (workspace/create -> {request}, session/list -> {_request}; verified on a live host). - package.json: drop the @deepseek-ai/dsh-client-runtime peer (package removed upstream in alpha.1; zero references in the source — the chunk externals entry stays and is resolution-tolerant). - scripts/e2e-mount.sh: fix a latent set -u crash in the npx fallback branch ($DSH_CMD followed by a full-width comma parsed as part of the variable name). - CI pin stays at @deepseek-ai/dsh@0.1.1-rc.2 until alpha.1 publishes to npm (comment updated); README/AGENTS document the new contracts. - Version lockstep bumped to 0.17.1. --- .github/workflows/ci.yml | 4 + AGENTS.md | 11 +- README.md | 10 +- README_EN.md | 10 +- dsh.plugin.json | 2 +- package.json | 3 +- scripts/e2e-aggregate-mount.sh | 5 +- scripts/e2e-mount.sh | 10 +- src/client/MarkdownHtml.tsx | 3 +- src/client/SideChatView.tsx | 5 +- src/client/TextEditor.tsx | 3 +- src/client/chunk-loader.ts | 7 ++ src/client/markdown-labels.tsx | 52 +++++++++ src/client/mermaid.tsx | 3 +- src/client/service.ts | 2 +- tests/e2e-host-protocol.spec.ts | 201 ++++++++++++++++++++++++++++++++ tests/e2e/drag-layout.e2e.ts | 41 +++---- tests/e2e/float-window.e2e.ts | 33 ++---- tests/e2e/host-protocol.ts | 104 +++++++++++++++++ tests/e2e/host.ts | 154 ++++++++++++++++++++++++ tests/e2e/mount.e2e.ts | 75 +++++------- tests/e2e/toggle-layout.e2e.ts | 27 +---- tsdown.config.ts | 2 +- 23 files changed, 623 insertions(+), 144 deletions(-) create mode 100644 src/client/markdown-labels.tsx create mode 100644 tests/e2e-host-protocol.spec.ts create mode 100644 tests/e2e/host-protocol.ts create mode 100644 tests/e2e/host.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f18da10..45cbe76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,10 @@ jobs: # Pin the DSH CLI the lane mounts into; the profile's bundle stack # (dsh-base / dsh-web-app) resolves from this installation. Keep in # step with the plugin's @deepseek-ai/* peer range when bumping. + # NOTE: DSH 0.1.2-alpha.1 (Remote gateway slash endpoints + one-time- + # token browser auth) is NOT on npm yet — the lanes speak BOTH dialects + # (tests/e2e/host-protocol.ts, dot-first with slash fallback), so bump + # this pin once @deepseek-ai/dsh@0.1.2-alpha.1 publishes. - name: Install dsh CLI (pinned) run: npm install -g @deepseek-ai/dsh@0.1.1-rc.2 diff --git a/AGENTS.md b/AGENTS.md index ea43586..91da216 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,15 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.1-rc.2`(挂载冒烟验证基线;peer 下限保持 `^0.1.0-rc.8`,rc.8 与 0.1.1-rc.x 双向兼容)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。 +**DSH 0.1.2-alpha.1 适配(双协议 e2e)**:0.1.2-alpha.1(GitHub tag `dsh-v0.1.2-alpha.1`,**尚未发布 npm**,故 CI 钉版暂不动,发布后再平移)有两项破坏性变更,e2e lane 已经双版本兼容: + +1. **一次性 token 鉴权**:`dsh web` 就绪行变为鉴权 URL `dsh web: http://127.0.0.1:/?token=<43字符>`(页面导航它换取签名 cookie,干净 URL 一律 401)。`scripts/e2e-mount.sh` 的 URL grep 必须延伸到空白(`[^ ]*`——在 `/` 截断会丢 token);lane 统一经 `tests/e2e/host.ts`:`PAGE_URL`/`pageUrl({...})` 做 goto(query 与 token 合并,绝不裸拼 `?`),`createHostApi()` 先用 token URL 换 `Set-Cookie`(`redirect: 'manual'`)再建带 cookie 的 APIRequestContext。插件自有 `/sidebar/*` 路由**不受**影响(webserver carrier 无鉴权层,仅 `/api`、index HTML 与 remote.mux 升级在 browser auth 后面),浏览器内同源 fetch 照旧。 +2. **Remote gateway 取代 ApiProxy**:点分端点 `POST /api/workspace.create`(payload=裸参数)被斜杠端点 `POST /api/workspace/create` 取代——payload 必须恰为 `{args: {...}}`,**args 按控制器的 TS 参数名包装**(`workspace/create`、`session/create` 的参数名是 `request` → `{args:{request:{...}}}`;`session/list` 的参数名是 `_request` 且**不可省略**——`{}`/`{request:{}}` 都被 typert 网关以 `args fields do not match the descriptor` 拒绝),envelope `method` 与路径一致,点分路径不再被认领(404)。`hostRpc(api, 'workspace.create', args)` 点分优先(已发布宿主零探测开销)、404 回退斜杠并缓存;参数名映射表 `SLASH_ARGS_KEY` 与全部契约由 `tests/e2e-host-protocol.spec.ts` 单测锁定(形状经真机 0.1.2-alpha.1 验证)。 + +另:`@deepseek-ai/dsh-client-runtime` 包在 0.1.2-alpha.1 被移除(继任 seed 是裸名 `@deepseek-ai/dsh-client-store`,无 `/client` 子路径)。插件源码对它零引用(chunk externals 白名单里的 `dsh-client-runtime/client` 解析失败会被 `buildExternalsRequire` 宽容跳过,见 `src/client/chunk-loader.ts` 头注释),已从 peerDependencies 移除;`dsh.client.inject` 里保留该条目无害(inject 目标不在 boot graph 时两版宿主都静默跳过)。 + +**运行时组件契约(真机验证过的一处破坏)**:`MarkdownText` 的 chrome labels prop 在 0.1.2-alpha.1 从扁平可选的 `codeLabels` 改名为**必填嵌套**的 `labels: { code: { copyLabel, copiedLabel }, footnotes }`——只传旧 prop 时 fence 渲染抛 `Cannot read properties of undefined (reading 'code')`。插件四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一改走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`(双形状对象 + 双 prop 名,两版通吃)。另注意 token 换 cookie 的 303 会**丢弃同 URL 的其它 query 参数**——e2e 的带 stamps 导航统一走 `host.ts` 的 `gotoPage()`(先给浏览器上下文 addCookies 再直达带参 URL,见 host.ts 注释)。 + ### npm 发版(GitHub Release → npm publish) `.github/workflows/release.yml` 在 GitHub Release 发布(tag `vX.Y.Z`)时自动发版到 npm: @@ -595,7 +604,7 @@ interface OpenTabSeed { | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **家族右面板互斥(v0.13.0+)** | 读取 `aionui-panel` 设置命名空间的 `rightPanel`:解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 路由返回 `externalDisable: true`,客户端挂载门 + 各类接管一并停用;`settings/document-updated` 推送实时生效,无 `remote` 服务的部署回退为启动时判定)。未安装 aionui 或提供方为其他值时不受影响 | -| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 | +| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时**必须**经 `src/client/markdown-labels.tsx` 的 `markdownTextProps(text, { copyLabel: t('copy'), copiedLabel: t('copied') })`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文;且该 helper 同时满足两代 prop 契约(0.1.1-rc.x 的扁平可选 `codeLabels` 与 0.1.2-alpha.1+ 改名后**必填嵌套**的 `labels: { code: {copyLabel, copiedLabel}, footnotes }`——只传旧 prop 在 alpha.1+ 上崩溃 `reading 'code'`,双形状对象 + 双 prop 名两版通吃) | | **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(`peerDependenciesMeta.optional: true`)提供 ja/ko/... 等第三语言覆盖。覆盖**借用 DSH 的英文槽位**:`store.getOverride(dshActive, ns, key)` 只在 `dshActive === 'en'` 时返回覆盖文本,DSH 在 zh 下覆盖完全惰性(保持原生 zh)。安装后 `apply()` 调 `ctx.get('betterLocale')` 取得 override store 并经 `attachBetterLocale()` 注入 `t()`:`t()` 先查 `store.getOverride(active, 'betterSidebar', key)`,命中则返回覆盖文本(如 ja),否则走原 zh/en 链;`isZh()` 改用 `store.isOverrideActive(active)` 判断(仅在 DSH=en 且有 override 时返回 false)。Sidebar root 另订阅 store 的 `subscribe()`(uSES on `store.active`),覆盖切换时全树重渲染(DSH locale 的 `active` 字段不变,所以既有 `localeRevision` uSES 不会触发)。本插件同时把 ja 词典 `register('betterSidebar', { ja })` 进 better-locale 的 store,让外部 `ctx.locale.lookup('betterSidebar', key)` 调用者也能拿到 ja 文本。better-locale 的 LanguageSwitcher 在用户选了非 native 且 DSH 非 en 时显示「请将 DSH 切换到英文以查看 [语言名]」提示。未安装 better-locale 时 `ctx.get` 返回 undefined,整段为 no-op,zh/en 行为不变。新增 zh key 时**必须**同步加 ja 翻译(`src/client/locales-ja.ts`),否则该 key 在 ja 覆盖下回退到 en | | **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 | diff --git a/README.md b/README.md index 669c674..0d0bcc1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ GitHub stars License: MIT dshfind

- 支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 + 支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1 插件生态:GitHub topic dsh-better-sidebar

文件管理 编辑预览 内嵌浏览器 真实终端 Git 面板 后台任务 侧边对话 插件接入

右侧栏 + 底部面板双工作台,并把 ctx.betterSidebar 服务开放给所有插件——
@@ -62,7 +62,7 @@ **前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。 **支持的 DSH 版本**: -支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 +支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1 ```sh dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入) @@ -254,7 +254,11 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ## 🆕 最近更新 -**支持的 DSH 版本**:支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) +**支持的 DSH 版本**:支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1 · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) + +### v0.17.1 + +- 🔗 **DSH 0.1.2-alpha.1 适配(双版本兼容)**:DSH 0.1.2-alpha.1 的 Remote gateway、一次性 token 浏览器鉴权与 `MarkdownText` labels 契约变更已全量适配,插件在 0.1.0-rc.8 ~ 0.1.1-rc.2 与 0.1.2-alpha.1 上一致工作(后者经 GitHub tag 源码构建的真机挂载冒烟 14/14 验证;该版本 npm 尚未发布,CI 钉版暂保持 0.1.1-rc.2,发布后平移)。要点:`MarkdownText` 四个渲染点统一改走双形状 labels helper([markdown-labels.tsx](./src/client/markdown-labels.tsx)),修复 alpha.1 上 markdown/mermaid 预览的 `reading 'code'` 崩溃;e2e 冒烟双协议化(token URL 换 cookie、`/api` 斜杠端点 + 按参数名包装 args,[tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts));移除已在 0.1.2-alpha.1 消亡的 `@deepseek-ai/dsh-client-runtime` peer ### v0.16.1 diff --git a/README_EN.md b/README_EN.md index 4ebc6a5..96cfa7c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -9,7 +9,7 @@ GitHub stars License: MIT dshfind

- Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 + Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1 Plugin ecosystem: GitHub topic dsh-better-sidebar

File management Edit & preview Embedded browser Real terminal Git panel Background tasks Side Chat Plugin integration

A dual workbench (right sidebar + bottom panel) that opens its ctx.betterSidebar service to every plugin —
@@ -62,7 +62,7 @@ **Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10. **Supported DSH versions**: -Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 +Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1 ```sh dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written) @@ -259,7 +259,11 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid Service API base screenshot -**Supported DSH versions**: Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page +**Supported DSH versions**: Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 0.1.2-alpha.1 · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page + +### v0.17.1 + +- 🔗 **DSH 0.1.2-alpha.1 adaptation (dual-version compatible)**: fully adapted to DSH 0.1.2-alpha.1's Remote gateway, one-time-token browser authentication, and the `MarkdownText` labels contract change — the plugin works identically on 0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified by a real-host mount smoke 14/14 against a source build of the GitHub tag; that version is not on npm yet, so the CI pin stays at 0.1.1-rc.2 until it publishes). Highlights: all four `MarkdownText` render sites now go through the dual-shape labels helper ([markdown-labels.tsx](./src/client/markdown-labels.tsx)), fixing the `reading 'code'` crash in markdown/mermaid previews on alpha.1; the e2e mount smoke speaks both wire dialects (token-URL cookie exchange, slash `/api` endpoints with parameter-named args, [tests/e2e/host-protocol.ts](./tests/e2e/host-protocol.ts)); dropped the `@deepseek-ai/dsh-client-runtime` peer removed upstream in 0.1.2-alpha.1 ### v0.16.1 diff --git a/dsh.plugin.json b/dsh.plugin.json index 1e7db14..d7787c4 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.17.0", + "version": "0.17.1", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index f782e69..7ec06d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.17.0", + "version": "0.17.1", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { @@ -87,7 +87,6 @@ "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/dsh-agent": "^0.1.0-rc.8", "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8", - "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8", "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8", "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8", "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8", diff --git a/scripts/e2e-aggregate-mount.sh b/scripts/e2e-aggregate-mount.sh index 12066dc..a2b6602 100755 --- a/scripts/e2e-aggregate-mount.sh +++ b/scripts/e2e-aggregate-mount.sh @@ -137,7 +137,10 @@ say "启动 dsh web(--port ${PORT},日志 ${LOG_DIR})…" $DSH_CMD web --port "$PORT" >"$OUT_LOG" 2>"$ERR_LOG" & SERVER_PID=$! -# 等待启动 URL 或进程退出(最多 120s) +# 等待启动 URL 或进程退出(最多 120s)。这里有意只取 origin:0.1.2-alpha.1+ +# 的就绪行是 `…/?token=<43字符>` 鉴权 URL,但下方的探活全部打插件的 +# `/sidebar/api/*` 路由——webserver carrier 不做鉴权(只有 /api、index 与 +# remote.mux 升级在 browser auth 之后),origin 拼路径即正确且两版通用。 URL="" for _ in $(seq 1 120); do if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi diff --git a/scripts/e2e-mount.sh b/scripts/e2e-mount.sh index 9f3188e..4470f28 100755 --- a/scripts/e2e-mount.sh +++ b/scripts/e2e-mount.sh @@ -44,7 +44,7 @@ command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pn # dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh) if ! command -v "$DSH_CMD" >/dev/null 2>&1; then if command -v npx >/dev/null 2>&1; then - say "PATH 上无 $DSH_CMD,回退 npx -y --package @deepseek-ai/dsh" + say "PATH 上无 ${DSH_CMD},回退 npx -y --package @deepseek-ai/dsh" DSH_CMD="npx -y --package @deepseek-ai/dsh dsh" else die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定" @@ -147,6 +147,12 @@ say "启动 dsh web(port=${PORT})..." $DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 & SERVER_PID=$! +# 就绪行解析:DSH 0.1.2-alpha.1+ 打印的是带一次性 token 的鉴权 URL +# (`dsh web: http://127.0.0.1:/?token=<43字符>`,页面导航用它换取 +# 浏览器 cookie;干净 URL 只会得到 401);0.1.1-rc.x 及更早是裸 origin。 +# 匹配必须延伸到空白(`[^ ]*`)——在 `/` 处截断会丢掉 token,alpha.1+ +# 宿主上的整条 lane 都会挂在首屏 401。LAN 后缀 `(LAN: …)` 是下一个 +# 空格分段,不会被并进来。 URL="" for _ in $(seq 1 120); do if ! kill -0 "$SERVER_PID" 2>/dev/null; then @@ -154,7 +160,7 @@ for _ in $(seq 1 120); do tail -30 "$WEB_LOG" >&2 || true exit 1 fi - if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then + if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+[^ ]*' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then break fi sleep 1 diff --git a/src/client/MarkdownHtml.tsx b/src/client/MarkdownHtml.tsx index 52f97e7..70011ab 100644 --- a/src/client/MarkdownHtml.tsx +++ b/src/client/MarkdownHtml.tsx @@ -19,6 +19,7 @@ import { createElement, type ReactNode } from 'react' import DOMPurify from 'dompurify' import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComponentType } from 'react' +import { markdownTextProps } from './markdown-labels.tsx' import { lazyChunkComponent } from './lazy-chunk.tsx' import { resolveLocalMediaDest } from './markdown-images.ts' import { @@ -193,7 +194,7 @@ function MarkdownSegment({ text, hasMermaid, media, codeLabels }: MarkdownSegmen
{hasMermaid ? - : } + : }
) } diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index 775d396..0dc500d 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -34,6 +34,7 @@ import { StateDot, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' +import { markdownTextProps } from './markdown-labels.tsx' import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx' import type { Context, SidebarHistoryEntry } from '../context-types.ts' import { @@ -186,13 +187,13 @@ function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNo case 'user': return (
- +
) case 'assistant': return (
- +
) case 'reasoning': diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx index b9ef995..25114c9 100644 --- a/src/client/TextEditor.tsx +++ b/src/client/TextEditor.tsx @@ -20,6 +20,7 @@ import { EditorState } from '@codemirror/state' import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { markdownTextProps } from './markdown-labels.tsx' import { api, htmlUrl } from './api.ts' import { rewriteLocalImageUrls } from './markdown-images.ts' import { languageForPath } from './lang.ts' @@ -422,7 +423,7 @@ export function TextEditor(props: FileViewerProps) { ? : hasMermaid ? - : } + : } )} {html && mode === 'preview' && ( diff --git a/src/client/chunk-loader.ts b/src/client/chunk-loader.ts index 30f0a9a..5f7f64f 100644 --- a/src/client/chunk-loader.ts +++ b/src/client/chunk-loader.ts @@ -67,6 +67,13 @@ type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports * (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were * dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the * chunks never asked for them, so they no longer belong here. + * + * DSH 0.1.2-alpha.1 removed the `dsh-client-runtime` package outright (the + * seed table gained bare-name `@deepseek-ai/dsh-client-store` instead); the + * runtime/client row below stays for 0.1.1-rc.x hosts — no chunk requires + * it, and {@link buildExternalsRequire} keeps an unresolvable spec + * undefined until a chunk actually asks (only then is it a loud error), so + * the entry is inert on 0.1.2-alpha.1+. */ export const CHUNK_EXTERNALS: readonly string[] = [ 'react', diff --git a/src/client/markdown-labels.tsx b/src/client/markdown-labels.tsx new file mode 100644 index 0000000..e29b0d6 --- /dev/null +++ b/src/client/markdown-labels.tsx @@ -0,0 +1,52 @@ +/** + * Copy-button / chrome labels for DSH's shared `MarkdownText`, shaped for + * BOTH prop generations the plugin supports: + * + * - 0.1.1-rc.x: optional flat prop `codeLabels` — the renderer reads + * `labels.copyLabel` / `labels.copiedLabel` directly. + * - 0.1.2-alpha.1+: renamed to a REQUIRED `labels` prop with a nested shape + * (`labels.code.copyLabel` + a screen-reader-only `labels.footnotes` + * heading). Passing only the old prop crashes the fence render with + * "Cannot read properties of undefined (reading 'code')" — the exact + * regression the mount lane caught on a real 0.1.2-alpha.1 host. + * + * The union object satisfies both readers, and {@link markdownTextProps} + * passes it under BOTH prop names — each host ignores the one it does not + * know. `footnotes` is left empty (the heading is sr-only; give it a real + * string only if a locale key ever earns its place in all 19 dictionaries). + */ +import type { ComponentProps } from 'react' +import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' + +/** The flat copy-button pair the plugin threads through its own props + * (e.g. MermaidMarkdownProps.codeLabels — the chunk contract stays put). */ +export interface MarkdownCopyLabels { + copyLabel: string + copiedLabel: string +} + +/** The dual-generation chrome labels object (see module doc). */ +export interface MarkdownChromeLabels extends MarkdownCopyLabels { + /** 0.1.2-alpha.1+ nested reads. */ + code: MarkdownCopyLabels + /** 0.1.2-alpha.1+ sr-only footnotes heading. */ + footnotes: string +} + +/** Build the dual-shape chrome labels from a flat copy-button pair. */ +export function markdownChromeLabels(labels: MarkdownCopyLabels): MarkdownChromeLabels { + return { + copyLabel: labels.copyLabel, + copiedLabel: labels.copiedLabel, + code: { copyLabel: labels.copyLabel, copiedLabel: labels.copiedLabel }, + footnotes: '', + } +} + +/** MarkdownText props carrying the labels under BOTH prop names. The cast is + * load-bearing: the plugin builds against the 0.1.1-rc.x declaration, where + * `labels` does not exist yet (and vice versa on a 0.1.2-alpha.1+ host). */ +export function markdownTextProps(text: string, labels: MarkdownCopyLabels): ComponentProps { + const chrome = markdownChromeLabels(labels) + return { text, codeLabels: chrome, labels: chrome } as unknown as ComponentProps +} diff --git a/src/client/mermaid.tsx b/src/client/mermaid.tsx index d2d3b38..7a7806c 100644 --- a/src/client/mermaid.tsx +++ b/src/client/mermaid.tsx @@ -30,6 +30,7 @@ import { createRoot, type Root } from 'react-dom/client' import mermaid from 'mermaid' import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import { isDarkScheme, subscribeColorScheme } from './theme.ts' +import { markdownTextProps } from './markdown-labels.tsx' import { t } from './locales.ts' import { sanitizeSvg } from './mermaid-sanitize.ts' import type { MermaidMarkdownProps } from './mermaid-blocks.ts' @@ -399,7 +400,7 @@ export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): Rea return (
- +
) } diff --git a/src/client/service.ts b/src/client/service.ts index f18738c..022b9b7 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -472,7 +472,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ -export const SIDEBAR_SERVICE_VERSION = '0.17.0' +export const SIDEBAR_SERVICE_VERSION = '0.17.1' /** * Monotonic capability list consumers use to gate new API usage (features diff --git a/tests/e2e-host-protocol.spec.ts b/tests/e2e-host-protocol.spec.ts new file mode 100644 index 0000000..4bf6262 --- /dev/null +++ b/tests/e2e-host-protocol.spec.ts @@ -0,0 +1,201 @@ +/** + * Unit tests for the e2e lanes' host-transport contracts (tests/e2e/ + * host-protocol.ts + host.ts), locking in the two DSH web dialects the lanes + * must speak: + * + * - 0.1.1-rc.x: bare-origin launch URL, no browser auth, ApiProxy dot + * endpoints (`POST /api/workspace.create`, payload = the bare args). + * - 0.1.2-alpha.1+ (Remote gateway + one-time-token browser auth): + * `/?token=<43 chars>` launch URL (303 → signed cookie), slash endpoints + * (`POST /api/workspace/create`, payload must be exactly `{ args }`, the + * envelope method equal to the path endpoint; a dot path is no longer + * claimed → 404). + * + * The shapes were transcribed from the deepseek-harness sources at tags + * dsh-v0.1.1-rc.2 and dsh-v0.1.2-alpha.1 (packages/client/connection, + * packages/api/gateway, packages/bundle/web-app). + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { APIRequestContext } from '@playwright/test' +import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './e2e/host-protocol' + +const BARE_URL = 'http://127.0.0.1:4199' +const TOKEN_URL = 'http://127.0.0.1:4199/?token=AbCdEf0123456789_-AbCdEf0123456789_-AbCd' + +// host.ts validates DSH_E2E_URL at module load; stub the env BEFORE the +// dynamic import so the glue module can load in the unit environment. +let host: typeof import('./e2e/host') + +beforeAll(async () => { + vi.stubEnv('DSH_E2E_URL', BARE_URL) + host = await import('./e2e/host') +}) + +describe('host-protocol: launch URL parsing', () => { + it('splits a bare-origin launch URL (0.1.1-rc.x hosts)', () => { + const launch = parseLaunchUrl(BARE_URL) + expect(launch.origin).toBe(BARE_URL) + expect(launch.pageUrl).toBe(BARE_URL) + expect(launch.token).toBeUndefined() + }) + + it('extracts the one-time token of an authenticated launch URL (0.1.2-alpha.1+)', () => { + const launch = parseLaunchUrl(TOKEN_URL) + expect(launch.origin).toBe(BARE_URL) + expect(launch.pageUrl).toBe(TOKEN_URL) + expect(launch.token).toBe('AbCdEf0123456789_-AbCdEf0123456789_-AbCd') + }) +}) + +describe('host-protocol: page URL query merge', () => { + it('appends stamps to a bare-origin URL', () => { + expect(pageUrlWith(BARE_URL, { 'dsh-desktop-mode': 'advanced' })) + .toBe(`${BARE_URL}/?dsh-desktop-mode=advanced`) + }) + + it('merges stamps into a token URL without breaking the token (never append ?…?…)', () => { + const merged = pageUrlWith(TOKEN_URL, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' }) + const parsed = new URL(merged) + expect(parsed.searchParams.get('token')).toBe('AbCdEf0123456789_-AbCdEf0123456789_-AbCd') + expect(parsed.searchParams.get('dsh-desktop-mode')).toBe('advanced') + expect(parsed.searchParams.get('dsh-desktop-platform')).toBe('win32') + expect(merged.match(/\?/g)).toHaveLength(1) + }) +}) + +describe('host-protocol: dual-dialect RPC attempts', () => { + it('orders the 0.1.1-rc.x dot dialect first (published hosts never pay a probe)', () => { + const args = { path: '/tmp/w' } + const [dot, slash] = rpcAttempts('workspace.create', args) + expect(dot).toEqual({ + protocol: 'dot', + path: '/api/workspace.create', + method: 'workspace.create', + payload: args, + }) + // The dot payload is the BARE args object (same reference — no wrapper). + expect(dot!.payload).toBe(args) + expect(slash).toEqual({ + protocol: 'slash', + path: '/api/workspace/create', + method: 'workspace/create', + payload: { args: { request: args } }, + }) + }) + + it('keys slash args by the parameter name — session/list is literally `_request` (verified on a live 0.1.2-alpha.1 host)', () => { + // typert gateway: session/list declares `_request` and rejects `{}` / + // `{request:{}}` — the wrapper table must reproduce the exact shape. + expect(rpcAttempts('session.list', {})[1]).toEqual({ + protocol: 'slash', + path: '/api/session/list', + method: 'session/list', + payload: { args: { _request: {} } }, + }) + expect(rpcAttempts('session.create', { workspaceId: 'w' })[1]).toEqual({ + protocol: 'slash', + path: '/api/session/create', + method: 'session/create', + payload: { args: { request: { workspaceId: 'w' } } }, + }) + }) + + it('fails loudly for methods without a verified slash args key', () => { + expect(() => rpcAttempts('settings.describe', {})).toThrow(/no slash-dialect args key/) + }) +}) + +/** A scripted APIRequestContext stand-in: records request paths/bodies and + * replays the queued responses (the last one repeats). */ +interface StubResponse { status: number; body: unknown } + +function stubApi(responses: StubResponse[]): APIRequestContext & { paths: string[]; bodies: unknown[] } { + const paths: string[] = [] + const bodies: unknown[] = [] + let cursor = 0 + const api = { + paths, + bodies, + post: async (path: string, options?: { data?: unknown }): Promise<{ + ok: () => boolean + status: () => number + text: () => Promise + }> => { + paths.push(path) + bodies.push(options?.data) + const response = responses[Math.min(cursor, responses.length - 1)]! + cursor += 1 + const bodyText = JSON.stringify(response.body) + return { + ok: () => response.status >= 200 && response.status < 300, + status: () => response.status, + text: async () => bodyText, + } + }, + } + return api as unknown as APIRequestContext & { paths: string[]; bodies: unknown[] } +} + +describe('host glue: hostRpc dialect negotiation', () => { + beforeEach(() => { + host.resetHostRpcForTests() + }) + + it('uses the dot dialect directly when the host answers it (0.1.1-rc.x)', async () => { + const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { items: [] } } } }]) + const first = await host.hostRpc<{ items: unknown[] }>(api, 'session.list', {}) + expect(first.value.items).toEqual([]) + // Second call must NOT probe again: the resolved dialect is cached. + await host.hostRpc(api, 'session.list', {}) + expect(api.paths).toEqual(['/api/session.list', '/api/session.list']) + }) + + it('falls back to the slash dialect on 404 and caches it (0.1.2-alpha.1+)', async () => { + const api = stubApi([ + { status: 404, body: { error: 'not found' } }, + { status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { sessionId: 'session-1' } } } }, + ]) + const seeded = await host.hostRpc<{ sessionId: string }>(api, 'session.create', { workspaceId: 'w' }) + expect(seeded.value.sessionId).toBe('session-1') + expect(api.paths).toEqual(['/api/session.create', '/api/session/create']) + // The slash attempt carries the Remote-gateway payload shape: args keyed + // by the controller's parameter name (`request` for session/create), + // method equal to the path endpoint. + const slashEnvelope = api.bodies[1] as { type: string; method: string; payload: Record } + expect(slashEnvelope.type).toBe('client-request') + expect(slashEnvelope.method).toBe('session/create') + expect(slashEnvelope.payload).toEqual({ args: { request: { workspaceId: 'w' } } }) + // Cached: the next call goes straight to the slash endpoint. + await host.hostRpc(api, 'session.list', {}) + expect(api.paths[2]).toBe('/api/session/list') + }) + + it('fails loudly when neither dialect is claimed', async () => { + const api = stubApi([{ status: 404, body: {} }]) + await expect(host.hostRpc(api, 'workspace.create', { path: '/tmp/w' })).rejects.toThrow(/no dialect answered/) + }) + + it('surfaces an envelope error instead of returning it', async () => { + const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: false, error: { message: 'boom' } } } }]) + await expect(host.hostRpc(api, 'workspace.create', { path: '/tmp/w' })).rejects.toThrow(/envelope error/) + }) +}) + +describe('host glue: one-time-token cookie exchange', () => { + it('follows the token URL with redirect manual and keeps only the cookie pair', async () => { + const calls: Array<{ url: string; init: RequestInit }> = [] + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + return new Response(null, { status: 303, headers: { 'set-cookie': 'dsh-auth-abc=xyz; Path=/; HttpOnly; SameSite=Strict' } }) + }) as typeof fetch + const cookie = await host.exchangeLaunchCookie(TOKEN_URL, fetchImpl) + expect(cookie).toBe('dsh-auth-abc=xyz') + expect(calls[0]!.url).toBe(TOKEN_URL) + expect(calls[0]!.init.redirect).toBe('manual') + }) + + it('rejects a token exchange without set-cookie (the /api seeding cannot proceed)', async () => { + const fetchImpl = (async () => new Response('unauthorized', { status: 401 })) as typeof fetch + await expect(host.exchangeLaunchCookie(TOKEN_URL, fetchImpl)).rejects.toThrow(/no set-cookie/) + }) +}) diff --git a/tests/e2e/drag-layout.e2e.ts b/tests/e2e/drag-layout.e2e.ts index 1d19a17..a3ce7fb 100644 --- a/tests/e2e/drag-layout.e2e.ts +++ b/tests/e2e/drag-layout.e2e.ts @@ -29,12 +29,8 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { test, expect, request, type APIRequestContext, type Locator, type Page } from '@playwright/test' - -const BASE_URL = process.env.DSH_E2E_URL -if (!BASE_URL) { - throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') -} +import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test' +import { PAGE_URL, createHostApi, hostRpc } from './host' /** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially * but against the same server — never share seed paths). */ @@ -42,28 +38,17 @@ const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh let api: APIRequestContext -/** Seed one workspace + one session through the host's unary RPC surface. */ +/** Seed one workspace + one session through the host's unary RPC surface + * (dual-protocol helper — see ./host-protocol.ts). */ async function seedSession(): Promise { mkdirSync(WORKSPACE_PATH, { recursive: true }) writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n') - const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { - data: { type: 'client-request', rpcId: 'e2e-drag-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, - }) - expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true) - const workspaceBody = (await workspace.json()) as { - result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown } - } - expect(workspaceBody.result.ok).toBe(true) - const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId - - const session = await api.post(`${BASE_URL}/api/session.create`, { - data: { type: 'client-request', rpcId: 'e2e-drag-session', method: 'session.create', payload: { workspaceId } }, - }) - expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true) + const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) + await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) } test.beforeAll(async () => { - api = await request.newContext({ baseURL: BASE_URL }) + api = await createHostApi() await seedSession() }) @@ -81,7 +66,7 @@ interface FrameSample { } test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) @@ -258,7 +243,7 @@ test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', as }) test('a very fast width drag still commits the dragged position (no rollback on quick release)', async ({ page }) => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) @@ -470,7 +455,7 @@ async function dispatchPointerOnStrip( } test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the dragged width (issue #247)', async ({ page }) => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) @@ -505,7 +490,7 @@ test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the }) test('a capture-lost drag with no usable coordinates keeps the last applied width (issue #247)', async ({ page }) => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) @@ -540,7 +525,7 @@ test('a capture-lost drag with no usable coordinates keeps the last applied widt drag release ─────────────────────────────────────────────────────── */ test('bottom panel never flashes full-width after a width drag release (issue #258)', async ({ page }) => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) @@ -634,7 +619,7 @@ test('the bottom-push anchor resolves through the composite selectors (at least // VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so // the contract is "at least one resolves", and when both resolve they // must hit the SAME element (otherwise the push would land twice). - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) const anchors = await page.evaluate(() => { const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]') diff --git a/tests/e2e/float-window.e2e.ts b/tests/e2e/float-window.e2e.ts index e2e819b..2c92e14 100644 --- a/tests/e2e/float-window.e2e.ts +++ b/tests/e2e/float-window.e2e.ts @@ -18,12 +18,8 @@ import { mkdirSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { test, expect, request, type APIRequestContext, type Page } from '@playwright/test' - -const BASE_URL = process.env.DSH_E2E_URL -if (!BASE_URL) { - throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') -} +import { test, expect, type APIRequestContext, type Page } from '@playwright/test' +import { PAGE_URL, createHostApi, hostRpc } from './host' const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-float-workspace') @@ -33,27 +29,12 @@ let api: APIRequestContext async function seedSession(): Promise { mkdirSync(WORKSPACE_PATH, { recursive: true }) - const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { - data: { type: 'client-request', rpcId: 'e2e-float-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, - }) - expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true) - const workspaceBody = (await workspace.json()) as { - result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown } - } - expect(workspaceBody.result.ok).toBe(true) - const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId - const session = await api.post(`${BASE_URL}/api/session.create`, { - data: { type: 'client-request', rpcId: 'e2e-float-session', method: 'session.create', payload: { workspaceId } }, - }) - expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true) - const sessionBody = (await session.json()) as { - result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown } - } - expect(sessionBody.result.ok).toBe(true) + const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) + await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) } test.beforeAll(async () => { - api = await request.newContext({ baseURL: BASE_URL }) + api = await createHostApi() await seedSession() }) @@ -93,7 +74,7 @@ async function dismissTakeovers(page: Page): Promise { /** Load the shell, dismiss onboarding takeovers, and expand the panel. * Returns the sidebar host locator. (An arrow const, not a hoisted function - * declaration, so the module-level BASE_URL narrowing carries in.) */ + * declaration, so the module-level PAGE_URL narrowing carries in.) */ const bootExpanded = async (page: Page): Promise> => { const pageErrors: string[] = [] const consoleErrors: string[] = [] @@ -101,7 +82,7 @@ const bootExpanded = async (page: Page): Promise> => page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) }) - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) diff --git a/tests/e2e/host-protocol.ts b/tests/e2e/host-protocol.ts new file mode 100644 index 0000000..99b303d --- /dev/null +++ b/tests/e2e/host-protocol.ts @@ -0,0 +1,104 @@ +/** + * Pure host-transport contracts for the e2e lanes, spanning the DSH + * 0.1.1-rc.x → 0.1.2-alpha.1 breaking split (Remote gateway + one-time-token + * browser auth). No Playwright imports here — these shapes are unit-testable + * (tests/e2e-host-protocol.spec.ts); ./host.ts wires them to a request + * context. + * + * What changed in 0.1.2-alpha.1 (deepseek-harness tag dsh-v0.1.2-alpha.1): + * + * - `dsh web` prints an AUTHENTICATED launch URL — `dsh web: + * http://127.0.0.1:/?token=<43 base64url chars>`. Navigating it + * exchanges the token for a signed cookie (HttpOnly, SameSite=Strict) that + * every `/api` request must then carry; the clean URL answers 401. Older + * hosts print a bare origin and have no browser auth. + * - The legacy ApiProxy dot-method endpoints (`POST /api/workspace.create` + * with `payload: `) were replaced by the Remote gateway's slash + * endpoints — `POST /api/workspace/create` with `payload: { args }` whose + * args object is keyed by the controller's TypeScript PARAMETER name + * (`workspace/create` → `{ request: {...} }`; `session/list`'s unused + * parameter is literally `_request` and is not omissible); a dot path is + * no longer claimed (404). The `{type:'client-request', rpcId, method, + * payload}` / `{type:'server-response', rpcId, result}` envelopes are + * unchanged, and the plugin's own `/sidebar/*` routes stay public (the + * webserver carrier owns no authentication — only `/api`, the index HTML, + * and the remote mux upgrade sit behind the browser auth). + */ + +/** The pieces of a `dsh web` launch URL the lanes need. */ +export interface LaunchUrl { + /** Scheme + host + port — the base for URL construction (a token URL would + * corrupt path concatenation). */ + origin: string + /** The URL as printed (token query included on 0.1.2-alpha.1+); the correct + * page.goto target — navigating it performs the token→cookie exchange. */ + pageUrl: string + /** The one-time launch token, when the host printed one. */ + token: string | undefined +} + +/** Split a `dsh web` launch URL (bare origin on 0.1.1-rc.x, `/?token=` on + * 0.1.2-alpha.1+) into the pieces the lanes address. */ +export function parseLaunchUrl(raw: string): LaunchUrl { + const url = new URL(raw) + return { + origin: url.origin, + pageUrl: raw, + token: url.searchParams.get('token') ?? undefined, + } +} + +/** Page navigation URL with extra query stamps (e.g. desktop-shell URL + * parameters) merged in — never naively append `?...` to a token URL (a + * double `?` breaks both params). The token survives the merge. */ +export function pageUrlWith(raw: string, extra: Record): string { + const url = new URL(raw) + for (const [key, value] of Object.entries(extra)) url.searchParams.set(key, value) + return url.toString() +} + +/** One host-RPC protocol attempt. */ +export interface RpcAttempt { + /** Which host dialect this attempt speaks. */ + protocol: 'dot' | 'slash' + /** Request path, relative to the origin, starting with `/api/`. */ + path: string + /** Envelope `method` string — must equal the endpoint the path selects. */ + method: string + /** Envelope `payload` — the bare args object on dot hosts, `{ args }` on + * slash hosts (the gateway rejects any other payload shape). */ + payload: Record +} + +/** The slash-dialect args key per host method: the 0.1.2-alpha.1 Remote + * controllers key the args object by the TypeScript PARAMETER name, not by + * the endpoint's own naming — workspace/create and session/create declare a + * single `request` parameter, while session/list's unused parameter is + * literally named `_request` (and is NOT omissible: the typert gateway + * rejects `{}` and `{request:{}}` alike). Verified against a live + * dsh-v0.1.2-alpha.1 host: every other shape fails with `args fields do not + * match the descriptor`. */ +const SLASH_ARGS_KEY: Record = { + 'workspace.create': 'request', + 'session.create': 'request', + 'session.list': '_request', +} + +/** Build the two dialects of one host RPC (method in dot form, e.g. + * `'workspace.create'`): the 0.1.1-rc.x dot endpoint first — the dialect + * every currently-published host speaks, so the deployed lanes never pay a + * probe round-trip — then the 0.1.2-alpha.1+ slash endpoint as the 404 + * fallback. Throws for methods without a known slash args key: the wrapper + * is per-parameter, so an unverified shape must fail loudly at build time + * instead of as a confusing gateway error mid-lane. */ +export function rpcAttempts(method: string, args: Record): RpcAttempt[] { + const slashKey = SLASH_ARGS_KEY[method] + if (slashKey === undefined) { + throw new Error(`host-protocol: no slash-dialect args key for ${JSON.stringify(method)} — verify the parameter name on the host and extend SLASH_ARGS_KEY`) + } + const slash = method.split('.').join('/') + return [ + { protocol: 'dot', path: `/api/${method}`, method, payload: args }, + { protocol: 'slash', path: `/api/${slash}`, method: slash, payload: { args: { [slashKey]: args } } }, + ] +} diff --git a/tests/e2e/host.ts b/tests/e2e/host.ts new file mode 100644 index 0000000..ad9fcc7 --- /dev/null +++ b/tests/e2e/host.ts @@ -0,0 +1,154 @@ +/** + * Host glue for the e2e lanes: page/asset URL construction, the authenticated + * request context, and the dual-protocol host RPC. The cross-version shapes + * (bare origin vs one-time-token URL, dot vs slash `/api` endpoints) live in + * ./host-protocol.ts; this module only wires them to Playwright. + * + * Every lane consumes the same contract regardless of host version: + * - `PAGE_URL` / `pageUrl({...})` for page.goto (the token URL performs the + * browser's token→cookie exchange on 0.1.2-alpha.1+; a bare origin is a + * no-op on older hosts), + * - `sidebarApi(path)` for the plugin's own public routes, + * - `createHostApi()` for an APIRequestContext that carries the auth cookie + * when the host printed a token, + * - `hostRpc(api, 'workspace.create', {...})` for the host's unary RPC — + * dot endpoint first, slash endpoint on 404 fallback (see host-protocol). + */ +import { request, type APIRequestContext, type Page } from '@playwright/test' +import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './host-protocol' + +const envUrl = process.env.DSH_E2E_URL +if (!envUrl) { + throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') +} +// Re-bind with an explicit string type: closure bodies below do not inherit +// the guard's narrowing of envUrl. +const RAW_URL: string = envUrl + +const LAUNCH = parseLaunchUrl(RAW_URL) + +/** Origin for URL construction (a token URL would corrupt path joins). */ +export const ORIGIN = LAUNCH.origin + +/** page.goto target: the launch URL as printed (token included on + * 0.1.2-alpha.1+ — navigating it exchanges the token for the cookie). */ +export const PAGE_URL = LAUNCH.pageUrl + +/** page.goto target with extra query stamps merged (token preserved). */ +export function pageUrl(extra: Record): string { + return pageUrlWith(RAW_URL, extra) +} + +/** Absolute URL for the plugin's own (public, unauthenticated) API route. */ +export function sidebarApi(path: string): string { + return `${ORIGIN}/sidebar/api/${path}` +} + +/** Exchange the one-time launch token for the auth-cookie header pair: the + * token URL answers 303 with Set-Cookie (following the redirect would lose + * it, hence `redirect: 'manual'`). Pure aside from the injected fetch — + * unit tests stub it (tests/e2e-host-protocol.spec.ts). */ +export async function exchangeLaunchCookie( + launchUrl: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const res = await fetchImpl(launchUrl, { redirect: 'manual' }) + const setCookie = res.headers.get('set-cookie') + if (setCookie === null) { + throw new Error(`token exchange failed: HTTP ${res.status} carried no set-cookie — cannot authenticate /api seeding`) + } + return setCookie.split(';', 1)[0] ?? '' +} + +/** The `name=value` pair of the auth cookie, once per process. */ +let cookieHeader: string | undefined + +/** page.goto through the host auth. A plain navigation goes to PAGE_URL (the + * token URL performs the browser's exchange on 0.1.2-alpha.1+), but a + * STAMPED navigation cannot: the exchange answers `303 → /` and silently + * DROPS every sibling query param (the mount lane caught the desktop-shell + * stamps vanishing this way), so pre-seed the browser context with the + * exchanged cookie and navigate the stamped URL directly. Playwright gives + * every test a fresh context, so the seeding is per-call and idempotent. */ +export async function gotoPage(page: Page, extra: Record = {}): Promise { + if (LAUNCH.token !== undefined && Object.keys(extra).length > 0) { + if (cookieHeader === undefined) cookieHeader = await exchangeLaunchCookie(RAW_URL) + const eq = cookieHeader.indexOf('=') + await page.context().addCookies([{ + name: cookieHeader.slice(0, eq), + value: cookieHeader.slice(eq + 1), + url: ORIGIN, + }]) + await page.goto(pageUrlWith(ORIGIN, extra), { waitUntil: 'domcontentloaded' }) + return + } + await page.goto(pageUrl(extra), { waitUntil: 'domcontentloaded' }) +} + +/** Authenticated request context for host RPC seeding (cookie attached when + * the launch URL carried a one-time token; plain on older hosts). */ +export async function createHostApi(): Promise { + if (cookieHeader === undefined && LAUNCH.token !== undefined) { + cookieHeader = await exchangeLaunchCookie(RAW_URL) + } + return request.newContext({ + baseURL: ORIGIN, + extraHTTPHeaders: cookieHeader === undefined ? {} : { cookie: cookieHeader }, + }) +} + +/** The unwrapped `result` of a successful host RPC. */ +export interface HostRpcOk { + ok: true + value: T +} + +/** Resolved dialect for hostRpc — cached after the first call so only the + * first RPC ever pays the 404 probe (dot-first: published hosts answer it + * directly; 0.1.2-alpha.1+ 404s the dot path and answers the slash one). */ +let protocol: 'dot' | 'slash' | undefined + +/** Test hook: forget the resolved dialect between unit-test cases. */ +export function resetHostRpcForTests(): void { + protocol = undefined +} + +let rpcCounter = 0 + +/** Call a host unary RPC (`'workspace.create'`, `'session.create'`, …) with + * the args object both dialects accept. Throws with the response body when + * the HTTP call or the envelope result fails — the lanes' seeding treats any + * failure as fatal, so the error text carries everything a report needs. */ +export async function hostRpc( + api: APIRequestContext, + method: string, + args: Record = {}, +): Promise> { + const attempts = rpcAttempts(method, args) + const ordered = protocol === 'slash' ? [attempts[1]!, attempts[0]!] : attempts + rpcCounter += 1 + const rpcId = `e2e-${method}-${rpcCounter}` + for (const attempt of ordered) { + const res = await api.post(attempt.path, { + data: { type: 'client-request', rpcId, method: attempt.method, payload: attempt.payload }, + }) + const bodyText = await res.text() + if (res.status() === 404 && protocol === undefined) continue // endpoint not claimed on this host → other dialect + if (!res.ok()) { + throw new Error(`hostRpc ${method} [${attempt.protocol}] HTTP ${res.status()}: ${bodyText.slice(0, 400)}`) + } + let envelope: { type?: string; result?: { ok: true; value: T } | { ok: false; error: unknown } } + try { + envelope = JSON.parse(bodyText) as typeof envelope + } catch { + throw new Error(`hostRpc ${method} [${attempt.protocol}]: non-JSON response: ${bodyText.slice(0, 400)}`) + } + const result = envelope.result + if (result === undefined || result.ok !== true) { + throw new Error(`hostRpc ${method} [${attempt.protocol}] envelope error: ${bodyText.slice(0, 400)}`) + } + protocol = attempt.protocol + return result + } + throw new Error(`hostRpc ${method}: no dialect answered (dot and slash both 404) — is this a DSH web host?`) +} diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts index 0e2495e..9b65f26 100644 --- a/tests/e2e/mount.e2e.ts +++ b/tests/e2e/mount.e2e.ts @@ -29,12 +29,8 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { test, expect, request, type APIRequestContext } from '@playwright/test' - -const BASE_URL = process.env.DSH_E2E_URL -if (!BASE_URL) { - throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') -} +import { test, expect, type APIRequestContext } from '@playwright/test' +import { PAGE_URL, createHostApi, gotoPage, hostRpc, sidebarApi } from './host' /** Workspace the sidebar renders against (created by the lane's seeding). */ const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace') @@ -127,29 +123,17 @@ async function seedSession(): Promise { '[def]: https://example.com/def', '', ].join('\n')) - const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { - data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, - }) - expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true) - const workspaceBody = (await workspace.json()) as { - result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown } - } - expect(workspaceBody.result.ok).toBe(true) - const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId - - const session = await api.post(`${BASE_URL}/api/session.create`, { - data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } }, - }) - expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true) - const sessionBody = (await session.json()) as { - result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown } - } - expect(sessionBody.result.ok).toBe(true) - seededSessionId = (sessionBody.result as { value: { sessionId: string } }).value.sessionId + // Seeded through the lanes' dual-protocol RPC helper (./host): 0.1.1-rc.x + // dot endpoints first, 0.1.2-alpha.1+ slash endpoints on 404 fallback, and + // the request context carries the auth cookie when the launch URL had a + // one-time token. + const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) + const session = await hostRpc<{ sessionId: string }>(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) + seededSessionId = session.value.sessionId } test.beforeAll(async () => { - api = await request.newContext({ baseURL: BASE_URL }) + api = await createHostApi() await seedSession() }) @@ -176,7 +160,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async (response) => response.url().includes('/sidebar/bundle/editor.js'), { timeout: 120_000 }, ) - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) @@ -310,7 +294,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async // (keyless boot has no model route), but admission + creation + the wire // envelope must all succeed — this is the deepest functional proof the // mount lane can make without a provider. - const start = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, { + const start = await api.post(sidebarApi('sidechat.start'), { data: { sessionId: seededSessionId, question: 'mount lane smoke' }, }) expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true) @@ -325,21 +309,14 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async // The child must be a REAL session in the host's store (provider-free // proof of the custom-seed creation; the boundary message itself only // becomes durable when a turn claims it, which needs a model route). - const list = await api.post(`${BASE_URL}/api/session.list`, { - data: { type: 'client-request', rpcId: 'e2e-sidechat-list', method: 'session.list', payload: {} }, - }) - expect(list.ok(), `session.list: ${list.status()} ${await list.text()}`).toBe(true) - const listBody = (await list.json()) as { - result: { ok: true; value: { items: Array<{ sessionId: string }> } } | { ok: false; error: unknown } - } - expect(listBody.result.ok, `session.list envelope: ${JSON.stringify(listBody)}`).toBe(true) - const listedItems = (listBody.result as { value: { items: Array<{ sessionId: string }> } }).value.items + const list = await hostRpc<{ items: Array<{ sessionId: string }> }>(api, 'session.list', {}) + const listedItems = list.value.items expect( listedItems.some(item => item.sessionId === childId), 'the thread child must appear in the host session list', ).toBe(true) for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) { - const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, { + const response = await api.post(sidebarApi(method), { data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId }, }) expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true) @@ -348,14 +325,14 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async // The Codex-style immediate-create flow: a blank question creates an // EMPTY thread (no prompt admitted), sidechat.info reports the live // agent, and the first prompt delivers the boundary host-side. - const empty = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, { + const empty = await api.post(sidebarApi('sidechat.start'), { data: { sessionId: seededSessionId, question: '' }, }) expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true) const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } } const emptyChildId = emptyBody.value?.childId expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/) - const info = await api.post(`${BASE_URL}/sidebar/api/sidechat.info`, { + const info = await api.post(sidebarApi('sidechat.info'), { data: { childId: emptyChildId }, }) expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true) @@ -363,7 +340,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true) expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true) for (const method of ['sidechat.prompt', 'sidechat.dispose']) { - const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, { + const response = await api.post(sidebarApi(method), { data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId }, }) expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true) @@ -559,7 +536,7 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome // Window Controls Overlay API the layout must stay untouched (plain-web // semantics) — the strip/body attribute appear only for real standard // geometry (see the WCO scenario below) or an opt-in preset. - await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' }) + await gotoPage(page, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' }) await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) await expect( page.locator('body[data-dsh-title-bar-compat]'), @@ -607,7 +584,7 @@ test('standard WCO geometry drives the strip reactively (issue #257)', async ({ }, } }) - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) // Real reported height (36px, not a hardcoded 32) drives the strip. await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 }) @@ -629,12 +606,12 @@ test('opt-in shell preset applies its strip when WCO is absent (data-driven, man // The anywhere-labs DSH Desktop preset (shell-presets.ts) is OPT-IN: under // the preset scheme the win32 advanced stamp resolves to its 32px fallback // even without the WCO API; auto never does this. - const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, { + const update = await request.post(sidebarApi('settings.update'), { data: { patch: { titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', titleBarCompat: true } }, }) expect(update.ok(), `settings.update: ${update.status()}`).toBe(true) try { - await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' }) + await gotoPage(page, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' }) await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 }) await expect @@ -647,26 +624,26 @@ test('opt-in shell preset applies its strip when WCO is absent (data-driven, man await expect(page.locator('style[data-dsh-preset-css]')).toHaveCount(0) } finally { // Restore the shared server state for the lanes after this one. - await request.post(`${BASE_URL}/sidebar/api/settings.update`, { + await request.post(sidebarApi('settings.update'), { data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } }, }) } }) test('custom scheme injects the user stylesheet live', async ({ request, page }) => { - const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, { + const update = await request.post(sidebarApi('settings.update'), { data: { patch: { titleBarScheme: 'custom', customCss: 'html { --dsh-e2e-marker: 1; }', titleBarCompat: true } }, }) expect(update.ok(), `settings.update: ${update.status()}`).toBe(true) try { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 }) await expect(page.locator('style[data-dsh-custom-css="custom"]')).toBeAttached() // The injected CSS is live (a custom property the page can read back). const marker = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--dsh-e2e-marker').trim()) expect(marker).toBe('1') } finally { - await request.post(`${BASE_URL}/sidebar/api/settings.update`, { + await request.post(sidebarApi('settings.update'), { data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } }, }) } diff --git a/tests/e2e/toggle-layout.e2e.ts b/tests/e2e/toggle-layout.e2e.ts index e391079..3011f44 100644 --- a/tests/e2e/toggle-layout.e2e.ts +++ b/tests/e2e/toggle-layout.e2e.ts @@ -17,12 +17,8 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { test, expect, request, type APIRequestContext } from '@playwright/test' - -const BASE_URL = process.env.DSH_E2E_URL -if (!BASE_URL) { - throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)') -} +import { test, expect, type APIRequestContext } from '@playwright/test' +import { PAGE_URL, createHostApi, hostRpc } from './host' const WORKSPACE_PATH = process.env.DSH_E2E_TOGGLE_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-toggle-workspace') @@ -31,23 +27,12 @@ let api: APIRequestContext async function seedSession(): Promise { mkdirSync(WORKSPACE_PATH, { recursive: true }) writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'toggle lane\n') - const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { - data: { type: 'client-request', rpcId: 'e2e-toggle-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, - }) - expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true) - const workspaceBody = (await workspace.json()) as { - result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown } - } - expect(workspaceBody.result.ok).toBe(true) - const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId - const session = await api.post(`${BASE_URL}/api/session.create`, { - data: { type: 'client-request', rpcId: 'e2e-toggle-session', method: 'session.create', payload: { workspaceId } }, - }) - expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true) + const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH }) + await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId }) } test.beforeAll(async () => { - api = await request.newContext({ baseURL: BASE_URL }) + api = await createHostApi() await seedSession() }) @@ -64,7 +49,7 @@ interface FrameSample { } test('bottom panel tracks the center column during the right-panel toggle transition (issue #315)', async ({ page }) => { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }) + await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' }) await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 }) const sidebar = page.locator('[data-dsh-better-sidebar]') await expect(sidebar).toBeAttached({ timeout: 90_000 }) diff --git a/tsdown.config.ts b/tsdown.config.ts index 50c3701..99d8261 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -55,7 +55,7 @@ const NODE_BUILTINS = new Set([ ...builtinModules.map(id => `node:${id}`), ]) -/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */ +/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption — removed in DSH 0.1.2-alpha.1 along with the runtime package, but no chunk requires it, so the entry stays inert there and live on 0.1.1-rc.x hosts). */ const CLIENT_EXTERNALS = [ 'react', 'react/jsx-runtime',