mirror of
https://github.com/TommyFang2077/dsh-desktop.git
synced 2026-08-17 09:06:36 +08:00
feat: add mainland-reachable desktop distribution
This commit is contained in:
63
tests/shell_update.test.mjs
Normal file
63
tests/shell_update.test.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
const nextTurn = () => new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
function element(hidden = false) {
|
||||
return {
|
||||
disabled: false,
|
||||
hidden,
|
||||
textContent: '',
|
||||
listeners: new Map(),
|
||||
addEventListener(name, listener) {
|
||||
this.listeners.set(name, listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('startup waits for an available signed shell update decision', async () => {
|
||||
const elements = new Map([
|
||||
['status', element()],
|
||||
['spinner', element()],
|
||||
['retry', element(true)],
|
||||
['detail', element(true)],
|
||||
['update-panel', element(true)],
|
||||
['update-notes', element()],
|
||||
['install-update', element()],
|
||||
['skip-update', element()],
|
||||
])
|
||||
const invokes = []
|
||||
globalThis.document = {
|
||||
readyState: 'complete',
|
||||
getElementById(id) {
|
||||
return elements.get(id)
|
||||
},
|
||||
}
|
||||
globalThis.window = {
|
||||
__TAURI__: {
|
||||
event: { async listen() {} },
|
||||
core: {
|
||||
async invoke(command) {
|
||||
invokes.push(command)
|
||||
if (command === 'check_shell_update') {
|
||||
return { version: '1.2.3', notes: 'signed release' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await import(new URL('../ui/app.js?update-test', import.meta.url))
|
||||
await nextTurn()
|
||||
|
||||
assert.deepEqual(invokes, ['check_shell_update'])
|
||||
assert.equal(elements.get('status').textContent, '发现壳更新 1.2.3')
|
||||
assert.equal(elements.get('update-notes').textContent, 'signed release')
|
||||
assert.equal(elements.get('update-panel').hidden, false)
|
||||
|
||||
await elements.get('skip-update').listeners.get('click')()
|
||||
await nextTurn()
|
||||
assert.deepEqual(invokes, ['check_shell_update', 'skip_shell_update'])
|
||||
assert.equal(elements.get('update-panel').hidden, true)
|
||||
})
|
||||
@@ -9,6 +9,12 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
class VisionPluginFilesTests(unittest.TestCase):
|
||||
def test_client_registers_system_settings_slots(self):
|
||||
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("settings.section", client)
|
||||
self.assertNotIn("settings.plugins.tab", client)
|
||||
self.assertIn("modlens-vision", client)
|
||||
client = (ROOT / "plugins" / "dsh-desktop-vision" / "client.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
@@ -36,6 +42,40 @@ class VisionPluginFilesTests(unittest.TestCase):
|
||||
self.assertIn("example: 'haiku'", host)
|
||||
|
||||
|
||||
class VoicePluginFilesTests(unittest.TestCase):
|
||||
def test_client_registers_composer_and_settings(self):
|
||||
client = (ROOT / "plugins" / "dsh-desktop-voice" / "client.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
host = (ROOT / "plugins" / "dsh-desktop-voice" / "index.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
pkg = (ROOT / "plugins" / "dsh-desktop-voice" / "package.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("settings.section", client)
|
||||
self.assertIn("conversation.input.right", client)
|
||||
self.assertIn("dsh-desktop-voice", client)
|
||||
self.assertIn("Ctrl+E", client)
|
||||
self.assertIn("dictationMode", client)
|
||||
self.assertIn("whisper-1", host)
|
||||
self.assertIn("/dsh-desktop/voice", host)
|
||||
self.assertIn("/dsh-desktop/voice/transcribe", host)
|
||||
self.assertIn("/dsh-desktop/voice/model", host)
|
||||
self.assertIn("SenseVoice", client)
|
||||
self.assertIn("sherpa-onnx@", host)
|
||||
self.assertNotIn("SpeechRecognition", client)
|
||||
self.assertNotIn("engine === 'browser'", client)
|
||||
self.assertIn("engine === 'openai'", client)
|
||||
self.assertIn("cfg.engine === 'openai'", host)
|
||||
self.assertFalse(
|
||||
(ROOT / "plugins" / "dsh-desktop-voice" / "model.int8.onnx").exists()
|
||||
)
|
||||
self.assertIn("https://api.openai.com/v1", host)
|
||||
self.assertIn("@deepseek-ai/dsh-client-ui-conversation", pkg)
|
||||
self.assertIn("@deepseek-ai/dsh-client-ui-settings", pkg)
|
||||
|
||||
|
||||
class ClipboardIngestTests(unittest.TestCase):
|
||||
def test_inject_delivers_images_as_paste_not_only_drop(self):
|
||||
ingest = (ROOT / "ui" / "inject" / "ingest.js").read_text(encoding="utf-8")
|
||||
@@ -67,6 +107,9 @@ class BundledAttributionTests(unittest.TestCase):
|
||||
self.assertIn("3.16.6", text)
|
||||
self.assertIn("ffb845c5480adc953392a6db6f8a98ede621174b", text)
|
||||
self.assertIn("dsh-desktop-vision", text)
|
||||
self.assertIn("dsh-desktop-voice", text)
|
||||
self.assertIn("https://github.com/dsh-market/dsh-market", text)
|
||||
self.assertIn("1.9.0", text)
|
||||
self.assertIn("dsh-plugin", readme)
|
||||
self.assertIn("带上眼睛", readme)
|
||||
self.assertIn("+8%", readme)
|
||||
@@ -74,6 +117,7 @@ class BundledAttributionTests(unittest.TestCase):
|
||||
self.assertTrue(
|
||||
(ROOT / "docs" / "licenses" / "dsh-anchored-standard.NOTICE").is_file()
|
||||
)
|
||||
self.assertTrue((ROOT / "docs" / "licenses" / "dshmarket.LICENSE").is_file())
|
||||
for name in ("splash.png", "session.png", "vision.png", "menu.png"):
|
||||
self.assertTrue((ROOT / "docs" / "screenshots" / name).is_file())
|
||||
|
||||
|
||||
72
tests/test_release.py
Normal file
72
tests/test_release.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts" / "build-gitea-update.py"
|
||||
|
||||
|
||||
class GiteaUpdateManifestTests(unittest.TestCase):
|
||||
def test_normalizes_matrix_artifacts_and_embeds_signatures(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
artifacts = root / "artifacts"
|
||||
output = root / "staged"
|
||||
fixtures = {
|
||||
"gitea-macos-arm64": ".app.tar.gz",
|
||||
"gitea-macos-x64": ".app.tar.gz",
|
||||
"gitea-linux": ".AppImage",
|
||||
"gitea-windows": ".exe",
|
||||
}
|
||||
for matrix, suffix in fixtures.items():
|
||||
bundle = artifacts / matrix / "target" / "release" / "bundle"
|
||||
bundle.mkdir(parents=True)
|
||||
artifact = bundle / f"DeepSeek Harness{suffix}"
|
||||
artifact.write_bytes(matrix.encode())
|
||||
artifact.with_name(artifact.name + ".sig").write_text(
|
||||
f"signature-{matrix}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
str(SCRIPT),
|
||||
"--artifacts",
|
||||
str(artifacts),
|
||||
"--output",
|
||||
str(output),
|
||||
"--version",
|
||||
"1.2.3",
|
||||
"--package-base-url",
|
||||
"http://gitea.example/api/packages/u/generic/app",
|
||||
"--pub-date",
|
||||
"2026-08-16T00:00:00Z",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
manifest = json.loads((output / "latest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["version"], "1.2.3")
|
||||
self.assertEqual(
|
||||
set(manifest["platforms"]),
|
||||
{
|
||||
"darwin-aarch64-app",
|
||||
"darwin-aarch64",
|
||||
"darwin-x86_64-app",
|
||||
"darwin-x86_64",
|
||||
"linux-x86_64-appimage",
|
||||
"linux-x86_64",
|
||||
"windows-x86_64-nsis",
|
||||
"windows-x86_64",
|
||||
},
|
||||
)
|
||||
windows = manifest["platforms"]["windows-x86_64"]
|
||||
self.assertEqual(windows["signature"], "signature-gitea-windows")
|
||||
self.assertTrue(windows["url"].endswith("/1.2.3/dsh-easy-desktop_1.2.3_windows_x86_64.exe"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
233
tests/voice_client.test.mjs
Normal file
233
tests/voice_client.test.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const CLIENT = new URL('../plugins/dsh-desktop-voice/client.js', import.meta.url)
|
||||
|
||||
function loadVoiceClient({ confirmInstall = () => false, engine = 'sensevoice', holdInstall = false } = {}) {
|
||||
let plugin
|
||||
const registered = new Map()
|
||||
const indicatorParts = {
|
||||
'.label': { textContent: '' },
|
||||
'.kbd': { textContent: '' },
|
||||
'.stop': { hidden: false },
|
||||
'.progress': { hidden: true, value: 0 },
|
||||
'.percent': { hidden: true, textContent: '' },
|
||||
}
|
||||
const indicator = {
|
||||
classList: { toggle() {} },
|
||||
querySelector(selector) {
|
||||
return indicatorParts[selector]
|
||||
},
|
||||
}
|
||||
const window = {
|
||||
__ModuleLoader__: {
|
||||
load(definition) {
|
||||
plugin = definition.factory((id) => {
|
||||
if (id === 'react') return React
|
||||
if (id === 'react/jsx-runtime') return jsx
|
||||
throw new Error(`unexpected module: ${id}`)
|
||||
})
|
||||
},
|
||||
},
|
||||
addEventListener() {},
|
||||
confirm: confirmInstall,
|
||||
}
|
||||
const document = {
|
||||
querySelector() {
|
||||
return {}
|
||||
},
|
||||
getElementById() {
|
||||
return indicator
|
||||
},
|
||||
}
|
||||
const jsx = {
|
||||
jsx(type, props, key) {
|
||||
return { type, props: props || {}, key }
|
||||
},
|
||||
jsxs(type, props, key) {
|
||||
return { type, props: props || {}, key }
|
||||
},
|
||||
}
|
||||
let hooks = []
|
||||
let hookIndex = 0
|
||||
const React = {
|
||||
useState(initial) {
|
||||
const index = hookIndex++
|
||||
if (!(index in hooks)) hooks[index] = typeof initial === 'function' ? initial() : initial
|
||||
return [hooks[index], (next) => {
|
||||
hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
|
||||
}]
|
||||
},
|
||||
useRef(initial) {
|
||||
const index = hookIndex++
|
||||
if (!(index in hooks)) hooks[index] = { current: initial }
|
||||
return hooks[index]
|
||||
},
|
||||
useEffect(effect, dependencies) {
|
||||
const index = hookIndex++
|
||||
const previous = hooks[index]
|
||||
const changed = !dependencies || !previous || dependencies.some((value, i) => value !== previous[i])
|
||||
hooks[index] = dependencies || null
|
||||
if (changed) effect()
|
||||
},
|
||||
}
|
||||
const requests = []
|
||||
let micRequests = 0
|
||||
const context = {
|
||||
AudioContext: function AudioContext() {},
|
||||
Blob,
|
||||
DataView,
|
||||
Float32Array,
|
||||
MediaRecorder: undefined,
|
||||
URL,
|
||||
clearTimeout,
|
||||
clearInterval,
|
||||
console,
|
||||
document,
|
||||
encodeURIComponent,
|
||||
fetch: async (url, options = {}) => {
|
||||
requests.push({ url, method: options.method || 'GET' })
|
||||
if (url === '/dsh-desktop/voice') {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
enabled: true,
|
||||
engine,
|
||||
dictationMode: 'toggle',
|
||||
language: 'zh',
|
||||
modelInstalled: false,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (url === '/dsh-desktop/voice/model' && (!options.method || options.method === 'GET')) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { status: 'installing', percent: 42, stage: '正在下载 SenseVoice 模型…' }
|
||||
},
|
||||
}
|
||||
}
|
||||
if (url === '/dsh-desktop/voice/model' && options.method === 'POST') {
|
||||
if (holdInstall) return new Promise(() => {})
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { installed: true }
|
||||
},
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected request: ${options.method || 'GET'} ${url}`)
|
||||
},
|
||||
navigator: {
|
||||
language: 'zh-CN',
|
||||
platform: 'Linux x86_64',
|
||||
mediaDevices: {
|
||||
getUserMedia() {
|
||||
micRequests += 1
|
||||
return new Promise(() => {})
|
||||
},
|
||||
},
|
||||
},
|
||||
setInterval,
|
||||
setTimeout,
|
||||
window,
|
||||
}
|
||||
vm.runInNewContext(readFileSync(CLIENT, 'utf8'), context, { filename: CLIENT.pathname })
|
||||
plugin.apply({
|
||||
slots: {
|
||||
inject(_name, register) {
|
||||
register()
|
||||
},
|
||||
register(meta, component) {
|
||||
registered.set(meta.name, component)
|
||||
},
|
||||
},
|
||||
})
|
||||
const MicButton = registered.get('conversation.input.right')
|
||||
assert.equal(typeof MicButton, 'function')
|
||||
const VoiceSettings = registered.get('settings.section')
|
||||
assert.equal(typeof VoiceSettings, 'function')
|
||||
const props = {
|
||||
inputActions: { setDraft() {} },
|
||||
useInput() {
|
||||
return ''
|
||||
},
|
||||
}
|
||||
return {
|
||||
indicatorParts,
|
||||
requests,
|
||||
get micRequests() {
|
||||
return micRequests
|
||||
},
|
||||
async renderMicReady() {
|
||||
hooks = []
|
||||
hookIndex = 0
|
||||
MicButton(props)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
hookIndex = 0
|
||||
return MicButton(props)
|
||||
},
|
||||
async renderSettingsReady() {
|
||||
hooks = []
|
||||
hookIndex = 0
|
||||
VoiceSettings({})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
hookIndex = 0
|
||||
return VoiceSettings({})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('microphone click offers to install a missing SenseVoice model', async () => {
|
||||
let prompts = 0
|
||||
const harness = loadVoiceClient({
|
||||
confirmInstall(message) {
|
||||
prompts += 1
|
||||
assert.match(message, /SenseVoice/)
|
||||
assert.match(message, /安装/)
|
||||
return true
|
||||
},
|
||||
})
|
||||
const button = await harness.renderMicReady()
|
||||
button.props.onClick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.equal(prompts, 1)
|
||||
assert.deepEqual(
|
||||
harness.requests.filter((request) => request.method === 'POST'),
|
||||
[{ url: '/dsh-desktop/voice/model', method: 'POST' }],
|
||||
)
|
||||
assert.equal(harness.micRequests, 1)
|
||||
})
|
||||
|
||||
function textOf(node) {
|
||||
if (node == null || typeof node === 'boolean') return ''
|
||||
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
||||
if (Array.isArray(node)) return node.map(textOf).join(' ')
|
||||
return textOf(node.props && node.props.children)
|
||||
}
|
||||
|
||||
test('OpenAI fields only render for the OpenAI engine', async () => {
|
||||
const localSettings = textOf(await loadVoiceClient({ engine: 'sensevoice' }).renderSettingsReady())
|
||||
assert.doesNotMatch(localSettings, /接口地址|API 密钥|Whisper 模型/)
|
||||
|
||||
const openAISettings = textOf(await loadVoiceClient({ engine: 'openai' }).renderSettingsReady())
|
||||
assert.match(openAISettings, /接口地址/)
|
||||
assert.match(openAISettings, /API 密钥/)
|
||||
assert.match(openAISettings, /Whisper 模型/)
|
||||
})
|
||||
|
||||
test('model installation renders live progress', async () => {
|
||||
const harness = loadVoiceClient({ confirmInstall: () => true, holdInstall: true })
|
||||
const button = await harness.renderMicReady()
|
||||
button.props.onClick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.equal(harness.indicatorParts['.progress'].hidden, false)
|
||||
assert.equal(harness.indicatorParts['.progress'].value, 42)
|
||||
assert.equal(harness.indicatorParts['.percent'].textContent, '42%')
|
||||
})
|
||||
112
tests/voice_host.test.mjs
Normal file
112
tests/voice_host.test.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
function response() {
|
||||
return {
|
||||
status: 0,
|
||||
body: '',
|
||||
writeHead(status) {
|
||||
this.status = status
|
||||
return this
|
||||
},
|
||||
end(body = '') {
|
||||
this.body = body
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function request(method, url) {
|
||||
return {
|
||||
method,
|
||||
url,
|
||||
headers: {},
|
||||
async *[Symbol.asyncIterator]() {},
|
||||
}
|
||||
}
|
||||
|
||||
test('voice host reports and accepts an on-demand SenseVoice installation', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-voice-test-'))
|
||||
process.env.DSH_DESKTOP_VOICE_HOME = home
|
||||
process.env.XDG_CONFIG_HOME = join(home, 'config')
|
||||
const configDir = join(home, 'config', 'dsh-desktop')
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
writeFileSync(join(configDir, 'voice.json'), JSON.stringify({ engine: 'auto' }))
|
||||
try {
|
||||
const routes = new Map()
|
||||
const plugin = await import(`../plugins/dsh-desktop-voice/index.js?test=${Date.now()}`)
|
||||
plugin.apply({
|
||||
inject(_dependencies, load) {
|
||||
load({
|
||||
webServer: {
|
||||
register(route) {
|
||||
routes.set(route.path, route.handler)
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const configResponse = response()
|
||||
await routes.get('/dsh-desktop/voice')(request('GET', '/dsh-desktop/voice'), configResponse)
|
||||
assert.equal(configResponse.status, 200)
|
||||
const missing = JSON.parse(configResponse.body)
|
||||
assert.equal(missing.engine, 'sensevoice')
|
||||
assert.equal(missing.modelInstalled, false)
|
||||
assert.equal(missing.modelStatus, 'missing')
|
||||
assert.deepEqual(missing.options.engines.map((engine) => engine.id), ['sensevoice', 'openai'])
|
||||
|
||||
const missingProgressResponse = response()
|
||||
await routes.get('/dsh-desktop/voice/model')(
|
||||
request('GET', '/dsh-desktop/voice/model'),
|
||||
missingProgressResponse,
|
||||
)
|
||||
assert.deepEqual(JSON.parse(missingProgressResponse.body), {
|
||||
status: 'missing',
|
||||
percent: 0,
|
||||
stage: '',
|
||||
})
|
||||
|
||||
const modelDir = join(home, 'sensevoice')
|
||||
const packageDir = join(home, 'runtime', 'node_modules', 'sherpa-onnx')
|
||||
mkdirSync(modelDir, { recursive: true })
|
||||
mkdirSync(packageDir, { recursive: true })
|
||||
writeFileSync(join(modelDir, 'model.int8.onnx'), '')
|
||||
truncateSync(join(modelDir, 'model.int8.onnx'), 239233841)
|
||||
writeFileSync(join(modelDir, 'tokens.txt'), '')
|
||||
truncateSync(join(modelDir, 'tokens.txt'), 315894)
|
||||
writeFileSync(
|
||||
join(modelDir, 'installed.json'),
|
||||
JSON.stringify({
|
||||
modelVersion: '2365baeacb507f821a0c8120fcee3d484dba7a07',
|
||||
runtimeVersion: '1.13.5',
|
||||
}),
|
||||
)
|
||||
writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ version: '1.13.5' }))
|
||||
|
||||
const installResponse = response()
|
||||
await routes.get('/dsh-desktop/voice/model')(request('POST', '/dsh-desktop/voice/model'), installResponse)
|
||||
assert.equal(installResponse.status, 200)
|
||||
const installed = JSON.parse(installResponse.body)
|
||||
assert.equal(installed.installed, true)
|
||||
assert.equal(installed.modelInstalled, true)
|
||||
assert.equal(installed.modelStatus, 'installed')
|
||||
|
||||
const installedProgressResponse = response()
|
||||
await routes.get('/dsh-desktop/voice/model')(
|
||||
request('GET', '/dsh-desktop/voice/model'),
|
||||
installedProgressResponse,
|
||||
)
|
||||
assert.deepEqual(JSON.parse(installedProgressResponse.body), {
|
||||
status: 'installed',
|
||||
percent: 100,
|
||||
stage: 'SenseVoice 已安装',
|
||||
})
|
||||
} finally {
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.DSH_DESKTOP_VOICE_HOME
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user