gen-session-format-catalog.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import {
  6. collectSessionFormatMigrations,
  7. readCurrentSessionFormatVersion,
  8. renderSessionFormatCatalog,
  9. } from './gen-session-format-catalog.ts'
  10. const fixtureRoots: string[] = []
  11. afterEach(() => {
  12. for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
  13. })
  14. function fixture(edges: Array<[number, number]>): string {
  15. const root = mkdtempSync(join(tmpdir(), 'dsh-session-format-catalog-'))
  16. fixtureRoots.push(root)
  17. mkdirSync(join(root, 'packages/core/session/src'), { recursive: true })
  18. writeFileSync(join(root, 'packages/core/session/src/types.ts'), 'export const SESSION_FORMAT_VERSION = 2\n')
  19. const catalogDependencies: Record<string, string> = {}
  20. for (const [from, to] of edges) {
  21. const dir = join(root, `packages/session/session-format-v${from}-to-v${to}`)
  22. const name = `@deepseek-ai/dsh-session-format-v${from}-to-v${to}`
  23. mkdirSync(dir, { recursive: true })
  24. catalogDependencies[name] = 'workspace:^'
  25. writeFileSync(join(dir, 'package.json'), JSON.stringify({
  26. name,
  27. dsh: { sessionFormatMigration: {
  28. from, to, export: '.',
  29. migration: `sessionFormatV${from}ToV${to}`,
  30. sourceCodec: `releasedV${from}SessionFormatCodec`,
  31. targetCodec: `releasedV${to}SessionFormatCodec`,
  32. targetHeaderValidator: `assertReleasedV${to}Header`,
  33. targetRestorer: `restoreReleasedV${to}Artifact`,
  34. } },
  35. dependencies: from === 0
  36. ? { '@deepseek-ai/dsh-session-format': 'workspace:^' }
  37. : {
  38. '@deepseek-ai/dsh-session-format': 'workspace:^',
  39. [`@deepseek-ai/dsh-session-format-v${from - 1}-to-v${from}`]: 'workspace:^',
  40. },
  41. }))
  42. }
  43. const catalog = join(root, 'packages/session/session-format-catalog')
  44. mkdirSync(catalog, { recursive: true })
  45. writeFileSync(join(catalog, 'package.json'), JSON.stringify({
  46. dependencies: catalogDependencies,
  47. peerDependencies: { '@deepseek-ai/dsh-session': 'workspace:^' },
  48. devDependencies: { '@deepseek-ai/dsh-session': 'workspace:^' },
  49. }))
  50. return root
  51. }
  52. function edgeManifest(root: string, from: number, to: number): {
  53. path: string
  54. value: Record<string, unknown> & {
  55. dependencies: Record<string, string>
  56. dsh: { sessionFormatMigration: Record<string, unknown> }
  57. }
  58. } {
  59. const path = join(root, `packages/session/session-format-v${from}-to-v${to}/package.json`)
  60. return {
  61. path,
  62. value: JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown> & {
  63. dependencies: Record<string, string>
  64. dsh: { sessionFormatMigration: Record<string, unknown> }
  65. },
  66. }
  67. }
  68. describe('session format catalog generator', () => {
  69. it('normalizes Windows separators in discovered manifest paths before validation', async () => {
  70. const root = fixture([[0, 1], [1, 2]])
  71. vi.resetModules()
  72. vi.doMock('node:fs', async (importOriginal) => {
  73. const actual = await importOriginal<typeof import('node:fs')>()
  74. return {
  75. ...actual,
  76. globSync: () => [
  77. 'packages\\session\\session-format-v0-to-v1\\package.json',
  78. 'packages\\session\\session-format-v1-to-v2\\package.json',
  79. ],
  80. }
  81. })
  82. try {
  83. const { collectSessionFormatMigrations: collect } = await import('./gen-session-format-catalog.ts')
  84. expect(collect(root, 2).map(item => [item.from, item.to])).toEqual([[0, 1], [1, 2]])
  85. } finally {
  86. vi.doUnmock('node:fs')
  87. vi.resetModules()
  88. }
  89. })
  90. it('discovers one complete adjacent chain and renders direct imports', () => {
  91. const root = fixture([[0, 1], [1, 2]])
  92. const version = readCurrentSessionFormatVersion(root)
  93. const declarations = collectSessionFormatMigrations(root, version)
  94. const output = renderSessionFormatCatalog(declarations, version)
  95. expect(declarations.map(item => [item.from, item.to])).toEqual([[0, 1], [1, 2]])
  96. expect(output).toContain("from '@deepseek-ai/dsh-session-format-v0-to-v1'")
  97. expect(output).toContain('currentVersion: 2')
  98. expect(output).toContain('encodeCurrentArtifact: artifact => releasedV2SessionFormatCodec.encodeArtifact(artifact)')
  99. expect(output).toContain('restoreReleasedV2Artifact(artifact, KNOWN_SESSION_EVENT_TYPES)')
  100. expect(output).toContain('assertReleasedV2Header(header)')
  101. expect(output).toContain('validateInstalledCurrentSessionHeader(header)')
  102. expect(output).toContain("from '@deepseek-ai/dsh-session'")
  103. expect(output).toContain("from './current.ts'")
  104. const imports = output.split('\n').filter(line => line.startsWith('import {'))
  105. expect(imports.filter(line => line.includes('releasedV1SessionFormatCodec'))).toHaveLength(1)
  106. })
  107. it('refuses a missing adjacent edge', () => {
  108. const root = fixture([[0, 1]])
  109. expect(() => collectSessionFormatMigrations(root, 2)).toThrow(/exactly one v1->v2/)
  110. })
  111. it('refuses a later edge whose declared source codec does not continue the prior target', () => {
  112. const root = fixture([[0, 1], [1, 2]])
  113. const manifest = edgeManifest(root, 1, 2)
  114. manifest.value.dsh.sessionFormatMigration['sourceCodec'] = 'UnrelatedV1Codec'
  115. writeFileSync(manifest.path, JSON.stringify(manifest.value))
  116. expect(() => collectSessionFormatMigrations(root, 2))
  117. .toThrow(/source codec UnrelatedV1Codec does not continue releasedV1SessionFormatCodec/)
  118. })
  119. it('requires every later edge to depend on the package that owns its source codec', () => {
  120. const root = fixture([[0, 1], [1, 2]])
  121. const manifest = edgeManifest(root, 1, 2)
  122. delete manifest.value.dependencies['@deepseek-ai/dsh-session-format-v0-to-v1']
  123. writeFileSync(manifest.path, JSON.stringify(manifest.value))
  124. expect(() => collectSessionFormatMigrations(root, 2))
  125. .toThrow(/must depend on @deepseek-ai\/dsh-session-format-v0-to-v1/)
  126. })
  127. it('requires the package name to identify its declared adjacent edge', () => {
  128. const root = fixture([[0, 1], [1, 2]])
  129. const manifest = edgeManifest(root, 1, 2)
  130. manifest.value['name'] = '@deepseek-ai/dsh-session-format-other'
  131. writeFileSync(manifest.path, JSON.stringify(manifest.value))
  132. expect(() => collectSessionFormatMigrations(root, 2))
  133. .toThrow(/name must be @deepseek-ai\/dsh-session-format-v1-to-v2/)
  134. })
  135. it('requires the catalog to share the installed Session package as a peer', () => {
  136. const root = fixture([[0, 1], [1, 2]])
  137. const path = join(root, 'packages/session/session-format-catalog/package.json')
  138. const manifest = JSON.parse(readFileSync(path, 'utf8')) as {
  139. dependencies: Record<string, string>
  140. peerDependencies: Record<string, string>
  141. devDependencies: Record<string, string>
  142. }
  143. manifest.dependencies['@deepseek-ai/dsh-session'] = 'workspace:^'
  144. delete manifest.peerDependencies['@deepseek-ai/dsh-session']
  145. writeFileSync(path, JSON.stringify(manifest))
  146. expect(() => collectSessionFormatMigrations(root, 2))
  147. .toThrow(/must share @deepseek-ai\/dsh-session through peer \+ dev dependencies/)
  148. })
  149. })