Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at what a ChatGPT.app-style host on top of the DeepSeek Harness looks like, with the harness's normally-invisible internals (trace timeline, context surface, subagent tree, compaction, plugin registry, rubrics) brought forward as first-class UI surfaces so plugin authors and researchers can see what the agent is actually doing. Runs against three keyless-to-live profiles (stdio-echo works on master out of the box; daemon-echo / daemon-vibe-echo activate once the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the in-repo runtime when this shell ships under examples/desktop/, so a fresh clone launches without config; env DSH_DEV_ROOT overrides for custom layouts, and a sibling deepseek-harness-dev/ checkout is the original dev workflow. Cold-clone gate (P0 fixes for first-time-clone usability): - HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker → sibling), unit-tested via mock fs so ordering is locked without needing either real layout on disk. - config yml leaves rewritten at assemble time so the sibling-clone paths (../../deepseek-harness-dev/examples/echo-agent/…) become the in-repo paths (../../echo-agent/…) in the released tree — source yml stays usable for local dev, released tree ships a working shape. - pnpm-workspace.yaml allowBuilds.electron = true (was placeholder). - missing-key card in stdio-deepseek offers a one-click switch to stdio-echo (the keyless profile that works on master) rather than daemon-echo (blocked on the not-yet-shipped daemon-demo). - assemble-oss-release.sh rewrites the source-side breadcrumb name 'dsh-desktop-demo' → 'dsh-desktop' for the released package.json. FOUC guard on the onboarding gate (41fc5df carried) keeps the first-launch splash from flashing before the runtime probe finishes. Test suite (1634 tests in source, 3990 in the runtime repo) covers resolver ordering, renderer classifiers, trace timeline shape, compaction diff rendering, rubric parity, and the missing-key onboarding paths.
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
// Artifact server unit tests. Runs under `node --test`, no Electron.
|
|
//
|
|
// Covers:
|
|
// 1. isArtifactPath / pathToArtifactId / artifactIdToPath / parseArtifactUrl
|
|
// — pure path matching, including traversal defence.
|
|
// 2. preparePage — SSE snippet injection into a full doc, a fragment, and
|
|
// a .md input.
|
|
// 3. ArtifactServer end-to-end:
|
|
// - starts on a random 127.0.0.1 port
|
|
// - initial scan picks up pre-existing files
|
|
// - fs.watch picks up a new file (event fires)
|
|
// - GET /a/<id>/ returns the page with the SSE snippet
|
|
// - GET /events opens an SSE channel with the right headers, and a
|
|
// subsequent artifact write broadcasts a reload event
|
|
// - close() releases the port
|
|
|
|
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
const os = require('node:os')
|
|
const http = require('node:http')
|
|
|
|
const {
|
|
ArtifactServer,
|
|
isArtifactPath,
|
|
pathToArtifactId,
|
|
artifactIdToPath,
|
|
parseArtifactUrl,
|
|
preparePage,
|
|
ARTIFACT_EXTS,
|
|
} = require('../src/main/artifact-server.js')
|
|
|
|
function tmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-artifact-test-'))
|
|
}
|
|
|
|
function get(url, headers = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.get(url, { headers }, (res) => {
|
|
const chunks = []
|
|
res.on('data', (c) => chunks.push(c))
|
|
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }))
|
|
})
|
|
req.on('error', reject)
|
|
req.setTimeout(2000, () => { req.destroy(new Error('http get timeout')); })
|
|
})
|
|
}
|
|
|
|
// -- pure helpers ------------------------------------------------------------
|
|
|
|
test('isArtifactPath matches html/svg/md and rejects everything else', () => {
|
|
assert.equal(isArtifactPath('foo.html'), true)
|
|
assert.equal(isArtifactPath('foo.HTML'), true)
|
|
assert.equal(isArtifactPath('/tmp/a/b/c.svg'), true)
|
|
assert.equal(isArtifactPath('report.md'), true)
|
|
assert.equal(isArtifactPath('foo.txt'), false)
|
|
assert.equal(isArtifactPath('foo'), false)
|
|
assert.equal(isArtifactPath(''), false)
|
|
assert.equal(isArtifactPath(null), false)
|
|
assert.equal(isArtifactPath(undefined), false)
|
|
assert.equal(isArtifactPath(42), false)
|
|
// Extensions we deliberately don't count: .htm, .xml, .txt.
|
|
assert.equal(isArtifactPath('foo.htm'), false)
|
|
})
|
|
|
|
test('pathToArtifactId returns null for paths outside the artifact dir', () => {
|
|
const dir = '/workspace/.artifacts'
|
|
assert.equal(pathToArtifactId('/workspace/.artifacts/report.html', dir), 'report.html')
|
|
assert.equal(pathToArtifactId('/workspace/.artifacts/sub/dir/a.html', dir), 'sub/dir/a.html')
|
|
assert.equal(pathToArtifactId('/workspace/other/report.html', dir), null)
|
|
assert.equal(pathToArtifactId('/etc/passwd', dir), null)
|
|
// The dir itself is not an artifact.
|
|
assert.equal(pathToArtifactId('/workspace/.artifacts', dir), null)
|
|
})
|
|
|
|
test('artifactIdToPath rejects traversal / absolute ids', () => {
|
|
const dir = '/workspace/.artifacts'
|
|
assert.equal(artifactIdToPath('report.html', dir), '/workspace/.artifacts/report.html')
|
|
assert.equal(artifactIdToPath('sub/dir/a.html', dir), '/workspace/.artifacts/sub/dir/a.html')
|
|
assert.equal(artifactIdToPath('../../etc/passwd', dir), null)
|
|
assert.equal(artifactIdToPath('/etc/passwd', dir), null)
|
|
assert.equal(artifactIdToPath('', dir), null)
|
|
assert.equal(artifactIdToPath(null, dir), null)
|
|
// URL-encoded traversal is also blocked.
|
|
assert.equal(artifactIdToPath('..%2F..%2Fetc%2Fpasswd', dir), null)
|
|
})
|
|
|
|
test('parseArtifactUrl extracts nested ids and rejects everything else', () => {
|
|
assert.equal(parseArtifactUrl('/a/report.html/'), 'report.html')
|
|
assert.equal(parseArtifactUrl('/a/report.html'), 'report.html')
|
|
assert.equal(parseArtifactUrl('/a/sub/dir/a.html/'), 'sub/dir/a.html')
|
|
assert.equal(parseArtifactUrl('/a/report.html?v=3'), 'report.html')
|
|
assert.equal(parseArtifactUrl('/'), null)
|
|
assert.equal(parseArtifactUrl('/events'), null)
|
|
assert.equal(parseArtifactUrl('/a/'), null)
|
|
assert.equal(parseArtifactUrl(''), null)
|
|
})
|
|
|
|
test('preparePage injects the SSE snippet into a full document before </body>', () => {
|
|
const src = '<!doctype html><html><head></head><body><h1>hi</h1></body></html>'
|
|
const out = preparePage(src, '.html')
|
|
assert.match(out, /new EventSource\('\/events'\)/)
|
|
// Snippet must appear before </body>, not after.
|
|
const sseIdx = out.indexOf("new EventSource('/events')")
|
|
const bodyIdx = out.indexOf('</body>')
|
|
assert.ok(sseIdx > 0 && sseIdx < bodyIdx, 'SSE snippet must appear before </body>')
|
|
})
|
|
|
|
test('preparePage wraps a bare-fragment .html input in a skeleton', () => {
|
|
const out = preparePage('<h1>hi</h1>', '.html')
|
|
assert.match(out, /^<!doctype html/i)
|
|
assert.match(out, /<h1>hi<\/h1>/)
|
|
assert.match(out, /new EventSource/)
|
|
})
|
|
|
|
test('preparePage renders .md as an escaped <pre> and injects the snippet', () => {
|
|
const out = preparePage('# hi\n<script>alert(1)</script>', '.md')
|
|
assert.match(out, /<script>/) // escaped, not literal
|
|
assert.doesNotMatch(out, /<script>alert\(1\)<\/script>/)
|
|
assert.match(out, /new EventSource/)
|
|
})
|
|
|
|
test('preparePage passes .svg through untouched (served with the svg MIME)', () => {
|
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="5"/></svg>'
|
|
assert.equal(preparePage(svg, '.svg'), svg)
|
|
})
|
|
|
|
test('ARTIFACT_EXTS is the source of truth', () => {
|
|
assert.deepEqual([...ARTIFACT_EXTS].sort(), ['.html', '.md', '.svg'])
|
|
})
|
|
|
|
// -- server integration ------------------------------------------------------
|
|
|
|
test('ArtifactServer starts on 127.0.0.1 with an ephemeral port', async () => {
|
|
const dir = tmpDir()
|
|
const s = new ArtifactServer({ artifactDir: dir })
|
|
try {
|
|
await s.ensureStarted()
|
|
assert.ok(s.port > 0)
|
|
assert.equal(s.host, '127.0.0.1')
|
|
assert.match(s.baseUrl(), /^http:\/\/127\.0\.0\.1:\d+$/)
|
|
// /health responds with a JSON payload.
|
|
const r = await get(s.baseUrl() + '/health')
|
|
assert.equal(r.status, 200)
|
|
assert.match(r.headers['content-type'], /application\/json/)
|
|
assert.match(r.body, /"ok":true/)
|
|
} finally {
|
|
await s.close()
|
|
}
|
|
})
|
|
|
|
test('ArtifactServer serves an existing HTML file with the SSE snippet injected', async () => {
|
|
const dir = tmpDir()
|
|
fs.writeFileSync(path.join(dir, 'report.html'), '<!doctype html><html><body><h1>hello</h1></body></html>')
|
|
const s = new ArtifactServer({ artifactDir: dir })
|
|
try {
|
|
await s.ensureStarted()
|
|
// The initial scan populates knownArtifacts.
|
|
assert.ok(s.knownArtifacts.has('report.html'), 'initial scan should register the file')
|
|
const r = await get(s.urlFor('report.html'))
|
|
assert.equal(r.status, 200)
|
|
assert.match(r.headers['content-type'], /text\/html/)
|
|
assert.match(r.body, /<h1>hello<\/h1>/)
|
|
assert.match(r.body, /new EventSource\('\/events'\)/)
|
|
// No-store cache header so live reload always fetches fresh.
|
|
assert.match(r.headers['cache-control'] || '', /no-store/)
|
|
} finally {
|
|
await s.close()
|
|
}
|
|
})
|
|
|
|
test('ArtifactServer rejects traversal / non-existent artifact ids', async () => {
|
|
const dir = tmpDir()
|
|
const s = new ArtifactServer({ artifactDir: dir })
|
|
try {
|
|
await s.ensureStarted()
|
|
const bad = await get(s.baseUrl() + '/a/..%2F..%2Fetc%2Fpasswd/')
|
|
assert.equal(bad.status, 400)
|
|
const missing = await get(s.baseUrl() + '/a/nope.html/')
|
|
assert.equal(missing.status, 404)
|
|
const junk = await get(s.baseUrl() + '/nope')
|
|
assert.equal(junk.status, 404)
|
|
} finally {
|
|
await s.close()
|
|
}
|
|
})
|
|
|
|
test('SSE /events opens the right headers and broadcasts reload on artifact update', async () => {
|
|
const dir = tmpDir()
|
|
const s = new ArtifactServer({ artifactDir: dir })
|
|
try {
|
|
await s.ensureStarted()
|
|
// Open an SSE connection manually so we can read the raw stream.
|
|
const url = new URL(s.baseUrl() + '/events')
|
|
const chunks = []
|
|
const req = http.get({ hostname: url.hostname, port: url.port, path: url.pathname })
|
|
const resPromise = new Promise((resolve) => req.on('response', resolve))
|
|
const res = await resPromise
|
|
assert.equal(res.statusCode, 200)
|
|
assert.match(res.headers['content-type'], /text\/event-stream/)
|
|
assert.match(res.headers['cache-control'] || '', /no-store/)
|
|
res.on('data', (c) => chunks.push(c.toString('utf8')))
|
|
|
|
// Wait a beat for the SSE handshake payload (`retry: 300`) and for the
|
|
// server to register our connection.
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
assert.ok(s.clients.size >= 1, 'server should have registered the SSE client')
|
|
|
|
// Poking the server directly to sidestep fs.watch flakiness under CI.
|
|
const abs = path.join(dir, 'live.html')
|
|
fs.writeFileSync(abs, '<!doctype html><html><body>v1</body></html>')
|
|
s._noteArtifact(abs, 'test')
|
|
|
|
// Give the broadcast a moment.
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
const raw = chunks.join('')
|
|
assert.match(raw, /event: reload/)
|
|
assert.match(raw, /"artifactId":"live\.html"/)
|
|
assert.match(raw, /"version":1/)
|
|
|
|
// Second update bumps the version.
|
|
fs.writeFileSync(abs, '<!doctype html><html><body>v2</body></html>')
|
|
s._noteArtifact(abs, 'test')
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
const raw2 = chunks.join('')
|
|
assert.match(raw2, /"version":2/)
|
|
|
|
req.destroy()
|
|
} finally {
|
|
await s.close()
|
|
}
|
|
})
|
|
|
|
test('artifact event is emitted with the resolved URL and version', async () => {
|
|
const dir = tmpDir()
|
|
const s = new ArtifactServer({ artifactDir: dir })
|
|
const events = []
|
|
s.on('artifact', (e) => events.push(e))
|
|
try {
|
|
await s.ensureStarted()
|
|
const abs = path.join(dir, 'x.svg')
|
|
fs.writeFileSync(abs, '<svg xmlns="http://www.w3.org/2000/svg"></svg>')
|
|
s._noteArtifact(abs, 'test')
|
|
assert.equal(events.length, 1)
|
|
assert.equal(events[0].artifactId, 'x.svg')
|
|
assert.equal(events[0].kind, 'svg')
|
|
assert.equal(events[0].version, 1)
|
|
assert.match(events[0].url, /\/a\/x\.svg\//)
|
|
|
|
s._noteArtifact(abs, 'test')
|
|
assert.equal(events[1].version, 2)
|
|
} finally {
|
|
await s.close()
|
|
}
|
|
})
|
|
|
|
test('close() releases the port', async () => {
|
|
const dir = tmpDir()
|
|
const s = new ArtifactServer({ artifactDir: dir })
|
|
await s.ensureStarted()
|
|
const port = s.port
|
|
await s.close()
|
|
// Trying to GET now should fail (connection refused). We give the OS a
|
|
// moment to release the port.
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
await assert.rejects(get(`http://127.0.0.1:${port}/health`))
|
|
})
|
|
|
|
test('extractPathCandidates pulls file paths from tool/result shapes', () => {
|
|
const { _internal } = require('../src/main/artifact-ipc.js')
|
|
const { extractPathCandidates } = _internal
|
|
assert.deepEqual(extractPathCandidates({ filePath: '/w/.artifacts/a.html' }), ['/w/.artifacts/a.html'])
|
|
assert.deepEqual(extractPathCandidates({ meta: { path: '/w/.artifacts/b.svg' } }), ['/w/.artifacts/b.svg'])
|
|
assert.deepEqual(
|
|
extractPathCandidates({ content: [{ type: 'text', text: 'wrote to ./.artifacts/c.md today' }] }),
|
|
['./.artifacts/c.md'],
|
|
)
|
|
// No text-block false positives on non-artifact extensions.
|
|
assert.deepEqual(
|
|
extractPathCandidates({ content: [{ type: 'text', text: 'saw file.txt earlier' }] }),
|
|
[],
|
|
)
|
|
// Both structured and text extraction can coexist.
|
|
const both = extractPathCandidates({
|
|
filePath: '/w/.artifacts/a.html',
|
|
content: [{ type: 'text', text: 'also wrote /w/.artifacts/b.md' }],
|
|
})
|
|
assert.ok(both.includes('/w/.artifacts/a.html'))
|
|
assert.ok(both.includes('/w/.artifacts/b.md'))
|
|
})
|