surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| 1 | import { describe, expect, mock, test, tier } from 'claude-code/testing' |
| 2 | |
| 3 | import Hooks from '../hooks' |
| 4 | import Fixtures from './fixtures' |
| 5 | |
| 6 | tier('builtin') |
| 7 | |
| 8 | const tiers = ['builtin', 'core', 'user', 'prepend', 'append'] as const |
| 9 | |
| 10 | describe('register', () => { |
| 11 | test( |
| 12 | 'rows queued through $.telemetry.log go out as one complete batch', |
| 13 | { plugins: [Fixtures.recording] }, |
| 14 | async ($, on) => { |
| 15 | mock.env(on, Fixtures.SENDING_ENV) |
| 16 | |
| 17 | const session = Fixtures.firstPartySession(on) |
| 18 | |
| 19 | await $.session.start(Fixtures.STARTED) |
| 20 | |
| 21 | const first = await $.command.run( |
| 22 | Fixtures.record(Fixtures.surveyAnswer()), |
| 23 | ) |
| 24 | |
| 25 | await $.command.run(Fixtures.record({ event: 'again', props: { n: 1 } })) |
| 26 | |
| 27 | expect(first).toEqual({ text: 'queued' }) |
| 28 | expect(session.posts, 'nothing before the window passes').toEqual([]) |
| 29 | |
| 30 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 31 | |
| 32 | expect( |
| 33 | session.posts.map(post => [ |
| 34 | post.url, |
| 35 | post.init?.method, |
| 36 | post.init?.auth, |
| 37 | post.init?.headers, |
| 38 | ]), |
| 39 | ).toEqual([ |
| 40 | [ |
| 41 | 'https://api.anthropic.com/api/event_logging/v2/batch', |
| 42 | 'POST', |
| 43 | 'the-handle', |
| 44 | { |
| 45 | 'Content-Type': 'application/json', |
| 46 | 'x-service-name': 'claude-code', |
| 47 | }, |
| 48 | ], |
| 49 | ]) |
| 50 | |
| 51 | expect(Fixtures.rowsOf(session)).toHaveLength(2) |
| 52 | expect(Fixtures.rowsOf(session)[0]).toEqual(Fixtures.EXPECTED_ROW) |
| 53 | |
| 54 | expect(Fixtures.rowsOf(session)[1]).toMatchObject({ |
| 55 | event_name: 'tengu_plugin_again', |
| 56 | metadata: { rh: Fixtures.REMOTE_HASH, n: 1 }, |
| 57 | }) |
| 58 | |
| 59 | expect(session.lines).toEqual(['telemetry: sent 2 row(s)']) |
| 60 | }, |
| 61 | ) |
| 62 | |
| 63 | test( |
| 64 | 'an engine without the version read leaves those three columns empty', |
| 65 | { plugins: [Fixtures.recording] }, |
| 66 | async ($, on) => { |
| 67 | mock.env(on, Fixtures.SENDING_ENV) |
| 68 | |
| 69 | const session = Fixtures.firstPartySession(on, { |
| 70 | engineVersion: 'unanswered', |
| 71 | }) |
| 72 | |
| 73 | await $.session.start(Fixtures.STARTED) |
| 74 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 75 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 76 | |
| 77 | const { version, version_base, build_time, ...rest } = |
| 78 | Fixtures.EXPECTED_ROW.env |
| 79 | |
| 80 | expect([version, version_base, build_time]).toEqual([ |
| 81 | Fixtures.ENGINE_VERSION.version, |
| 82 | Fixtures.ENGINE_VERSION.base, |
| 83 | Fixtures.ENGINE_VERSION.builtAt, |
| 84 | ]) |
| 85 | |
| 86 | expect(Fixtures.rowsOf(session)).toEqual([ |
| 87 | { ...Fixtures.EXPECTED_ROW, env: rest }, |
| 88 | ]) |
| 89 | |
| 90 | expect(session.lines).toEqual([ |
| 91 | expect.stringContaining( |
| 92 | "telemetry: the engine's version is not readable here, so the " + |
| 93 | 'version columns stay empty (', |
| 94 | ), |
| 95 | 'telemetry: sent 1 row(s)', |
| 96 | ]) |
| 97 | |
| 98 | expect(session.lines[0]).toContain('session.version') |
| 99 | }, |
| 100 | ) |
| 101 | |
| 102 | test( |
| 103 | 'a full queue goes out at once, before the window', |
| 104 | { plugins: [Fixtures.recording] }, |
| 105 | async ($, on) => { |
| 106 | mock.env(on, Fixtures.SENDING_ENV) |
| 107 | |
| 108 | const session = Fixtures.firstPartySession(on) |
| 109 | |
| 110 | for (let row = 0; row < Hooks.BATCH_ROWS; row++) { |
| 111 | await $.command.run(Fixtures.record({ event: 'x', props: { row } })) |
| 112 | } |
| 113 | |
| 114 | await session.clock.settle() |
| 115 | |
| 116 | expect(session.posts).toHaveLength(1) |
| 117 | expect(Fixtures.rowsOf(session)).toHaveLength(Hooks.BATCH_ROWS) |
| 118 | }, |
| 119 | ) |
| 120 | |
| 121 | test( |
| 122 | 'the end of the session sends what still waits', |
| 123 | { plugins: [Fixtures.recording] }, |
| 124 | async ($, on) => { |
| 125 | mock.env(on, Fixtures.SENDING_ENV) |
| 126 | |
| 127 | const session = Fixtures.firstPartySession(on) |
| 128 | |
| 129 | await $.session.start(Fixtures.STARTED) |
| 130 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 131 | await $.session.end(Fixtures.ENDED) |
| 132 | |
| 133 | expect(Fixtures.rowsOf(session)).toEqual([Fixtures.EXPECTED_ROW]) |
| 134 | }, |
| 135 | ) |
| 136 | |
| 137 | test( |
| 138 | 'a tengu_ name is kept; an external build sends no address', |
| 139 | { plugins: [Fixtures.recording] }, |
| 140 | async ($, on) => { |
| 141 | mock.env(on, { ...Fixtures.SENDING_ENV, USER_TYPE: 'external' }) |
| 142 | |
| 143 | const session = Fixtures.firstPartySession(on) |
| 144 | |
| 145 | await $.command.run( |
| 146 | Fixtures.record({ |
| 147 | ...Fixtures.surveyAnswer(), |
| 148 | event: 'tengu_repl_diff_panel_shown', |
| 149 | }), |
| 150 | ) |
| 151 | |
| 152 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 153 | |
| 154 | expect(Fixtures.rowsOf(session)[0]).toMatchObject({ |
| 155 | event_name: 'tengu_repl_diff_panel_shown', |
| 156 | user_type: 'external', |
| 157 | device_id: Fixtures.DEVICE_ID, |
| 158 | }) |
| 159 | |
| 160 | expect(Fixtures.rowsOf(session)[0]).not.toHaveProperty('email') |
| 161 | }, |
| 162 | ) |
| 163 | |
| 164 | test( |
| 165 | 'nothing is sent where any switch has analytics off', |
| 166 | { plugins: [Fixtures.recording] }, |
| 167 | async ($, on) => { |
| 168 | let environment: Readonly<Record<string, string>> = {} |
| 169 | |
| 170 | on('env.get', ($, e) => ({ value: environment[e.name] })) |
| 171 | |
| 172 | const session = Fixtures.firstPartySession(on) |
| 173 | const answers: (string | undefined)[] = [] |
| 174 | |
| 175 | for (const off of Fixtures.ANALYTICS_OFF_ENVIRONMENTS) { |
| 176 | environment = { ...Fixtures.SENDING_ENV, ...off } |
| 177 | |
| 178 | answers.push( |
| 179 | (await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))).text, |
| 180 | ) |
| 181 | |
| 182 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 183 | } |
| 184 | |
| 185 | const withheld = session.posts.length |
| 186 | |
| 187 | environment = Fixtures.SENDING_ENV |
| 188 | |
| 189 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 190 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 191 | |
| 192 | expect(answers).toEqual( |
| 193 | Fixtures.ANALYTICS_OFF_ENVIRONMENTS.map(() => 'queued'), |
| 194 | ) |
| 195 | |
| 196 | expect(withheld, 'nothing posted while any switch was off').toBe(0) |
| 197 | |
| 198 | expect( |
| 199 | session.posts, |
| 200 | 'one batch once every switch is clear', |
| 201 | ).toHaveLength(1) |
| 202 | }, |
| 203 | ) |
| 204 | |
| 205 | test( |
| 206 | 'a gateway the managed policy pins sends nothing', |
| 207 | { plugins: [Fixtures.recording] }, |
| 208 | async ($, on) => { |
| 209 | mock.env(on, Fixtures.SENDING_ENV) |
| 210 | |
| 211 | const session = Fixtures.firstPartySession(on, { |
| 212 | policy: { kind: 'settings', settings: { forceLoginMethod: 'gateway' } }, |
| 213 | }) |
| 214 | |
| 215 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 216 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 217 | |
| 218 | expect(session.posts).toEqual([]) |
| 219 | }, |
| 220 | ) |
| 221 | |
| 222 | test( |
| 223 | 'unreadable switches send nothing, and the debug log says why', |
| 224 | { plugins: [Fixtures.recording] }, |
| 225 | async ($, on) => { |
| 226 | mock.env(on, Fixtures.SENDING_ENV) |
| 227 | |
| 228 | const session = Fixtures.firstPartySession(on, { |
| 229 | policy: { kind: 'unreadable' }, |
| 230 | }) |
| 231 | |
| 232 | const { text } = await $.command.run( |
| 233 | Fixtures.record(Fixtures.surveyAnswer()), |
| 234 | ) |
| 235 | |
| 236 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 237 | |
| 238 | expect({ text, posts: session.posts }).toEqual({ |
| 239 | text: 'queued', |
| 240 | posts: [], |
| 241 | }) |
| 242 | |
| 243 | expect(session.reads, 'nothing of the machine is read either').toEqual([]) |
| 244 | |
| 245 | expect(session.lines.at(-1)).toStartWith( |
| 246 | 'telemetry: the analytics switches could not be read, so nothing is ' + |
| 247 | 'sent (', |
| 248 | ) |
| 249 | }, |
| 250 | ) |
| 251 | |
| 252 | test( |
| 253 | 'a host-managed third-party provider follows DISABLE_TELEMETRY', |
| 254 | { plugins: [Fixtures.recording] }, |
| 255 | async ($, on) => { |
| 256 | let environment = Fixtures.HOST_MANAGED_BEDROCK_ENV |
| 257 | |
| 258 | on('env.get', ($, e) => ({ value: environment[e.name] })) |
| 259 | |
| 260 | const session = Fixtures.firstPartySession(on) |
| 261 | |
| 262 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 263 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 264 | |
| 265 | const managed = session.posts.length |
| 266 | |
| 267 | environment = { ...environment, DISABLE_TELEMETRY: '1' } |
| 268 | |
| 269 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 270 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 271 | |
| 272 | expect({ managed, afterOptOut: session.posts.length }).toEqual({ |
| 273 | managed: 1, |
| 274 | afterOptOut: 1, |
| 275 | }) |
| 276 | }, |
| 277 | ) |
| 278 | |
| 279 | test( |
| 280 | 'marks go out as the feature events, by their own names', |
| 281 | { plugins: [Fixtures.marking] }, |
| 282 | async ($, on) => { |
| 283 | mock.env(on, Fixtures.SENDING_ENV) |
| 284 | |
| 285 | const session = Fixtures.firstPartySession(on) |
| 286 | const answers: (string | undefined)[] = [] |
| 287 | |
| 288 | for (const entry of [ |
| 289 | { feature: 'learn_page', kind: 'ok' }, |
| 290 | { feature: 'learn_page', kind: 'sad', reason: 'blocked' }, |
| 291 | { feature: 'suggest_learning', kind: 'bad', reason: 'api_error' }, |
| 292 | ]) { |
| 293 | answers.push((await $.command.run(Fixtures.mark(entry))).text) |
| 294 | } |
| 295 | |
| 296 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 297 | |
| 298 | expect(answers).toEqual(['queued', 'queued', 'queued']) |
| 299 | |
| 300 | expect( |
| 301 | Fixtures.rowsOf(session), |
| 302 | 'one batch, the feature events by their own names, no plugin prefix', |
| 303 | ).toMatchObject([ |
| 304 | { |
| 305 | event_name: 'tengu_feature_ok', |
| 306 | metadata: { feature_name: 'learn_page' }, |
| 307 | }, |
| 308 | { |
| 309 | event_name: 'tengu_feature_sad', |
| 310 | metadata: { feature_name: 'learn_page', error_code: 'blocked' }, |
| 311 | }, |
| 312 | { |
| 313 | event_name: 'tengu_feature_bad', |
| 314 | metadata: { |
| 315 | feature_name: 'suggest_learning', |
| 316 | error_code: 'api_error', |
| 317 | }, |
| 318 | }, |
| 319 | ]) |
| 320 | }, |
| 321 | ) |
| 322 | |
| 323 | test( |
| 324 | 'a mark with a bad kind or a wrong reason is refused, nothing queued', |
| 325 | { plugins: [Fixtures.marking] }, |
| 326 | async ($, on) => { |
| 327 | mock.env(on, Fixtures.SENDING_ENV) |
| 328 | |
| 329 | const session = Fixtures.firstPartySession(on) |
| 330 | |
| 331 | const refusalFor = async (entry: unknown) => |
| 332 | (await $.command.run(Fixtures.mark(entry))).text |
| 333 | |
| 334 | expect( |
| 335 | await refusalFor({ feature: 'learn_page', kind: 'meh' }), |
| 336 | ).toEndWith("$.telemetry.mark: kind: 'ok', 'sad' or 'bad'") |
| 337 | |
| 338 | expect( |
| 339 | await refusalFor({ feature: 'learn_page', kind: 'bad' }), |
| 340 | ).toEndWith( |
| 341 | '$.telemetry.mark: reason: a bad mark names why, a snake_case token', |
| 342 | ) |
| 343 | |
| 344 | expect( |
| 345 | await refusalFor({ feature: 'learn_page', kind: 'ok', reason: 'x' }), |
| 346 | ).toEndWith('$.telemetry.mark: reason: an ok mark carries none') |
| 347 | |
| 348 | expect(await refusalFor({ feature: 'Learn Page', kind: 'ok' })).toEndWith( |
| 349 | '$.telemetry.mark: takes a feature name, a snake_case token', |
| 350 | ) |
| 351 | |
| 352 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 353 | |
| 354 | expect(session.posts).toEqual([]) |
| 355 | }, |
| 356 | ) |
| 357 | |
| 358 | test( |
| 359 | 'with no first-party credential nothing is sent, and the debug log says so', |
| 360 | { plugins: [Fixtures.recording] }, |
| 361 | async ($, on) => { |
| 362 | mock.env(on, Fixtures.SENDING_ENV) |
| 363 | |
| 364 | const session = Fixtures.firstPartySession(on, { |
| 365 | authorizations: [null], |
| 366 | }) |
| 367 | |
| 368 | const { text } = await $.command.run( |
| 369 | Fixtures.record(Fixtures.surveyAnswer()), |
| 370 | ) |
| 371 | |
| 372 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 373 | |
| 374 | expect({ text, posts: session.posts }).toEqual({ |
| 375 | text: 'queued', |
| 376 | posts: [], |
| 377 | }) |
| 378 | |
| 379 | expect(session.lines.at(-1)).toBe( |
| 380 | 'telemetry: 1 row(s) not sent: this session has no first-party ' + |
| 381 | 'credential to authorize', |
| 382 | ) |
| 383 | }, |
| 384 | ) |
| 385 | |
| 386 | test( |
| 387 | 'a server error is tried once more; a second one drops the batch', |
| 388 | { plugins: [Fixtures.recording] }, |
| 389 | async ($, on) => { |
| 390 | mock.env(on, Fixtures.SENDING_ENV) |
| 391 | |
| 392 | const session = Fixtures.firstPartySession(on, { |
| 393 | answers: [Fixtures.REFUSED, Fixtures.REFUSED], |
| 394 | }) |
| 395 | |
| 396 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 397 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 398 | await session.clock.advance(Hooks.RETRY_DELAY_MS) |
| 399 | |
| 400 | expect(session.posts).toHaveLength(2) |
| 401 | |
| 402 | expect(session.lines.at(-1)).toBe( |
| 403 | 'telemetry: 1 row(s) not sent: the ingest answered 500', |
| 404 | ) |
| 405 | }, |
| 406 | ) |
| 407 | |
| 408 | test( |
| 409 | 'a server error then an acceptance sends the batch', |
| 410 | { plugins: [Fixtures.recording] }, |
| 411 | async ($, on) => { |
| 412 | mock.env(on, Fixtures.SENDING_ENV) |
| 413 | |
| 414 | const session = Fixtures.firstPartySession(on, { |
| 415 | answers: [Fixtures.REFUSED, Fixtures.ACCEPTED], |
| 416 | }) |
| 417 | |
| 418 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 419 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 420 | await session.clock.advance(Hooks.RETRY_DELAY_MS) |
| 421 | |
| 422 | expect(session.posts.map(post => post.init?.body)).toEqual([ |
| 423 | session.posts[0]?.init?.body, |
| 424 | session.posts[0]?.init?.body, |
| 425 | ]) |
| 426 | |
| 427 | expect(session.lines.at(-1)).toBe('telemetry: sent 1 row(s)') |
| 428 | }, |
| 429 | ) |
| 430 | |
| 431 | test( |
| 432 | 'a client error is not retried', |
| 433 | { plugins: [Fixtures.recording] }, |
| 434 | async ($, on) => { |
| 435 | mock.env(on, Fixtures.SENDING_ENV) |
| 436 | |
| 437 | const session = Fixtures.firstPartySession(on, { |
| 438 | answers: [Fixtures.REJECTED], |
| 439 | }) |
| 440 | |
| 441 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 442 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 443 | await session.clock.advance(Hooks.RETRY_DELAY_MS) |
| 444 | |
| 445 | expect(session.posts).toHaveLength(1) |
| 446 | |
| 447 | expect(session.lines.at(-1)).toBe( |
| 448 | 'telemetry: 1 row(s) not sent: the ingest answered 400', |
| 449 | ) |
| 450 | }, |
| 451 | ) |
| 452 | |
| 453 | test( |
| 454 | 'free text and a malformed entry are refused, nothing queued', |
| 455 | { plugins: [Fixtures.recording] }, |
| 456 | async ($, on) => { |
| 457 | mock.env(on, Fixtures.SENDING_ENV) |
| 458 | |
| 459 | const session = Fixtures.firstPartySession(on) |
| 460 | |
| 461 | const refusalFor = async (entry: unknown) => |
| 462 | ( |
| 463 | await $.command.run({ |
| 464 | ...Fixtures.record(Fixtures.surveyAnswer()), |
| 465 | args: JSON.stringify(entry), |
| 466 | }) |
| 467 | ).text |
| 468 | |
| 469 | expect( |
| 470 | await refusalFor({ event: 'x', props: { note: 'hello world' } }), |
| 471 | ).toEndWith( |
| 472 | '$.telemetry.log: props.note: free text is refused; a string is a ' + |
| 473 | 'Choice, { value, of: [...] }', |
| 474 | ) |
| 475 | |
| 476 | expect( |
| 477 | await refusalFor({ |
| 478 | event: 'x', |
| 479 | props: { page: { value: 'elsewhere', of: ['ready', 'later'] } }, |
| 480 | }), |
| 481 | ).toEndWith( |
| 482 | '$.telemetry.log: props.page.value: one of the members of `of`', |
| 483 | ) |
| 484 | |
| 485 | expect(await refusalFor({ event: 'Survey' })).toEndWith( |
| 486 | '$.telemetry.log: takes an event name, a snake_case token', |
| 487 | ) |
| 488 | |
| 489 | expect( |
| 490 | await refusalFor({ event: 'x', props: { 'a path': 1 } }), |
| 491 | ).toEndWith('$.telemetry.log: props: every key is a snake_case token') |
| 492 | |
| 493 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 494 | |
| 495 | expect(session.posts).toEqual([]) |
| 496 | }, |
| 497 | ) |
| 498 | |
| 499 | test( |
| 500 | 'a refused entry is denied by the hook, naming the caller and the reason', |
| 501 | { plugins: [Fixtures.recording, Fixtures.marking] }, |
| 502 | async ($, on) => { |
| 503 | mock.env(on, Fixtures.SENDING_ENV) |
| 504 | |
| 505 | const session = Fixtures.firstPartySession(on) |
| 506 | |
| 507 | const logged = ( |
| 508 | await $.command.run(Fixtures.typed('record', { event: 'Survey' })) |
| 509 | ).text |
| 510 | |
| 511 | const marked = ( |
| 512 | await $.command.run( |
| 513 | Fixtures.typed('mark', { feature: 'learn_page', kind: 'meh' }), |
| 514 | ) |
| 515 | ).text |
| 516 | |
| 517 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 518 | |
| 519 | expect({ logged, marked, posts: session.posts }).toEqual({ |
| 520 | logged: |
| 521 | 'HooksError: recording: $.telemetry.log: takes an event name, a ' + |
| 522 | 'snake_case token', |
| 523 | marked: |
| 524 | "HooksError: marking: $.telemetry.mark: kind: 'ok', 'sad' or 'bad'", |
| 525 | posts: [], |
| 526 | }) |
| 527 | }, |
| 528 | ) |
| 529 | |
| 530 | test( |
| 531 | 'a number that is not finite is refused, nothing queued', |
| 532 | { |
| 533 | plugins: [ |
| 534 | { |
| 535 | name: 'counting', |
| 536 | tier: 'builtin', |
| 537 | register(on) { |
| 538 | on('command.run', { command: 'count' }, $ => |
| 539 | $.telemetry.log({ event: 'x', props: { n: Number.NaN } }).then( |
| 540 | () => ({ text: 'queued' }), |
| 541 | (error: unknown) => ({ text: String(error) }), |
| 542 | ), |
| 543 | ) |
| 544 | }, |
| 545 | }, |
| 546 | ], |
| 547 | }, |
| 548 | async ($, on) => { |
| 549 | mock.env(on, Fixtures.SENDING_ENV) |
| 550 | |
| 551 | const session = Fixtures.firstPartySession(on) |
| 552 | |
| 553 | const { text } = await $.command.run({ |
| 554 | command: 'count', |
| 555 | args: '', |
| 556 | origin: { kind: 'composer' }, |
| 557 | presentation: Fixtures.FULLSCREEN, |
| 558 | }) |
| 559 | |
| 560 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 561 | |
| 562 | expect(text).toEndWith('$.telemetry.log: props.n: a number is finite') |
| 563 | expect(session.posts).toEqual([]) |
| 564 | }, |
| 565 | ) |
| 566 | |
| 567 | test( |
| 568 | 'the config, the machine and the variables are read once a session', |
| 569 | { plugins: [Fixtures.recording] }, |
| 570 | async ($, on) => { |
| 571 | mock.env(on, Fixtures.SENDING_ENV) |
| 572 | |
| 573 | const session = Fixtures.firstPartySession(on) |
| 574 | |
| 575 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 576 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 577 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 578 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 579 | |
| 580 | expect({ |
| 581 | posts: session.posts.length, |
| 582 | configReads: session.reads.filter(path => path === Fixtures.CONFIG_PATH) |
| 583 | .length, |
| 584 | runs: session.runs.map(run => run.argv.slice(0, 2)), |
| 585 | }).toEqual({ posts: 2, configReads: 1, runs: [['sh', '-c']] }) |
| 586 | }, |
| 587 | ) |
| 588 | |
| 589 | test( |
| 590 | 'on Windows the platform is read off the environment and no program runs', |
| 591 | { plugins: [Fixtures.recording] }, |
| 592 | async ($, on) => { |
| 593 | mock.env(on, Fixtures.WINDOWS_ENV) |
| 594 | |
| 595 | const session = Fixtures.firstPartySession(on) |
| 596 | |
| 597 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 598 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 599 | |
| 600 | expect(Fixtures.rowsOf(session)[0]).toMatchObject({ |
| 601 | env: { |
| 602 | platform: 'win32', |
| 603 | platform_raw: 'win32', |
| 604 | arch: 'x64', |
| 605 | shell: 'cmd', |
| 606 | terminal: 'windows-terminal', |
| 607 | deployment_environment: 'unknown-win32', |
| 608 | }, |
| 609 | }) |
| 610 | |
| 611 | expect(session.runs).toEqual([]) |
| 612 | }, |
| 613 | ) |
| 614 | |
| 615 | test( |
| 616 | 'on Linux the distribution, kernel, WSL and EC2 come from their files', |
| 617 | { plugins: [Fixtures.recording] }, |
| 618 | async ($, on) => { |
| 619 | mock.env(on, { ...Fixtures.SENDING_ENV, WSL_DISTRO_NAME: 'Ubuntu' }) |
| 620 | |
| 621 | const session = Fixtures.firstPartySession(on, { |
| 622 | probeOutput: Fixtures.LINUX_PROBED, |
| 623 | files: Fixtures.LINUX_FILES, |
| 624 | existing: ['/.dockerenv'], |
| 625 | }) |
| 626 | |
| 627 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 628 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 629 | |
| 630 | expect(Fixtures.rowsOf(session)[0]).toMatchObject({ |
| 631 | env: { |
| 632 | platform: 'linux', |
| 633 | platform_raw: 'linux', |
| 634 | arch: 'x64', |
| 635 | terminal: 'ghostty', |
| 636 | package_managers: 'npm', |
| 637 | runtimes: 'node', |
| 638 | linux_distro_id: 'ubuntu', |
| 639 | linux_distro_version: '24.04', |
| 640 | linux_kernel: '6.6.87.2-microsoft-standard-WSL2', |
| 641 | wsl_version: '2', |
| 642 | deployment_environment: 'aws-ec2', |
| 643 | }, |
| 644 | }) |
| 645 | }, |
| 646 | ) |
| 647 | |
| 648 | test( |
| 649 | 'in a GitHub Actions run the client and the workflow ids ride along', |
| 650 | { plugins: [Fixtures.recording] }, |
| 651 | async ($, on) => { |
| 652 | mock.env(on, Fixtures.GITHUB_ACTION_ENV) |
| 653 | |
| 654 | const session = Fixtures.firstPartySession(on) |
| 655 | |
| 656 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| 657 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 658 | |
| 659 | expect(Fixtures.rowsOf(session)[0]).toMatchObject({ |
| 660 | client_type: 'github-action', |
| 661 | entrypoint: 'claude-code-github-action', |
| 662 | env: { |
| 663 | is_ci: true, |
| 664 | is_github_action: true, |
| 665 | is_claude_code_action: true, |
| 666 | deployment_environment: 'github-actions', |
| 667 | github_event_name: 'pull_request', |
| 668 | github_actions_runner_os: 'Linux', |
| 669 | github_actions_runner_environment: 'github-hosted', |
| 670 | github_action_ref: 'v1', |
| 671 | github_actions_metadata: { |
| 672 | actor_id: '11', |
| 673 | repository_id: '22', |
| 674 | repository_owner_id: '33', |
| 675 | }, |
| 676 | }, |
| 677 | }) |
| 678 | }, |
| 679 | ) |
| 680 | |
| 681 | test( |
| 682 | 'a failed authorize is not held against the next batch', |
| 683 | { plugins: [Fixtures.recording] }, |
| 684 | async ($, on) => { |
| 685 | mock.env(on, Fixtures.SENDING_ENV) |
| 686 | |
| 687 | const session = Fixtures.firstPartySession(on, { |
| 688 | authorizations: ['refused', Fixtures.BEARER], |
| 689 | }) |
| 690 | |
| 691 | await $.command.run(Fixtures.record({ event: 'x' })) |
| 692 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 693 | |
| 694 | const postsAfterFirst = session.posts.length |
| 695 | |
| 696 | await $.command.run(Fixtures.record({ event: 'x' })) |
| 697 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 698 | |
| 699 | expect({ postsAfterFirst, posts: session.posts.length }).toEqual({ |
| 700 | postsAfterFirst: 0, |
| 701 | posts: 1, |
| 702 | }) |
| 703 | |
| 704 | expect(session.lines[0]).toContain('the credential store is locked') |
| 705 | }, |
| 706 | ) |
| 707 | |
| 708 | test( |
| 709 | 'a plugin a person installed is refused, and its own hook carries on', |
| 710 | { plugins: [Fixtures.visiting] }, |
| 711 | async ($, on) => { |
| 712 | mock.env(on, Fixtures.SENDING_ENV) |
| 713 | |
| 714 | const session = Fixtures.firstPartySession(on) |
| 715 | |
| 716 | const { text } = await $.command.run( |
| 717 | Fixtures.typed('visit', Fixtures.surveyAnswer()), |
| 718 | ) |
| 719 | |
| 720 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 721 | |
| 722 | expect({ text, posts: session.posts }).toEqual({ |
| 723 | text: `HooksError: visiting: $.telemetry.log: ${Hooks.REFUSED.deny}`, |
| 724 | posts: [], |
| 725 | }) |
| 726 | }, |
| 727 | ) |
| 728 | |
| 729 | test( |
| 730 | 'a plugin an administrator prepended is refused too', |
| 731 | { plugins: [Fixtures.managing] }, |
| 732 | async ($, on) => { |
| 733 | mock.env(on, Fixtures.SENDING_ENV) |
| 734 | |
| 735 | const session = Fixtures.firstPartySession(on) |
| 736 | |
| 737 | const { text } = await $.command.run( |
| 738 | Fixtures.typed('manage', { feature: 'learn_page', kind: 'ok' }), |
| 739 | ) |
| 740 | |
| 741 | await session.clock.advance(Hooks.BATCH_WINDOW_MS) |
| 742 | |
| 743 | expect({ text, posts: session.posts }).toEqual({ |
| 744 | text: `HooksError: managing: $.telemetry.mark: ${Hooks.REFUSED.deny}`, |
| 745 | posts: [], |
| 746 | }) |
| 747 | }, |
| 748 | ) |
| 749 | |
| 750 | test('the gate serves built-ins and core, throws on no origin', () => { |
| 751 | expect(tiers.map(tier => Hooks.served({}, Fixtures.nextOf(tier)))).toEqual([ |
| 752 | 'served', |
| 753 | 'served', |
| 754 | Hooks.REFUSED, |
| 755 | Hooks.REFUSED, |
| 756 | Hooks.REFUSED, |
| 757 | ]) |
| 758 | |
| 759 | expect(() => Hooks.served({}, Fixtures.nextOf(undefined))).toThrow( |
| 760 | '$.telemetry: the call names no origin', |
| 761 | ) |
| 762 | }) |
| 763 | |
| 764 | test('a gate that threw refuses; a refusal from beneath passes up', () => { |
| 765 | expect(Hooks.caught({}, Fixtures.failingNext(false))).toEqual(Hooks.REFUSED) |
| 766 | |
| 767 | expect(() => Hooks.caught({}, Fixtures.failingNext(true))).toThrow( |
| 768 | 'beneath refused the entry', |
| 769 | ) |
| 770 | }) |
| 771 | }) |