reporter.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. import { describe, expect, it, vi } from 'vitest'
  2. import {
  3. DSH_TELEMETRY_ENDPOINT,
  4. SecretRedactor,
  5. TELEMETRY_SCHEMA_VERSION,
  6. TelemetryReporter,
  7. type AnonymousId,
  8. type ConsentDecision,
  9. type TelemetryPayload,
  10. } from '@deepseek-ai/dsh-telemetry'
  11. const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' }
  12. const DENY: ConsentDecision = { allowed: false, reason: 'disabled' }
  13. const anon = (value = 'anon-123'): (() => Promise<AnonymousId>) => async () => value as AnonymousId
  14. function okResponse(): Response {
  15. return { ok: true } as Response
  16. }
  17. describe('TelemetryReporter.report', () => {
  18. it('skips delivery when consent is denied', async () => {
  19. const fetchMock = vi.fn(async () => okResponse())
  20. const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() })
  21. reporter.report({ command: 'build', durationMs: 1, success: true }, DENY)
  22. await reporter.flush(50)
  23. expect(fetchMock).not.toHaveBeenCalled()
  24. })
  25. it('posts a redacted envelope when consent is granted', async () => {
  26. const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
  27. const reporter = new TelemetryReporter({
  28. endpoint: 'https://collector.test/telemetry',
  29. fetch: fetchMock,
  30. anonymousId: anon('anon-xyz'),
  31. redactor: new SecretRedactor(),
  32. now: () => 0,
  33. timeoutMs: 100,
  34. })
  35. const payload: TelemetryPayload = {
  36. command: 'config',
  37. durationMs: 7,
  38. success: true,
  39. cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n',
  40. packageJsonContent: '{ "name": "app" }',
  41. }
  42. reporter.report(payload, ALLOW)
  43. await reporter.flush(50)
  44. expect(fetchMock).toHaveBeenCalledTimes(1)
  45. const call = fetchMock.mock.calls[0]!
  46. expect(call[0]).toBe('https://collector.test/telemetry')
  47. const init = call[1]!
  48. expect(init.method).toBe('POST')
  49. const body = JSON.parse(init.body as string) as Record<string, unknown>
  50. expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION)
  51. expect(body.anonymousId).toBe('anon-xyz')
  52. expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z')
  53. expect(body.command).toBe('config')
  54. expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890')
  55. expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek')
  56. expect(body.packageJsonContent).toContain('app')
  57. })
  58. it('posts an envelope without content fields when they are absent', async () => {
  59. const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
  60. const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 })
  61. reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW)
  62. await reporter.flush(50)
  63. const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record<string, unknown>
  64. expect('cordisYmlContent' in body).toBe(false)
  65. expect('packageJsonContent' in body).toBe(false)
  66. })
  67. it('swallows a non-OK HTTP status', async () => {
  68. const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response))
  69. const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
  70. reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW)
  71. await expect(reporter.flush(50)).resolves.toBeUndefined()
  72. expect(fetchMock).toHaveBeenCalledTimes(1)
  73. })
  74. it('swallows a transport failure', async () => {
  75. const fetchMock = vi.fn(async () => { throw new Error('network down') })
  76. const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
  77. reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
  78. await expect(reporter.flush(50)).resolves.toBeUndefined()
  79. })
  80. it('swallows a non-Error transport rejection', async () => {
  81. const fetchMock = vi.fn(async () => { throw 'boom' })
  82. const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
  83. reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
  84. await expect(reporter.flush(50)).resolves.toBeUndefined()
  85. })
  86. it('swallows a failure while resolving the anonymous id, never sending', async () => {
  87. const fetchMock = vi.fn(async () => okResponse())
  88. const reporter = new TelemetryReporter({
  89. fetch: fetchMock,
  90. anonymousId: async () => { throw new Error('config unwritable') },
  91. timeoutMs: 100,
  92. })
  93. reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW)
  94. await reporter.flush(50)
  95. expect(fetchMock).not.toHaveBeenCalled()
  96. })
  97. })
  98. describe('TelemetryReporter.flush', () => {
  99. it('returns immediately when nothing is in flight', async () => {
  100. const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() })
  101. await expect(reporter.flush()).resolves.toBeUndefined()
  102. })
  103. it('resolves on the timeout cap when a send never settles', async () => {
  104. const reporter = new TelemetryReporter({
  105. fetch: () => new Promise<Response>(() => {}),
  106. anonymousId: anon(),
  107. timeoutMs: 10,
  108. })
  109. reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW)
  110. const started = Date.now()
  111. await reporter.flush(15)
  112. expect(Date.now() - started).toBeLessThan(1000)
  113. })
  114. })
  115. describe('TelemetryReporter defaults', () => {
  116. it('defaults the endpoint and transport seams without options', () => {
  117. const reporter = new TelemetryReporter()
  118. expect(reporter).toBeInstanceOf(TelemetryReporter)
  119. expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid')
  120. })
  121. })