harness.ts 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. /**
  2. * Shared fixture: a real local backend over a temp workspace beside a sibling
  3. * directory outside it, and a sandbox policy whose only job is naming the root.
  4. *
  5. * The real backend, not a mocked `ctx.fs`, because the gates under test are
  6. * only meaningful against a real filesystem: a symlink that leaves the
  7. * workspace, a file whose byte size exceeds the cap, and bytes that are not
  8. * text. A fake provider would let a string-prefix containment check pass this
  9. * file, which is exactly the defect the gate exists to prevent.
  10. */
  11. import { mkdir, mkdtemp, rm } from 'node:fs/promises'
  12. import { tmpdir } from 'node:os'
  13. import { join } from 'node:path'
  14. import { Context } from '@deepseek-ai/cordis'
  15. import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
  16. import { SessionId } from '@deepseek-ai/dsh-session/types'
  17. import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
  18. import { WorkspaceFiles, type Config, type WorkspaceFileScope } from '../src/index.ts'
  19. /** Build the header-derived scope that direct service calls receive after Typert lookup. */
  20. function fileScope(workspaceRoot: string): WorkspaceFileScope {
  21. return { sessionId: SessionId('s-test'), workspaceRoot }
  22. }
  23. export const signal = (): AbortSignal => new AbortController().signal
  24. /** One temp workspace and the context serving it. */
  25. export interface Harness {
  26. readonly workspace: string
  27. readonly outside: string
  28. readonly ctx: Context
  29. readonly scope: WorkspaceFileScope
  30. /**
  31. * The service under test, at the given caps. One per test: the service key is
  32. * global to the Context, so a second call with caps is a defect in the test.
  33. */
  34. endpoint(caps?: Partial<Config>): WorkspaceFiles
  35. dispose(): Promise<void>
  36. }
  37. /**
  38. * Create the workspace, its outside sibling, and a context with the local
  39. * backend rooted at the workspace.
  40. * @param prefix - temp directory prefix naming the suite.
  41. * @returns the harness; dispose it in `afterEach`.
  42. */
  43. export async function openWorkspace(prefix: string): Promise<Harness> {
  44. const root = await mkdtemp(join(tmpdir(), prefix))
  45. const workspace = join(root, 'workspace')
  46. const outside = join(root, 'outside')
  47. await mkdir(workspace, { recursive: true })
  48. await mkdir(outside, { recursive: true })
  49. const ctx = new Context()
  50. const fiber = await ctx.plugin(LocalFileSystem, { cwd: workspace })
  51. ctx.provide('sandboxPolicy', {
  52. workspaceRoot: workspace,
  53. resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }),
  54. } as never)
  55. let service: WorkspaceFiles | undefined
  56. return {
  57. workspace,
  58. outside,
  59. ctx,
  60. scope: fileScope(workspace),
  61. endpoint: (caps) => {
  62. if (service !== undefined) {
  63. if (caps !== undefined) throw new Error('the harness serves one WorkspaceFiles per test; hoist the endpoint')
  64. return service
  65. }
  66. service = new WorkspaceFiles(ctx, {
  67. maxBytes: caps?.maxBytes ?? 1024 * 1024,
  68. maxFileBytes: caps?.maxFileBytes ?? 1024 * 1024,
  69. maxLines: caps?.maxLines ?? 5000,
  70. maxEntries: caps?.maxEntries ?? 2000,
  71. })
  72. return service
  73. },
  74. dispose: async () => {
  75. await fiber.dispose()
  76. await rm(root, { recursive: true, force: true })
  77. },
  78. }
  79. }
  80. /**
  81. * Await an operation expected to fail with a Remote error.
  82. * @param operation - the call under test.
  83. * @returns the Remote failure's code and details.
  84. */
  85. export async function failureOf(operation: Promise<unknown>): Promise<{ code: string; details: unknown }> {
  86. try {
  87. await operation
  88. } catch (error: unknown) {
  89. const failure = remoteErrorOf(error)
  90. // A non-Remote throw is a defect in the service, not an expected outcome.
  91. if (failure === undefined) throw error
  92. return { code: failure.code, details: failure.details }
  93. }
  94. throw new Error('expected the operation to fail')
  95. }