Issue #611 asked for file-type icons like the official explorer's, and #429 built a colored theme for them. DSH 0.1.5-rc.2 makes most of that work unnecessary: `@deepseek-ai/dsh-client-ui-primitives` now exports `FileTypeIcon` / `CodeFileIcon` with the host's own 48-type full-color code artwork plus category-colored sheet glyphs (and its classifier behind them). So the plugin renders the host's icons instead of shipping a color table, and color becomes the only behaviour rather than an opt-in theme. File icons (src/client/file-icons.tsx) - `builtinFileIcon(path, size)` → `<FileTypeIcon path size />`; folders use the host's folder category. The plugin's own per-extension VSCodicon map is gone. - The public `registerFileIcon` API from #531 is unchanged and still outranks the built-ins, so an external plugin can take over any row. One semantic consequence, now documented and tested: because the host classifier covers every path, a registered catch-all (`exts: []`) claims rows it used to lose to the built-in map. An unregistered catch-all (the old icon theme's shape) is unaffected. - Nothing is lazy any more: the artwork lives in a platform module the client already holds, so `lib/client-file-icons.js` and its 563-entry dataset (255 kB) are deleted outright — the core bundle grew by 10 kB (0.85 → 0.86 MB). - `src/client/file-icon-theme.ts`, the `fileIconTheme` pref (prefs-shared / config / client prefs), the settings-page select row and the six `settingsFileIconTheme*` keys in all 20 dictionaries are removed with it. Tab glyphs (src/client/builtins/tab-icons.tsx) - Every built-in tab type now declares a colored glyph: files (the host's folder artwork), changes/diff (commit), tasks (checklist), side chat (discussion), terminal (shell), browser (globe). - The color comes from a `--dsw-alias-*` token through a wrapper class, never from a literal — the skin contract (§12) holds and `tests/theme.spec.ts` enforces both halves (no literals in the module, every color declaration in the stylesheet resolves to a token). Native right Sidebar chips (src/client/native/tab-adapter.tsx) - The host's tab definition has no icon field, but the `sidebar.right.pane.tab.title` slot IS the chip's content, so the plugin draws the glyph itself: an editor tab with a path shows that file's icon, every other tab shows its descriptor's icon — the same `descriptor.icon` the workbench strip and the guide capsules already render, so the three cannot drift. The glyph is `aria-hidden`, which keeps the chip's accessible name exactly the title the mount lane matches on. Gates: typecheck / lint / check:consumer-types green; pnpm test 127 files / 1342 passed / 9 skipped (3 new chip cases, 1 rewritten built-in case).
156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
/**
|
|
* Lazy chunk route tests (src/bundle-route.ts): the /sidebar/bundle handler
|
|
* that serves the client's split bundles. Pins the trust fence, the name
|
|
* allowlist (no traversal), method gating, and the caching contract — ETag
|
|
* + If-None-Match 304 so multi-MB chunks revalidate cheaply instead of
|
|
* re-downloading.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import { CHUNK_NAMES, createBundleRouteHandler } from '../src/bundle-route.ts'
|
|
|
|
interface FakeRes {
|
|
status: number
|
|
headers: Record<string, string>
|
|
body: string
|
|
writeHead(status: number, headers?: Record<string, string>): void
|
|
end(body?: string | Buffer): void
|
|
}
|
|
|
|
function fakeRes(): FakeRes {
|
|
return {
|
|
status: 0,
|
|
headers: {},
|
|
body: '',
|
|
writeHead(status, headers = {}) {
|
|
this.status = status
|
|
this.headers = headers
|
|
},
|
|
end(body) {
|
|
if (body !== undefined) this.body = body.toString()
|
|
},
|
|
} as FakeRes
|
|
}
|
|
|
|
function req(method: string, url: string, headers: Record<string, string> = {}): IncomingMessage {
|
|
return { method, url, headers } as unknown as IncomingMessage
|
|
}
|
|
|
|
/** One handler instance over a scratch dir with a fake chunk file. */
|
|
function setup(): { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; dir: string; cleanup: () => void } {
|
|
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-'))
|
|
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 0;')
|
|
const handler = createBundleRouteHandler(() => true, dir)
|
|
return { handler, dir, cleanup: () => { rmSync(dir, { recursive: true, force: true }) } }
|
|
}
|
|
|
|
describe('/sidebar/bundle route', () => {
|
|
it('serves an allowlisted chunk with the JS content type and an ETag', async () => {
|
|
const { handler, cleanup } = setup()
|
|
try {
|
|
const res = fakeRes()
|
|
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8')
|
|
expect(res.headers['cache-control']).toBe('no-cache')
|
|
expect(res.headers.etag).toMatch(/^"[0-9a-f]{12}"$/)
|
|
expect(res.body).toContain('window.__ModuleLoader__')
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('revalidates with a 304 when If-None-Match matches (no re-download)', async () => {
|
|
const { handler, cleanup } = setup()
|
|
try {
|
|
const first = fakeRes()
|
|
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
|
|
const etag = first.headers.etag!
|
|
const second = fakeRes()
|
|
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
|
|
expect(second.status).toBe(304)
|
|
expect(second.body).toBe('')
|
|
expect(second.headers.etag).toBe(etag)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('serves the new body after the file changed (ETag rotates)', async () => {
|
|
const { handler, dir, cleanup } = setup()
|
|
try {
|
|
const first = fakeRes()
|
|
await handler(req('GET', '/sidebar/bundle/editor.js'), first as unknown as ServerResponse)
|
|
// The rewrite must change the LENGTH too: the ETag memo revalidates on
|
|
// stat (mtime+size), and Windows updates file timestamps lazily — an
|
|
// equal-size rewrite right after the first read can still look
|
|
// unchanged (stale 304). A size delta busts the memo deterministically
|
|
// on every platform.
|
|
writeFileSync(join(dir, 'client-editor.js'), 'window.__ModuleLoader__ && 1; // rotated')
|
|
const second = fakeRes()
|
|
await handler(req('GET', '/sidebar/bundle/editor.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
|
|
expect(second.status).toBe(200)
|
|
expect(second.headers.etag).not.toBe(first.headers.etag)
|
|
expect(second.body).toContain('&& 1')
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('rejects unknown chunk names (including traversal attempts) with 404', async () => {
|
|
const { handler, cleanup } = setup()
|
|
try {
|
|
for (const url of ['/sidebar/bundle/evil.js', '/sidebar/bundle/xlsx', '/sidebar/bundle/../secret.js', '/sidebar/bundle/xlsx.js/extra']) {
|
|
const res = fakeRes()
|
|
await handler(req('GET', url), res as unknown as ServerResponse)
|
|
expect(res.status, url).toBe(404)
|
|
}
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('404s an allowlisted name whose built file is missing', async () => {
|
|
const { handler, cleanup } = setup()
|
|
try {
|
|
const res = fakeRes()
|
|
// 'terminal' is allowlisted but the scratch fixture only ships
|
|
// client-editor.js — the built file is missing → 404.
|
|
await handler(req('GET', '/sidebar/bundle/terminal.js'), res as unknown as ServerResponse)
|
|
expect(res.status).toBe(404)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('gates non-GET/HEAD methods with 405', async () => {
|
|
const { handler, cleanup } = setup()
|
|
try {
|
|
const res = fakeRes()
|
|
await handler(req('POST', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
|
expect(res.status).toBe(405)
|
|
} finally {
|
|
cleanup()
|
|
}
|
|
})
|
|
|
|
it('enforces the browser-trust fence with 403 before any lookup', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-fence-'))
|
|
try {
|
|
const handler = createBundleRouteHandler(() => false, dir)
|
|
const res = fakeRes()
|
|
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
|
|
expect(res.status).toBe(403)
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
|
|
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale'])
|
|
})
|
|
})
|