dsh-native-session-delete 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,132 @@
1
+ {
2
+ "name": "dsh-native-session-delete",
3
+ "version": "1.0.4",
4
+ "description": "Permanent session deletion in the native DeepSeek Harness menu",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "compatibility.json",
14
+ "cordis.patch.yml",
15
+ "lib/client.js",
16
+ "scripts/build-client.mjs",
17
+ "scripts/smoke-ui.mjs",
18
+ "src/index.js",
19
+ "src/host/delete-session.mjs",
20
+ "README.md",
21
+ "README.en.md",
22
+ "AGENTS.md",
23
+ "LICENSE",
24
+ "SECURITY.md",
25
+ "THIRD_PARTY_NOTICES.md"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "inject": [
33
+ "@deepseek-ai/dsh-client-connection",
34
+ "@deepseek-ai/dsh-client-locale",
35
+ "@deepseek-ai/dsh-client-runtime",
36
+ "@deepseek-ai/dsh-client-ui-conversation",
37
+ "@deepseek-ai/dsh-client-ui-sidebar"
38
+ ],
39
+ "platform": "web"
40
+ }
41
+ },
42
+ "keywords": [
43
+ "dsh-plugin",
44
+ "deepseek-harness",
45
+ "session",
46
+ "delete",
47
+ "privacy"
48
+ ],
49
+ "author": "WSL043",
50
+ "license": "MIT",
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/WSL043/dsh-native-session-delete.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/WSL043/dsh-native-session-delete/issues"
60
+ },
61
+ "homepage": "https://github.com/WSL043/dsh-native-session-delete#readme",
62
+ "engines": {
63
+ "node": "^22.19.0 || >=24.0.0"
64
+ },
65
+ "dependencies": {
66
+ "clsx": "^2.0.0"
67
+ },
68
+ "peerDependencies": {
69
+ "@deepseek-ai/cordis": "4.0.1",
70
+ "@deepseek-ai/dsh-client-connection": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
71
+ "@deepseek-ai/dsh-client-locale": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
72
+ "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
73
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
74
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
75
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
76
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
77
+ "@deepseek-ai/dsh-invariants": "0.1.0-rc.6 || 0.1.0-rc.7 || 0.1.0-rc.8",
78
+ "react": "^18.2.0"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "@deepseek-ai/cordis": {
82
+ "optional": true
83
+ },
84
+ "@deepseek-ai/dsh-client-connection": {
85
+ "optional": true
86
+ },
87
+ "@deepseek-ai/dsh-client-locale": {
88
+ "optional": true
89
+ },
90
+ "@deepseek-ai/dsh-client-runtime": {
91
+ "optional": true
92
+ },
93
+ "@deepseek-ai/dsh-client-ui-conversation": {
94
+ "optional": true
95
+ },
96
+ "@deepseek-ai/dsh-client-ui-primitives": {
97
+ "optional": true
98
+ },
99
+ "@deepseek-ai/dsh-client-ui-sidebar": {
100
+ "optional": true
101
+ },
102
+ "@deepseek-ai/dsh-client-ui-slots": {
103
+ "optional": true
104
+ },
105
+ "@deepseek-ai/dsh-invariants": {
106
+ "optional": true
107
+ },
108
+ "react": {
109
+ "optional": true
110
+ }
111
+ },
112
+ "devDependencies": {
113
+ "@deepseek-ai/dsh-client-connection": "0.1.0-rc.8",
114
+ "@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
115
+ "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
116
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.8",
117
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.8",
118
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.1.0-rc.8",
119
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.8",
120
+ "@deepseek-ai/dsh-client-ui-workspace": "0.1.0-rc.8",
121
+ "@deepseek-ai/dsh-invariants": "0.1.0-rc.8",
122
+ "dsh-ui-workspace-rc6": "npm:@deepseek-ai/dsh-client-ui-workspace@0.1.0-rc.6",
123
+ "dsh-ui-workspace-rc7": "npm:@deepseek-ai/dsh-client-ui-workspace@0.1.0-rc.7",
124
+ "playwright": "1.62.1"
125
+ },
126
+ "scripts": {
127
+ "build": "node scripts/build-client.mjs",
128
+ "test": "node --test tests/*.test.mjs",
129
+ "check": "pnpm run test && pnpm run build && pnpm pack --pack-destination .artifacts",
130
+ "smoke:ui": "node scripts/smoke-ui.mjs"
131
+ }
132
+ }
@@ -0,0 +1,170 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+
7
+ const require = createRequire(import.meta.url)
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const output = resolve(here, '../lib/client.js')
10
+ const compatibility = JSON.parse(readFileSync(resolve(here, '../compatibility.json'), 'utf8'))
11
+ const LATEST_UPSTREAM_VERSION = compatibility.latestTested
12
+ const SUPPORTED_UPSTREAM_VERSIONS = new Set(compatibility.supported)
13
+
14
+ export const resolveUpstreamClient = () => require.resolve('@deepseek-ai/dsh-client-ui-workspace/client')
15
+ export const resolveUpstreamManifest = () => require.resolve('@deepseek-ai/dsh-client-ui-workspace/package.json')
16
+
17
+ const replaceOnce = (source, before, after, label) => {
18
+ const first = source.indexOf(before)
19
+ if (first === -1 || source.indexOf(before, first + before.length) !== -1) {
20
+ throw new Error(`upstream marker mismatch: ${label}`)
21
+ }
22
+ return `${source.slice(0, first)}${after}${source.slice(first + before.length)}`
23
+ }
24
+
25
+ const findFunctionSignature = (source, functionName) => {
26
+ const pattern = new RegExp(`function ${functionName}\\(\\{[^}]+\\}\\) \\{`, 'g')
27
+ const matches = [...source.matchAll(pattern)]
28
+ if (matches.length !== 1) throw new Error(`upstream marker mismatch: ${functionName} signature`)
29
+ return matches[0][0]
30
+ }
31
+
32
+ /**
33
+ * Add one narrow feature to the shipped workspace client while preserving the
34
+ * rest of the official bundle byte-for-byte after a modification notice.
35
+ * Exact markers turn upstream UI drift into a build failure instead of a
36
+ * silently malformed client.
37
+ */
38
+ export function patchWorkspaceClient(upstream, upstreamVersion = LATEST_UPSTREAM_VERSION) {
39
+ if (!SUPPORTED_UPSTREAM_VERSIONS.has(upstreamVersion)) {
40
+ throw new Error(`unsupported @deepseek-ai/dsh-client-ui-workspace version: ${upstreamVersion}`)
41
+ }
42
+ const sessionTreeSignature = findFunctionSignature(upstream, 'SessionTree')
43
+ const workspaceBrowserSignature = findFunctionSignature(upstream, 'WorkspaceBrowser')
44
+ if (!sessionTreeSignature.includes('onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore,')) {
45
+ throw new Error('upstream marker mismatch: SessionTree delete insertion point')
46
+ }
47
+ if (!workspaceBrowserSignature.includes('deleteWorkspace, insertWorkspaceBefore, archiveSession, insertSessionBefore,')) {
48
+ throw new Error('upstream marker mismatch: WorkspaceBrowser delete insertion point')
49
+ }
50
+ let source = upstream
51
+ const patch = (before, after, label) => {
52
+ source = replaceOnce(source, before, after, label)
53
+ }
54
+
55
+ patch(
56
+ 'id: "@deepseek-ai/dsh-client-ui-workspace",',
57
+ 'id: "dsh-native-session-delete",',
58
+ 'client module id',
59
+ )
60
+ patch(
61
+ 'function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }) {',
62
+ 'function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, onDelete, drag, flat = false, t }) {',
63
+ 'session row props',
64
+ )
65
+ patch(
66
+ `\t\t\t\t{\n\t\t\t\t\tid: "archive",\n\t\t\t\t\tlabel: t("menu.archiveSession"),\n\t\t\t\t\ticon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconArchiveOutline20, { size: 16 })\n\t\t\t\t}\n`,
67
+ `\t\t\t\t{\n\t\t\t\t\tid: "archive",\n\t\t\t\t\tlabel: t("menu.archiveSession"),\n\t\t\t\t\ticon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconArchiveOutline20, { size: 16 })\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tid: "delete-session",\n\t\t\t\t\tlabel: t("menu.deleteSession"),\n\t\t\t\t\ticon: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconTrashOutline16, {}),\n\t\t\t\t\tdanger: true\n\t\t\t\t}\n`,
68
+ 'session delete menu item',
69
+ )
70
+ patch(
71
+ '\t\t\t\t\t\t\t\t\tif (id === "archive") onArchive(node.id);\n',
72
+ '\t\t\t\t\t\t\t\t\tif (id === "archive") onArchive(node.id);\n\t\t\t\t\t\t\t\t\tif (id === "delete-session") onDelete(node.id, title);\n',
73
+ 'session delete menu selection',
74
+ )
75
+ patch(
76
+ sessionTreeSignature,
77
+ sessionTreeSignature.replace(
78
+ 'onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore,',
79
+ 'onDeleteRequest, onSessionRename, onSessionArchive, onSessionDelete, insertWorkspaceBefore,',
80
+ ),
81
+ 'session tree props',
82
+ )
83
+ patch(
84
+ '\t\t\t\t\t\t\t\t\t\t\tonArchive: onSessionArchive,\n',
85
+ '\t\t\t\t\t\t\t\t\t\t\tonArchive: onSessionArchive,\n\t\t\t\t\t\t\t\t\t\t\tonDelete: onSessionDelete,\n',
86
+ 'tree row delete prop',
87
+ )
88
+ patch(
89
+ 'function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t }) {',
90
+ 'function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, onSessionDelete, archivedSessionIds, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t }) {',
91
+ 'flat list props',
92
+ )
93
+ patch(
94
+ '\t\t\t\t\t\t\tonFork: forkSession,\n\t\t\t\t\t\t\tonArchive: onSessionArchive,\n\t\t\t\t\t\t\tflat: true,\n',
95
+ '\t\t\t\t\t\t\tonFork: forkSession,\n\t\t\t\t\t\t\tonArchive: onSessionArchive,\n\t\t\t\t\t\t\tonDelete: onSessionDelete,\n\t\t\t\t\t\t\tflat: true,\n',
96
+ 'flat row delete prop',
97
+ )
98
+ patch(
99
+ workspaceBrowserSignature,
100
+ workspaceBrowserSignature.replace(
101
+ 'deleteWorkspace, insertWorkspaceBefore, archiveSession, insertSessionBefore,',
102
+ 'deleteWorkspace, insertWorkspaceBefore, archiveSession, deleteSession, insertSessionBefore,',
103
+ ),
104
+ 'workspace browser delete action prop',
105
+ )
106
+ patch(
107
+ `\t\t\tconst onSessionArchive = (sessionId) => {\n\t\t\t\tarchiveSession(sessionId).catch((reason) => {\n\t\t\t\t\tconsole.warn("session archive rejected:", reason);\n\t\t\t\t});\n\t\t\t};\n`,
108
+ `\t\t\tconst onSessionArchive = (sessionId) => {\n\t\t\t\tarchiveSession(sessionId).catch((reason) => {\n\t\t\t\t\tconsole.warn("session archive rejected:", reason);\n\t\t\t\t});\n\t\t\t};\n\t\t\tconst [sessionDeleteTarget, setSessionDeleteTarget] = (0, react.useState)(null);\n\t\t\tconst [sessionDeleting, setSessionDeleting] = (0, react.useState)(false);\n\t\t\tconst [sessionDeleteError, setSessionDeleteError] = (0, react.useState)(null);\n\t\t\tconst onSessionDelete = (sessionId, title) => {\n\t\t\t\tsetSessionDeleteTarget({ sessionId, title });\n\t\t\t\tsetSessionDeleteError(null);\n\t\t\t};\n\t\t\tconst closeSessionDelete = () => {\n\t\t\t\tif (sessionDeleting) return;\n\t\t\t\tsetSessionDeleteTarget(null);\n\t\t\t\tsetSessionDeleteError(null);\n\t\t\t};\n\t\t\tconst confirmSessionDelete = () => {\n\t\t\t\tif (sessionDeleting || sessionDeleteTarget === null) return;\n\t\t\t\tsetSessionDeleting(true);\n\t\t\t\tsetSessionDeleteError(null);\n\t\t\t\tdeleteSession(sessionDeleteTarget.sessionId).then(() => {\n\t\t\t\t\tsetSessionDeleting(false);\n\t\t\t\t\tsetSessionDeleteTarget(null);\n\t\t\t\t\tsetSessionDeleteError(null);\n\t\t\t\t}).catch((reason) => {\n\t\t\t\t\tsetSessionDeleting(false);\n\t\t\t\t\tsetSessionDeleteError(reason instanceof Error ? reason.message : String(reason));\n\t\t\t\t});\n\t\t\t};\n`,
109
+ 'session delete dialog state',
110
+ )
111
+ patch(
112
+ '\t\t\t\t\t\t\tonSessionArchive,\n\t\t\t\t\t\t\tarchivedSessionIds,\n',
113
+ '\t\t\t\t\t\t\tonSessionArchive,\n\t\t\t\t\t\t\tonSessionDelete,\n\t\t\t\t\t\t\tarchivedSessionIds,\n',
114
+ 'flat list delete handler',
115
+ )
116
+ patch(
117
+ '\t\t\t\t\t\t\tonSessionArchive,\n\t\t\t\t\t\t\tforkSession,\n',
118
+ '\t\t\t\t\t\t\tonSessionArchive,\n\t\t\t\t\t\t\tonSessionDelete,\n\t\t\t\t\t\t\tforkSession,\n',
119
+ 'session tree delete handler',
120
+ )
121
+ patch(
122
+ `\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {\n\t\t\t\t\t\topen: deleteTarget !== null,\n`,
123
+ `\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {\n\t\t\t\t\t\topen: sessionDeleteTarget !== null,\n\t\t\t\t\t\tonClose: closeSessionDelete,\n\t\t\t\t\t\tcloseLabel: t("close"),\n\t\t\t\t\t\ttitle: t("delete.session.title"),\n\t\t\t\t\t\t...sessionDeleteTarget === null ? {} : { description: t("delete.session.desc", { name: sessionDeleteTarget.title }) },\n\t\t\t\t\t\tfooter: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {\n\t\t\t\t\t\t\tvariant: "outline",\n\t\t\t\t\t\t\tdisabled: sessionDeleting,\n\t\t\t\t\t\t\tonClick: closeSessionDelete,\n\t\t\t\t\t\t\tchildren: t("cancel")\n\t\t\t\t\t\t}), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {\n\t\t\t\t\t\t\tvariant: "outline",\n\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteAction,\n\t\t\t\t\t\t\tdisabled: sessionDeleting,\n\t\t\t\t\t\t\tonClick: confirmSessionDelete,\n\t\t\t\t\t\t\tchildren: t("delete.session.confirm")\n\t\t\t\t\t\t})] }),\n\t\t\t\t\t\tchildren: [sessionDeleting && (0, react_jsx_runtime.jsx)("div", {\n\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.deleteStatus,\n\t\t\t\t\t\t\trole: "status",\n\t\t\t\t\t\t\tchildren: t("delete.session.pending")\n\t\t\t\t\t\t}), sessionDeleteError !== null && (0, react_jsx_runtime.jsx)("div", {\n\t\t\t\t\t\t\tclassName: WorkspaceBrowser_module_css_default.renameError,\n\t\t\t\t\t\t\trole: "alert",\n\t\t\t\t\t\t\tchildren: sessionDeleteError\n\t\t\t\t\t\t})]\n\t\t\t\t\t}),\n\t\t\t\t\t(0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.Modal, {\n\t\t\t\t\t\topen: deleteTarget !== null,\n`,
124
+ 'session delete confirmation modal',
125
+ )
126
+ patch(
127
+ '\t\t\t"menu.archiveSession": "归档会话",\n',
128
+ '\t\t\t"menu.archiveSession": "归档会话",\n\t\t\t"menu.deleteSession": "删除会话",\n\t\t\t"delete.session.title": "永久删除会话?",\n\t\t\t"delete.session.desc": "“{name}”的会话记录将从本机永久删除,且无法恢复。正在运行的任务会先安全停止。",\n\t\t\t"delete.session.confirm": "永久删除",\n\t\t\t"delete.session.pending": "正在永久删除会话…",\n',
129
+ 'Chinese delete locale',
130
+ )
131
+ patch(
132
+ '\t\t\t"menu.archiveSession": "Archive session",\n',
133
+ '\t\t\t"menu.archiveSession": "Archive session",\n\t\t\t"menu.deleteSession": "Delete session",\n\t\t\t"delete.session.title": "Permanently delete session?",\n\t\t\t"delete.session.desc": "The local record for “{name}” will be permanently deleted and cannot be recovered. Running work will be stopped safely before deletion.",\n\t\t\t"delete.session.confirm": "Delete permanently",\n\t\t\t"delete.session.pending": "Permanently deleting session…",\n',
134
+ 'English delete locale',
135
+ )
136
+ patch(
137
+ `\t\t\t\tarchiveSession: async (sessionId) => {\n\t\t\t\t\tawait ctx.workspaces.archiveSession(sessionId);\n\t\t\t\t},\n`,
138
+ `\t\t\t\tarchiveSession: async (sessionId) => {\n\t\t\t\t\tawait ctx.workspaces.archiveSession(sessionId);\n\t\t\t\t},\n\t\t\t\tdeleteSession: async (sessionId) => {\n\t\t\t\t\tconst response = await fetch("/plugins/dsh-session-delete/delete", {\n\t\t\t\t\t\tmethod: "POST",\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t"content-type": "application/json",\n\t\t\t\t\t\t\t"x-dsh-session-delete-confirmation": "delete-session"\n\t\t\t\t\t\t},\n\t\t\t\t\t\tbody: JSON.stringify({ sessionId })\n\t\t\t\t\t});\n\t\t\t\t\tconst payload = await response.json().catch(() => null);\n\t\t\t\t\tif (!response.ok || payload?.ok !== true) {\n\t\t\t\t\t\tthrow new Error(payload?.error?.message ?? \`Delete failed (HTTP \${response.status})\`);\n\t\t\t\t\t}\n\t\t\t\t\tif (ctx.sessions.list.getSnapshot().current === sessionId) ctx.sessions.clear();\n\t\t\t\t\tconst refreshes = await Promise.allSettled([\n\t\t\t\t\t\tctx.sessions.refresh(),\n\t\t\t\t\t\tctx.workspaces.refresh()\n\t\t\t\t\t]);\n\t\t\t\t\tfor (const refresh of refreshes) {\n\t\t\t\t\t\tif (refresh.status === "rejected") console.warn("session deletion succeeded but runtime refresh failed:", refresh.reason);\n\t\t\t\t\t}\n\t\t\t\t},\n`,
139
+ 'browser delete request',
140
+ )
141
+ const homePathCall = '(0, _deepseek_ai_dsh_client_runtime_client.abbreviateHomePath)(row.cwd, home)'
142
+ if (source.includes(homePathCall)) {
143
+ patch(
144
+ homePathCall,
145
+ 'typeof _deepseek_ai_dsh_client_runtime_client.abbreviateHomePath === "function" ? (0, _deepseek_ai_dsh_client_runtime_client.abbreviateHomePath)(row.cwd, home) : row.cwd',
146
+ 'home path compatibility fallback',
147
+ )
148
+ }
149
+
150
+ const notice = `// Modified from @deepseek-ai/dsh-client-ui-workspace ${upstreamVersion} by DSH Native Session Delete. See THIRD_PARTY_NOTICES.md.\n`
151
+ return `${notice}${source}`
152
+ }
153
+
154
+ export async function buildClient() {
155
+ const manifest = JSON.parse(await readFile(resolveUpstreamManifest(), 'utf8'))
156
+ if (manifest.version !== LATEST_UPSTREAM_VERSION) {
157
+ throw new Error(
158
+ `unsupported @deepseek-ai/dsh-client-ui-workspace version: ${manifest.version ?? 'unknown'}`,
159
+ )
160
+ }
161
+ const upstream = await readFile(resolveUpstreamClient(), 'utf8')
162
+ const patched = patchWorkspaceClient(upstream, manifest.version)
163
+ await mkdir(dirname(output), { recursive: true })
164
+ await writeFile(output, patched, 'utf8')
165
+ return output
166
+ }
167
+
168
+ if (process.argv[1] !== undefined && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
169
+ process.stdout.write(`${await buildClient()}\n`)
170
+ }
@@ -0,0 +1,216 @@
1
+ import { chromium } from 'playwright'
2
+ import { resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ const HELP = `Usage:
6
+ pnpm smoke:ui -- --session "Exact session title" [options]
7
+
8
+ Options:
9
+ --url <url> Running DSH URL (default: http://127.0.0.1:14171)
10
+ --session <title> Exact existing session title (required)
11
+ --channel <name> Browser channel such as chrome (optional)
12
+ --executable <path> Existing Chromium-family executable (optional)
13
+ --headed Show the browser window
14
+ --screenshot <path> Save the opened confirmation dialog
15
+ --simulate-delete-success
16
+ On a ?fixture page only, intercept the delete request,
17
+ click the final button, and verify no page reload
18
+ --help Show this help
19
+
20
+ By default this check opens Delete session, verifies the confirmation dialog,
21
+ and clicks Cancel. The simulation mode never reaches the Host deletion route or
22
+ controls DSH's process lifecycle.
23
+ `
24
+
25
+ export function parseArgs(argv) {
26
+ const result = { url: 'http://127.0.0.1:14171', headed: false }
27
+ for (let index = 0; index < argv.length; index += 1) {
28
+ const arg = argv[index]
29
+ if (arg === '--') continue
30
+ if (arg === '--help') return { help: true }
31
+ if (arg === '--headed') {
32
+ result.headed = true
33
+ continue
34
+ }
35
+ if (arg === '--simulate-delete-success') {
36
+ result.simulateDeleteSuccess = true
37
+ continue
38
+ }
39
+ if (['--url', '--session', '--channel', '--executable', '--screenshot'].includes(arg)) {
40
+ const value = argv[index + 1]
41
+ if (value === undefined || value.startsWith('--')) throw new Error(`${arg} requires a value`)
42
+ result[arg.slice(2)] = value
43
+ index += 1
44
+ continue
45
+ }
46
+ throw new Error(`unknown argument: ${arg}`)
47
+ }
48
+ if (typeof result.session !== 'string' || result.session.length === 0) {
49
+ throw new Error('--session is required so the smoke test never chooses a user session implicitly')
50
+ }
51
+ if (result.channel !== undefined && result.executable !== undefined) {
52
+ throw new Error('use either --channel or --executable, not both')
53
+ }
54
+ if (result.simulateDeleteSuccess === true) {
55
+ if (!isFixtureUrl(result.url)) {
56
+ throw new Error('--simulate-delete-success requires a DSH fixture URL so it cannot target user sessions')
57
+ }
58
+ }
59
+ return result
60
+ }
61
+
62
+ export function isFixtureUrl(url) {
63
+ return new URL(url).searchParams.has('fixture')
64
+ }
65
+
66
+ export function isIgnorableFixtureConsoleError(message) {
67
+ return (
68
+ message.includes('[cordis-client-runner] syncing inspect providers failed:')
69
+ && message.includes('fixture connection RPC endpoint "dynamicCordisRunner/syncInspectManifest" is unavailable')
70
+ ) || (
71
+ message.includes('[ui-cordis] reading the Cordis inventory failed:')
72
+ && message.includes('fixture connection RPC endpoint "dynamicCordisRunner/inventory" is unavailable')
73
+ )
74
+ }
75
+
76
+ export async function runSmoke(options) {
77
+ const deleteRequests = []
78
+ const consoleErrors = []
79
+ const fixture = isFixtureUrl(options.url)
80
+ const browser = await chromium.launch({
81
+ headless: !options.headed,
82
+ ...(options.channel === undefined ? {} : { channel: options.channel }),
83
+ ...(options.executable === undefined ? {} : { executablePath: options.executable }),
84
+ })
85
+ try {
86
+ const page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
87
+ let navigationArmed = false
88
+ let mainFrameNavigations = 0
89
+ if (options.simulateDeleteSuccess === true) {
90
+ await page.addInitScript(() => {
91
+ globalThis.__dshDeleteSmokeDocumentToken = crypto.randomUUID()
92
+ })
93
+ await page.route('**/plugins/dsh-session-delete/delete', async (route) => {
94
+ await route.fulfill({
95
+ status: 200,
96
+ contentType: 'application/json',
97
+ body: JSON.stringify({ ok: true, value: { deleted: true } }),
98
+ })
99
+ })
100
+ page.on('framenavigated', (frame) => {
101
+ if (navigationArmed && frame === page.mainFrame()) mainFrameNavigations += 1
102
+ })
103
+ }
104
+ page.on('request', (request) => {
105
+ if (new URL(request.url()).pathname === '/plugins/dsh-session-delete/delete') {
106
+ deleteRequests.push(request.method())
107
+ }
108
+ })
109
+ page.on('console', (message) => {
110
+ if (message.type() === 'error') consoleErrors.push(message.text())
111
+ })
112
+
113
+ await page.goto(options.url, { waitUntil: 'domcontentloaded' })
114
+ const documentToken = options.simulateDeleteSuccess === true
115
+ ? await page.evaluate(() => globalThis.__dshDeleteSmokeDocumentToken)
116
+ : undefined
117
+ if (fixture) {
118
+ // Fixture mode cannot persist DSH's product-wide onboarding acknowledgement,
119
+ // so clicking Continue immediately reopens the unrelated notice. Remove only
120
+ // that exact fixture-only overlay; never mutate real settings or user pages.
121
+ const onboarding = page.getByRole('dialog', { name: /^(内测声明|Internal Testing Notice)$/ })
122
+ const onboardingVisible = await onboarding
123
+ .waitFor({ state: 'visible', timeout: 3000 })
124
+ .then(() => true, () => false)
125
+ if (onboardingVisible) {
126
+ await onboarding.evaluate((element) => {
127
+ const overlay = element.parentElement
128
+ if (overlay !== null) overlay.remove()
129
+ else element.remove()
130
+ for (const inertElement of document.querySelectorAll('[inert]')) {
131
+ inertElement.removeAttribute('inert')
132
+ }
133
+ })
134
+ }
135
+ }
136
+ const matchingTitles = page.getByText(options.session, { exact: true })
137
+ await matchingTitles.first().waitFor()
138
+ const rowCount = await matchingTitles.count()
139
+ if (rowCount !== 1) {
140
+ throw new Error(`expected exactly one visible session row named ${JSON.stringify(options.session)}, found ${rowCount}`)
141
+ }
142
+
143
+ const row = matchingTitles.first().locator('xpath=ancestor::*[@role="treeitem"][1]')
144
+ await row.hover()
145
+ const actions = row.getByRole('button')
146
+ if (await actions.count() !== 1) throw new Error('session action button was not uniquely identifiable')
147
+ await actions.click()
148
+
149
+ const archiveItem = page.getByRole('menuitem', { name: /^(Archive session|归档会话)$/ })
150
+ await archiveItem.waitFor()
151
+ const deleteItem = page.getByRole('menuitem', { name: /^(Delete session|删除会话)$/ })
152
+ await deleteItem.waitFor()
153
+ const [archiveColor, deleteColor] = await Promise.all([
154
+ archiveItem.evaluate((element) => getComputedStyle(element).color),
155
+ deleteItem.evaluate((element) => getComputedStyle(element).color),
156
+ ])
157
+ if (archiveColor === deleteColor) {
158
+ throw new Error(`delete menu item is not using a distinct danger color (${deleteColor})`)
159
+ }
160
+ await deleteItem.click()
161
+
162
+ const dialog = page.getByRole('dialog')
163
+ await dialog.getByText(/^(Permanently delete session\?|永久删除会话?)$/).waitFor()
164
+ await dialog.getByRole('button', { name: /^(Delete permanently|永久删除)$/ }).waitFor()
165
+ const cancel = dialog.getByRole('button', { name: /^(Cancel|取消)$/ })
166
+ await cancel.waitFor()
167
+ if (options.screenshot !== undefined) await dialog.screenshot({ path: options.screenshot })
168
+ if (options.simulateDeleteSuccess === true) {
169
+ navigationArmed = true
170
+ await dialog.getByRole('button', { name: /^(Delete permanently|永久删除)$/ }).click()
171
+ await page.waitForTimeout(750)
172
+ const settledToken = await page.evaluate(() => globalThis.__dshDeleteSmokeDocumentToken)
173
+ if (deleteRequests.length !== 1) {
174
+ throw new Error(`simulated success expected one delete request, observed ${deleteRequests.length}`)
175
+ }
176
+ if (mainFrameNavigations !== 0 || settledToken !== documentToken) {
177
+ throw new Error(`successful deletion reloaded the WebView (${mainFrameNavigations} main-frame navigation(s))`)
178
+ }
179
+ await dialog.getByText(/^(Permanently delete session\?|永久删除会话?)$/).waitFor({ state: 'hidden' })
180
+ } else {
181
+ await cancel.click()
182
+ await dialog.waitFor({ state: 'hidden' })
183
+ if (deleteRequests.length > 0) {
184
+ throw new Error(`cancel path unexpectedly sent ${deleteRequests.length} delete request(s)`)
185
+ }
186
+ }
187
+ const blockingConsoleErrors = fixture
188
+ ? consoleErrors.filter((message) => !isIgnorableFixtureConsoleError(message))
189
+ : consoleErrors
190
+ if (blockingConsoleErrors.length > 0) {
191
+ throw new Error(`browser console errors: ${blockingConsoleErrors.join(' | ')}`)
192
+ }
193
+ return {
194
+ ok: true,
195
+ checks: options.simulateDeleteSuccess === true
196
+ ? ['Archive session', 'red Delete session', 'confirmation dialog', 'successful delete without reload']
197
+ : ['Archive session', 'red Delete session', 'confirmation dialog', 'cancel without request'],
198
+ }
199
+ } finally {
200
+ await browser.close()
201
+ }
202
+ }
203
+
204
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
205
+ try {
206
+ const options = parseArgs(process.argv.slice(2))
207
+ if (options.help) {
208
+ process.stdout.write(HELP)
209
+ } else {
210
+ process.stdout.write(`${JSON.stringify(await runSmoke(options))}\n`)
211
+ }
212
+ } catch (error) {
213
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
214
+ process.exitCode = 1
215
+ }
216
+ }