diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfda749..040dd8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,13 +10,9 @@ jobs: strategy: fail-fast: false matrix: - include: - - node: 22 - host-ref: dsh-v0.1.2-rc.1 - - node: 24 - host-ref: dsh-v0.1.6-alpha.1 + node: [22, 24] runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 15 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 @@ -28,11 +24,37 @@ jobs: with: version: 11.7.0 - - name: Clone pinned DSH host + - name: Install plugin dependencies + run: pnpm install --frozen-lockfile + + - name: Check + run: pnpm run check + + smoke: + needs: check + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 + with: + version: 11.7.0 + + - name: Resolve latest DSH tag + id: host + run: echo "ref=$(node scripts/resolve-dsh-tag.mjs)" >> "$GITHUB_OUTPUT" + + - name: Clone latest DSH host run: | DSH_ROOT="$GITHUB_WORKSPACE/../../.dsh/source/current" mkdir -p "$(dirname "$DSH_ROOT")" - git clone --depth 1 --branch "${{ matrix.host-ref }}" https://github.com/deepseek-ai/deepseek-harness.git "$DSH_ROOT" + git clone --depth 1 --branch "${{ steps.host.outputs.ref }}" https://github.com/deepseek-ai/deepseek-harness.git "$DSH_ROOT" + echo "Resolved DSH host ${{ steps.host.outputs.ref }}" - name: Install and build DSH host working-directory: ${{ github.workspace }}/../../.dsh/source/current @@ -43,9 +65,6 @@ jobs: - name: Install plugin dependencies run: pnpm install --frozen-lockfile - - name: Check - run: pnpm run check - - name: Browser annotation interactions run: | node test/overlay-browser.mjs @@ -58,7 +77,7 @@ jobs: run: node scripts/verify-pack.mjs - name: Smoke real DSH Web - run: node scripts/e2e.mjs --install tarball --tarball "${{ steps.pack.outputs.tarball }}" --tarball-sha256 "${{ steps.pack.outputs.sha256 }}" --port 3190 ${{ matrix.node == 24 && '--sidebar' || '' }} + run: node scripts/e2e.mjs --install tarball --tarball "${{ steps.pack.outputs.tarball }}" --tarball-sha256 "${{ steps.pack.outputs.sha256 }}" --port 3190 --sidebar env: DSH_ROOT: ${{ github.workspace }}/../../.dsh/source/current DSH_BIN: ${{ github.workspace }}/../../.dsh/source/current/apps/cli/lib/bin.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfcde98..07b2449 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,6 @@ jobs: env: RELEASE_TAG: ${{ github.event.release.tag_name }} PRERELEASE: ${{ github.event.release.prerelease }} - HOST_REF: dsh-v0.1.6-alpha.1 steps: # actions/checkout@v4 @@ -86,13 +85,18 @@ jobs: fi fi - - name: Clone selected DSH host + - name: Resolve latest DSH tag + id: host + run: echo "ref=$(node scripts/resolve-dsh-tag.mjs)" >> "$GITHUB_OUTPUT" + + - name: Clone latest DSH host shell: bash run: | DSH_ROOT="$GITHUB_WORKSPACE/../../.dsh/source/current" mkdir -p "$(dirname "$DSH_ROOT")" - git clone --depth 1 --branch "$HOST_REF" https://github.com/deepseek-ai/deepseek-harness.git "$DSH_ROOT" + git clone --depth 1 --branch "${{ steps.host.outputs.ref }}" https://github.com/deepseek-ai/deepseek-harness.git "$DSH_ROOT" echo "DSH_ROOT=$DSH_ROOT" >> "$GITHUB_ENV" + echo "Resolved DSH host ${{ steps.host.outputs.ref }}" - name: Install and build DSH host working-directory: ${{ github.workspace }}/../../.dsh/source/current diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e7cb6..14c56ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ - **第三种界面语言不再把批注界面退回中文**:本插件只有 zh / en 两套文案,而 DSH 的 locale 服务不止这两种。节点选定 `ru`(或任何 zh/en 之外的语言)时,`setLang` 以前把一切非 zh/en 都当成 zh,于是整套批注 UI —— 工具条、编辑窗口、提示、批注协议块 —— 全变成中文。现在 `zh`/`en`/`ru` 原样采用,其余一律英文;`test/locale-fallback.test.mjs` 固定这条规则(fr / de / zh-Hans 走英文)。 - **恢复 DSH 0.1.1(textarea 输入区)上的 Enter 批注拼稿**:1.4.6 适配 0.1.2 的 `div[data-composer-input]` 输入区时,移除了 Enter keydown 门控里的 textarea 分支,旧核心上 Enter 不再拼入批注块(发送按钮路径不受影响,但 Enter 发送会静默丢弃待发送批注)。门控现并行接受 `div[data-composer-input]` 与 textarea 两条分支;0.1.1-rc.2 实测恢复,div 分支行为不变。 +### 工程 +- CI 与发布验收不再把宿主钉在过期标签上。单元检查(Node 22 / 24)只跑 `pnpm run check`,不克隆宿主;冒烟和发布前的真实 Web 验收改为 `scripts/resolve-dsh-tag.mjs` 在运行时选取最高的 `dsh-v*` 标签。冒烟探针按文档相对路径解析插件地址(0.2.0 起是 `plugins/??…`),不再把相对路径粘到端口后面。 + ## [1.4.11-preview.1] - 2026-09-15 - 更新 preview 预发布验收宿主至 DSH 0.1.6-alpha.1;发布任务验证同一安装包后发布至 npm preview。 diff --git a/README.md b/README.md index 7a92f29..b2c8dc5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # dsh-annotation -Verified hosts: DSH 0.1.6-alpha.1 and the 0.1.2-rc.1 minimum. These are core versions, not Desktop shell versions. +CI runs unit checks on Node 22 and 24 without cloning a DSH host. Browser smoke and release acceptance resolve the highest `dsh-v*` tag on deepseek-ai/deepseek-harness at run time. These are core versions, not Desktop shell versions. Sidebar file annotations (DSH 0.1.6-alpha.1): select plain text, Markdown, or code in a workspace file preview and use the same annotate, save, and Enter-to-send flow. The editor and model-facing quote include the file path. After reloading and reopening the original file, its markers are restored; identical passages in different files remain separate. PDF, images, and HTML inside iframes are not covered. diff --git a/README.zh-CN.md b/README.zh-CN.md index a9eeda6..0402059 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # dsh-annotation -验证宿主:DSH 0.1.6-alpha.1;最低支持线仍验证 DSH 0.1.2-rc.1。版本号指 DSH 内核,不是 Desktop 外壳。 +CI 在 Node 22 与 24 上跑单元检查,不克隆 DSH 宿主。浏览器冒烟和发布验收在运行时解析 deepseek-ai/deepseek-harness 上最高的 `dsh-v*` 标签。版本号指 DSH 内核,不是 Desktop 外壳。 侧边栏文件批注(DSH 0.1.6-alpha.1):在工作区文件预览中选中文本、Markdown 或代码正文,即可沿用同一套批注、保存和回车发送流程。编辑器和发给模型的引用包含文件路径;刷新并重新打开原文件后恢复标记,同文不同文件可分别批注。当前不包含 PDF、图片或 iframe 内 HTML。 diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 7af1cd8..f35203f 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -8,6 +8,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { createServer } from 'node:net' import { fileURLToPath, pathToFileURL } from 'node:url' +import { pluginClientUrl } from './plugin-client-url.mjs' const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const DSH_ROOT = process.env.DSH_ROOT ?? resolve(process.env.HOME ?? '', '.dsh/source/current') @@ -102,7 +103,7 @@ try { } }) const clientUrl = state.clientUrl ?? '/plugins/@changfenhuang/dsh-annotation/client.js' - const response = await fetch(`http://127.0.0.1:${PORT}${clientUrl}`) + const response = await fetch(pluginClientUrl(readyUrl, clientUrl)) if (!response.ok) fail(`Annotation bundle 返回 ${response.status}`) if (!state.mounted) fail('Annotation bundle 已加载,但页面没有挂载批注入口') if (pageErrors.length > 0) fail(`页面异常: ${pageErrors.slice(0, 3).join(' | ')}`) @@ -159,13 +160,25 @@ try { const later = page.getByRole('button', { name: '稍后配置', exact: true }) if (await later.isVisible()) await later.click() async function openFile() { - const expand = page.getByRole('button', { name: '打开右侧边栏', exact: true }) - if (await expand.isVisible()) await expand.click() - if (!await page.locator('[data-sidebar-right-guide-entry="files"]').isVisible()) { - await page.getByRole('button', { name: '新标签页', exact: true }).click() + // Reload can leave the session list focused; the right sidebar belongs to the open session. + const session = page.getByText('文件批注验收', { exact: true }).first() + if (await session.isVisible()) await session.click() + const expand = page.locator('[data-sidebar-right-expand]') + const opened = page.locator('[data-sidebar-right-open]') + // The header control mounts after the session view. A collapsed panel stays + // in the DOM with visibility:hidden, so don't click that copy first. + await expand.or(opened).first().waitFor({ state: 'attached', timeout: 15000 }) + if ((await opened.count()) === 0) { + if (await expand.isVisible()) await expand.click() + else await expand.first().evaluate(button => button.click()) } - await page.getByText('工作区文件', { exact: true }).click() - await page.locator('[data-files-entry="file"][data-files-path$="/README.zh-CN.md"] button').click() + await opened.first().waitFor({ state: 'attached' }) + const files = page.locator('[data-sidebar-right-guide-entry="files"]') + if (!(await files.isVisible())) { + await page.locator('[data-dockkit-add-tab]').first().click() + } + await files.click() + await page.locator('[data-files-entry="file"][data-files-path$="/README.zh-CN.md"] > button').click() await page.locator('[data-document-markdown] p').first().waitFor({ state: 'visible' }) } await openFile() @@ -188,7 +201,7 @@ try { await page.locator('.dsh-ann-num').waitFor({ state: 'visible' }) await page.locator('.dsh-ann-num').click() if (await page.locator('.dsh-ann-input').inputValue() !== '请解释这个文件段落') fail('文件批注刷新恢复失败') - await page.locator('.dsh-ann-card-head button').click() + await page.locator('.dsh-ann-card-head > .dsh-ann-icon').click() if (pageErrors.length > 0) fail(`侧边栏异常: ${pageErrors.join(' | ')}`) console.log('PASS 真实侧边栏打开工作区文件、选区、来源路径、保存、刷新后重新打开定位、重新编辑') } diff --git a/scripts/plugin-client-url.mjs b/scripts/plugin-client-url.mjs new file mode 100644 index 0000000..1afe4ba --- /dev/null +++ b/scripts/plugin-client-url.mjs @@ -0,0 +1,8 @@ +// 把 __DSH_BOOT__ 里的插件地址解析成可 fetch 的绝对 URL。 +// 0.2.0 起 boot 条目是文档相对路径(plugins/??/client.js&rev=…), +// 直接接到 origin 后面会变成非法端口。旧宿主的 /plugins//client.js 仍然适用。 + +export function pluginClientUrl(pageUrl, clientUrl) { + const origin = new URL(pageUrl).origin + return new URL(clientUrl, `${origin}/`).href +} diff --git a/scripts/resolve-dsh-tag.mjs b/scripts/resolve-dsh-tag.mjs new file mode 100644 index 0000000..3dbc5a8 --- /dev/null +++ b/scripts/resolve-dsh-tag.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// 从 deepseek-ai/deepseek-harness 的 dsh-v* 标签里选出最高语义化版本。 +// CI 冒烟和发布验收都走这里,避免把宿主钉死在过期 tag 上。 + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +const TAG = /^dsh-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/ +const REMOTE = 'https://github.com/deepseek-ai/deepseek-harness.git' + +export function parseDshTag(name) { + const match = TAG.exec(name) + if (match === null) return null + return { + name, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + pre: match[4] === undefined ? null : match[4].split('.'), + } +} + +function compareIdentifiers(left, right) { + const leftNum = /^[0-9]+$/.test(left) + const rightNum = /^[0-9]+$/.test(right) + if (leftNum && rightNum) return Number(left) - Number(right) + if (leftNum) return -1 + if (rightNum) return 1 + return left < right ? -1 : left > right ? 1 : 0 +} + +export function compareDshTags(leftName, rightName) { + const left = parseDshTag(leftName) + const right = parseDshTag(rightName) + if (left === null || right === null) { + throw new Error(`not a dsh tag: ${left === null ? leftName : rightName}`) + } + for (const key of ['major', 'minor', 'patch']) { + if (left[key] !== right[key]) return left[key] - right[key] + } + if (left.pre === null && right.pre === null) return 0 + if (left.pre === null) return 1 + if (right.pre === null) return -1 + const length = Math.max(left.pre.length, right.pre.length) + for (let index = 0; index < length; index++) { + if (index >= left.pre.length) return -1 + if (index >= right.pre.length) return 1 + const order = compareIdentifiers(left.pre[index], right.pre[index]) + if (order !== 0) return order + } + return 0 +} + +export function latestDshTag(names) { + const tags = names.filter(name => parseDshTag(name) !== null) + if (tags.length === 0) return null + return tags.reduce((best, name) => compareDshTags(name, best) > 0 ? name : best) +} + +export function tagNamesFromLsRemote(output) { + const names = [] + for (const line of output.split('\n')) { + const ref = line.split('\t')[1] + if (ref === undefined || !ref.startsWith('refs/tags/') || ref.endsWith('^{}')) continue + names.push(ref.slice('refs/tags/'.length)) + } + return names +} + +function invokedAsCli() { + const entry = process.argv[1] + if (entry === undefined) return false + return import.meta.url === pathToFileURL(resolve(entry)).href +} + +if (invokedAsCli()) { + const output = execFileSync('git', ['ls-remote', '--tags', REMOTE], { encoding: 'utf8' }) + const latest = latestDshTag(tagNamesFromLsRemote(output)) + if (latest === null) { + console.error('deepseek-harness 上没有 dsh-v* 标签') + process.exit(1) + } + console.log(latest) +} diff --git a/test/layout-overlay.test.mjs b/test/layout-overlay.test.mjs index 8c2ed0c..fe8af4e 100644 --- a/test/layout-overlay.test.mjs +++ b/test/layout-overlay.test.mjs @@ -103,6 +103,8 @@ test('编辑窗口:标题右侧可删除该批注,且不再有取消按钮', assert.match(editor, /removeQuote\(editingId\)/, '删除复用待发送批注移除逻辑') assert.doesNotMatch(editor, /dsh-ann-cancel/, '编辑窗口不再有取消按钮') assert.doesNotMatch(source, /dsh-ann-cancel/, '取消按钮的样式一并移除') +}) + // Source slice for one `function name(...) { ... }` by brace matching. function tryExtractFunction (name) { const start = source.indexOf(`function ${name}(`) @@ -231,6 +233,3 @@ test('编号胶囊的顶部下限抬到会话列上沿 + 4px,原文已滚入 assert.match(build, /anchor\.top >= chipFloor && chipTop < chipFloor/, 'clamp only while the passage itself starts below the floor — passages scrolled under the header keep the skip rule (#65)') }) - - -}) diff --git a/test/plugin-client-url.test.mjs b/test/plugin-client-url.test.mjs new file mode 100644 index 0000000..f382630 --- /dev/null +++ b/test/plugin-client-url.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { pluginClientUrl } from '../scripts/plugin-client-url.mjs' + +const page = 'http://127.0.0.1:3190/?token=abc' + +test('文档相对的 combo 路由解析到 /plugins/,不把路径粘进端口', () => { + assert.equal( + pluginClientUrl(page, 'plugins/??@changfenhuang/dsh-annotation/client.js&rev=99'), + 'http://127.0.0.1:3190/plugins/??@changfenhuang/dsh-annotation/client.js&rev=99', + ) +}) + +test('旧宿主的绝对路径和已经绝对的 URL 保持原样', () => { + assert.equal( + pluginClientUrl(page, '/plugins/@changfenhuang/dsh-annotation/client.js'), + 'http://127.0.0.1:3190/plugins/@changfenhuang/dsh-annotation/client.js', + ) + assert.equal( + pluginClientUrl(page, 'http://127.0.0.1:3190/plugins/@changfenhuang/dsh-annotation/client.js'), + 'http://127.0.0.1:3190/plugins/@changfenhuang/dsh-annotation/client.js', + ) +}) diff --git a/test/resolve-dsh-tag.test.mjs b/test/resolve-dsh-tag.test.mjs new file mode 100644 index 0000000..f3626fa --- /dev/null +++ b/test/resolve-dsh-tag.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { compareDshTags, latestDshTag, tagNamesFromLsRemote } from '../scripts/resolve-dsh-tag.mjs' + +test('语义化比较:数字补丁、预发布顺序、正式版高于预发布', () => { + assert.ok(compareDshTags('dsh-v0.1.10', 'dsh-v0.1.9') > 0) + assert.ok(compareDshTags('dsh-v0.1.2-rc.1', 'dsh-v0.1.2-alpha.5') > 0) + assert.ok(compareDshTags('dsh-v0.1.0-rc.10', 'dsh-v0.1.0-rc.2') > 0) + assert.ok(compareDshTags('dsh-v0.2.0', 'dsh-v0.2.0-rc.2') > 0) + assert.ok(compareDshTags('dsh-v0.2.0-rc.2', 'dsh-v0.1.7-rc.2') > 0) + assert.equal(compareDshTags('dsh-v0.1.6-alpha.1', 'dsh-v0.1.6-alpha.1'), 0) +}) + +test('最高标签忽略非 dsh-v 引用和注解标签剥除行', () => { + const names = tagNamesFromLsRemote([ + 'abc\trefs/tags/dsh-v0.1.2-rc.1', + 'def\trefs/tags/dsh-v0.2.0-rc.2', + 'def\trefs/tags/dsh-v0.2.0-rc.2^{}', + 'ghi\trefs/tags/dsh-v0.1.7-rc.2', + 'jkl\trefs/tags/not-a-release', + ].join('\n')) + assert.deepEqual(names, [ + 'dsh-v0.1.2-rc.1', + 'dsh-v0.2.0-rc.2', + 'dsh-v0.1.7-rc.2', + 'not-a-release', + ]) + assert.equal(latestDshTag(names), 'dsh-v0.2.0-rc.2') + assert.equal(latestDshTag(['nightly', 'v1.0.0']), null) +})