frontend-static.spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * REAL-composition coverage: a test-only cordis.yml booted through the
  3. * vendored Loader mounts the webserver and frontend-static rows, and every
  4. * assertion observes the served HTTP surface — asset serving, explicit index
  5. * entry points with index taps, 404 misses, traversal rejection, 405 on non-
  6. * GET/HEAD, and seat release on fiber disposal (HMR safety).
  7. */
  8. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { pathToFileURL } from 'node:url'
  12. import { afterEach, describe, expect, it } from 'vitest'
  13. import { Context } from '@deepseek-ai/cordis'
  14. import Loader from '@deepseek-ai/cordis-plugin-loader'
  15. import Include from '@deepseek-ai/cordis-plugin-include'
  16. import * as Connection from '@deepseek-ai/dsh-client-connection'
  17. import LocalCredentials from '@deepseek-ai/dsh-credentials-local'
  18. import HttpServer from '@deepseek-ai/dsh-host-webserver'
  19. import * as FrontendStatic from '../src/index.ts'
  20. let root: string | undefined
  21. let context: Context | undefined
  22. afterEach(async () => {
  23. await context?.fiber.dispose()
  24. context = undefined
  25. if (root !== undefined) await rm(root, { recursive: true, force: true })
  26. root = undefined
  27. })
  28. /** Write a dist fixture and the authenticated Web rows, then boot them through the real Loader. */
  29. async function loadComposition(): Promise<Context> {
  30. root = await mkdtemp(join(tmpdir(), 'dsh-frontend-static-'))
  31. const dist = join(root, 'dist')
  32. await mkdir(dist)
  33. const distIndex = join(dist, 'index.html')
  34. await writeFile(distIndex, '<head></head><body>shell</body>')
  35. await writeFile(join(dist, 'app.js'), 'export {}')
  36. await writeFile(join(dist, 'blob.bin'), 'BLOB')
  37. await writeFile(join(dist, 'manifest.webmanifest'), '{}')
  38. await mkdir(join(dist, 'empty'))
  39. const configPath = join(root, 'cordis.yml')
  40. await writeFile(configPath, [
  41. "- name: '@deepseek-ai/dsh-credentials-local'",
  42. ' config:',
  43. ` path: '${join(root, '.credentials.yaml')}'`,
  44. ' watch: false',
  45. "- name: '@deepseek-ai/dsh-host-webserver'",
  46. ' config:',
  47. " host: '127.0.0.1'",
  48. ' port: 0',
  49. "- name: '@deepseek-ai/dsh-client-connection'",
  50. '- id: frontend',
  51. " name: '@deepseek-ai/dsh-host-frontend-static'",
  52. ' config:',
  53. ` distIndex: '${distIndex}'`,
  54. '',
  55. ].join('\n'))
  56. context = new Context()
  57. context.baseUrl = pathToFileURL(root).href + '/'
  58. await context.plugin(Loader)
  59. context.loader.builtins.include = Include
  60. const modules = new Map<string, unknown>([
  61. ['@deepseek-ai/dsh-credentials-local', LocalCredentials],
  62. ['@deepseek-ai/dsh-host-webserver', HttpServer],
  63. ['@deepseek-ai/dsh-client-connection', Connection],
  64. ['@deepseek-ai/dsh-host-frontend-static', FrontendStatic],
  65. ])
  66. context.loader.internal = {
  67. version: 'v2',
  68. async import(specifier: string) {
  69. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  70. return modules.get(specifier)
  71. },
  72. } as unknown as NonNullable<typeof context.loader.internal>
  73. await context.loader.create({
  74. name: 'cordis:include',
  75. config: { path: pathToFileURL(configPath).href },
  76. })
  77. await context.loader.await()
  78. return context
  79. }
  80. /** GET (by default) one path against the running server; returns status, content-type, and the body. */
  81. async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; type: string | null; body: string }> {
  82. const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init)
  83. return {
  84. status: response.status,
  85. type: response.headers.get('content-type'),
  86. body: await response.text(),
  87. }
  88. }
  89. describe('real Loader composition', () => {
  90. it('serves explicit index entries and files while preserving HTTP error semantics', { timeout: 60_000 }, async () => {
  91. const loaded = await loadComposition()
  92. const unloaded = [...loaded.loader.entries()]
  93. .filter(entry => entry.fiber === undefined && !entry.disabled)
  94. .map(entry => entry.options.name)
  95. expect(unloaded).toEqual([])
  96. const server = loaded.webServer
  97. const port = server.port
  98. const launchUrl = loaded.connection.authenticatedUrl(`http://127.0.0.1:${String(port)}`)
  99. const exchange = await fetch(launchUrl, { redirect: 'manual' })
  100. expect(exchange.status).toBe(303)
  101. expect(exchange.headers.get('location')).toBe('/')
  102. const setCookie = exchange.headers.get('set-cookie')
  103. if (setCookie === null) throw new Error('authenticated frontend did not set a cookie')
  104. const cookie = setCookie.split(';', 1)[0]!
  105. const authenticated = (init?: RequestInit): RequestInit => {
  106. const headers = new Headers(init?.headers)
  107. headers.set('cookie', cookie)
  108. return { ...init, headers }
  109. }
  110. expect(await request(port, '/')).toMatchObject({
  111. status: 401,
  112. type: 'text/plain; charset=utf-8',
  113. body: 'dsh web authentication required; reopen the URL printed by dsh web.\n',
  114. })
  115. // Real assets with their MIME types; a live rebuild is served on the next read.
  116. expect(await request(port, '/app.js')).toMatchObject({ status: 200, type: 'text/javascript; charset=utf-8', body: 'export {}' })
  117. expect(await request(port, '/manifest.webmanifest')).toMatchObject({
  118. status: 200,
  119. type: 'application/manifest+json',
  120. body: '{}',
  121. })
  122. expect(await request(port, '/app.js', { method: 'HEAD' })).toEqual({
  123. status: 200,
  124. type: 'text/javascript; charset=utf-8',
  125. body: '',
  126. })
  127. await writeFile(join(root!, 'dist', 'app.js'), 'export const rebuilt = true')
  128. expect(await request(port, '/app.js')).toMatchObject({ status: 200, body: 'export const rebuilt = true' })
  129. // Unknown extension ships as octet-stream.
  130. expect(await request(port, '/blob.bin')).toMatchObject({ status: 200, type: 'application/octet-stream', body: 'BLOB' })
  131. // Only the root and index path render index.html through registered taps.
  132. const untap = server.tapIndex(html => html.replace('<head>', '<head><script>window.__T__=1</script>'))
  133. for (const path of ['/', '/index.html', '/?view=test']) {
  134. const got = await request(port, path, authenticated())
  135. expect(got.status).toBe(200)
  136. expect(got.type).toBe('text/html; charset=utf-8')
  137. expect(got.body).toContain('__T__')
  138. expect(got.body).toContain('shell')
  139. }
  140. expect(await request(port, '/', authenticated({ method: 'HEAD' }))).toEqual({
  141. status: 200,
  142. type: 'text/html; charset=utf-8',
  143. body: '',
  144. })
  145. untap()
  146. expect((await request(port, '/', authenticated())).body).not.toContain('__T__')
  147. // A missing configured index follows the same empty-404 contract for both
  148. // of its public entry paths and for both supported methods.
  149. await rm(join(root!, 'dist', 'index.html'))
  150. for (const path of ['/', '/index.html']) {
  151. const get = await request(port, path, authenticated())
  152. const head = await request(port, path, authenticated({ method: 'HEAD' }))
  153. expect(get).toEqual({ status: 404, type: null, body: '' })
  154. expect(head).toEqual(get)
  155. }
  156. // Ordinary unknown paths and static-resource misses are empty 404s for
  157. // both GET and HEAD; neither class can be mistaken for the HTML shell.
  158. const ordinaryMisses = ['/no/such/route', '/empty', '/app.js/child']
  159. const assetMisses = [
  160. '/missing.js',
  161. '/missing.css',
  162. '/missing.mjs',
  163. '/missing.js.map',
  164. '/missing.webmanifest',
  165. '/missing.manifest',
  166. ]
  167. for (const path of [...ordinaryMisses, ...assetMisses]) {
  168. const get = await request(port, path)
  169. const head = await request(port, path, { method: 'HEAD' })
  170. expect(get).toEqual({ status: 404, type: null, body: '' })
  171. expect(head).toEqual(get)
  172. }
  173. expect(await request(port, '/api/no/such/route', authenticated())).toEqual({
  174. status: 404,
  175. type: 'text/plain;charset=UTF-8',
  176. body: 'not found',
  177. })
  178. // Traversal outside the dist root is 403, non-GET/HEAD is 405, and a
  179. // malformed filesystem target still reaches the webserver's 400 guard.
  180. expect((await request(port, '/..%2f..%2fetc%2fpasswd')).status).toBe(403)
  181. expect((await request(port, '/app.js', { method: 'POST' })).status).toBe(405)
  182. expect((await request(port, '/bad%00path')).status).toBe(400)
  183. // HMR safety: disposing the frontend row releases the fallback seat (the
  184. // unclaimed webserver answers 404) and the seat is claimable again.
  185. const frontendEntry = [...loaded.loader.entries()].find(e => e.options.id === 'frontend')
  186. expect(frontendEntry).toBeDefined()
  187. await frontendEntry!.fiber?.dispose()
  188. expect((await request(port, '/no/such/route')).status).toBe(404)
  189. expect(() => server.registerFallback(() => {})).not.toThrow()
  190. })
  191. })