// 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// 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
hi