dsh --profile <name> replaces the fixed entry modes: --config and -p are removed, --patch adds overlays over the composed profile, a positional task selects one-shot mode (requires the headless-runner row), and dsh web stays as the alias for --profile web carrying the Web flag family as patches. dsh plugin --profile <name> forwards verbatim to pnpm in the profile directory, initializes on first use, and reconciles the dsh.plugins layer list after add/remove (patch-less packages warn and stay plain dependencies). Config dumps and the keyless web e2e scaffold compose the same bundle layers over the same empty root as the boot.
128 lines
5.3 KiB
TypeScript
128 lines
5.3 KiB
TypeScript
/**
|
|
* REAL-composition coverage: a test-only cordis.yml booted through the
|
|
* vendored Loader mounts the webserver and frontend-static rows, and every
|
|
* assertion observes the served HTTP surface — asset serving, MIME fallback,
|
|
* SPA index fallback with index taps, traversal rejection, 405 on non-GET/
|
|
* HEAD, and seat release on fiber disposal (HMR safety).
|
|
*/
|
|
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
import Include from '@cordisjs/plugin-include'
|
|
import HttpServer from '@deepseek-ai/dsh-host-webserver'
|
|
import * as FrontendStatic from '../src/index.ts'
|
|
|
|
let root: string | undefined
|
|
let context: Context | undefined
|
|
|
|
afterEach(async () => {
|
|
await context?.fiber.dispose()
|
|
context = undefined
|
|
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
|
root = undefined
|
|
})
|
|
|
|
/** Write a dist fixture and a two-row cordis.yml, then boot it through the real Loader. */
|
|
async function loadComposition(): Promise<Context> {
|
|
root = await mkdtemp(join(tmpdir(), 'dsh-frontend-static-'))
|
|
const dist = join(root, 'dist')
|
|
await mkdir(dist)
|
|
const distIndex = join(dist, 'index.html')
|
|
await writeFile(distIndex, '<head></head><body>shell</body>')
|
|
await writeFile(join(dist, 'app.js'), 'export {}')
|
|
await writeFile(join(dist, 'blob.bin'), 'BLOB')
|
|
const configPath = join(root, 'cordis.yml')
|
|
await writeFile(configPath, [
|
|
"- name: '@deepseek-ai/dsh-host-webserver'",
|
|
' config:',
|
|
" host: '127.0.0.1'",
|
|
' port: 0',
|
|
'- id: frontend',
|
|
" name: '@deepseek-ai/dsh-frontend-static'",
|
|
' config:',
|
|
` distIndex: '${distIndex}'`,
|
|
'',
|
|
].join('\n'))
|
|
|
|
context = new Context()
|
|
context.baseUrl = pathToFileURL(root).href + '/'
|
|
await context.plugin(Loader)
|
|
context.loader.builtins.include = Include
|
|
const modules = new Map<string, unknown>([
|
|
['@deepseek-ai/dsh-host-webserver', HttpServer],
|
|
['@deepseek-ai/dsh-frontend-static', FrontendStatic],
|
|
])
|
|
context.loader.internal = {
|
|
version: 'v2',
|
|
async import(specifier: string) {
|
|
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
|
return modules.get(specifier)
|
|
},
|
|
} as unknown as NonNullable<typeof context.loader.internal>
|
|
await context.loader.create({
|
|
name: 'cordis:include',
|
|
config: { path: pathToFileURL(configPath).href },
|
|
})
|
|
await context.loader.await()
|
|
return context
|
|
}
|
|
|
|
/** GET (by default) one path against the running server; returns status, content-type, and a body prefix. */
|
|
async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; type: string | null; body: string }> {
|
|
const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init)
|
|
return {
|
|
status: response.status,
|
|
type: response.headers.get('content-type'),
|
|
body: (await response.text()).slice(0, 80),
|
|
}
|
|
}
|
|
|
|
describe('real Loader composition', () => {
|
|
it('serves the dist with SPA fallback, taps, traversal rejection, and method gating', { timeout: 60_000 }, async () => {
|
|
const loaded = await loadComposition()
|
|
const unloaded = [...loaded.loader.entries()]
|
|
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
|
.map(entry => entry.options.name)
|
|
expect(unloaded).toEqual([])
|
|
const server = loaded.httpServer
|
|
const port = server.port
|
|
|
|
// Real asset with its MIME type; a live rebuild is served on the next read.
|
|
expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' })
|
|
await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
|
|
expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
|
|
|
|
// Unknown extension ships as octet-stream.
|
|
expect(await request(port, '/blob.bin')).toMatchObject({ status: 200, type: 'application/octet-stream', body: 'BLOB' })
|
|
|
|
// `/`, the index path, and any miss all render index.html (SPA routing)
|
|
// through the registered index taps.
|
|
const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
|
|
for (const path of ['/', '/index.html', '/no/such/route']) {
|
|
const got = await request(port, path)
|
|
expect(got.status).toBe(200)
|
|
expect(got.body).toContain('__T__')
|
|
expect(got.body).toContain('shell')
|
|
}
|
|
untap()
|
|
expect((await request(port, '/')).body).not.toContain('__T__')
|
|
|
|
// Traversal outside the dist root is 403; non-GET/HEAD is 405.
|
|
expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
|
|
expect((await request(port, '/nowhere', { method: 'POST' })).status).toBe(405)
|
|
|
|
// HMR safety: disposing the frontend row releases the fallback seat (the
|
|
// unclaimed webserver answers 404) and the seat is claimable again.
|
|
const frontendEntry = [...loaded.loader.entries()].find(e => e.options.id === 'frontend')
|
|
expect(frontendEntry).toBeDefined()
|
|
await frontendEntry!.fiber?.dispose()
|
|
expect((await request(port, '/no/such/route')).status).toBe(404)
|
|
expect(() => server.registerFallback(() => {})).not.toThrow()
|
|
})
|
|
})
|