@raidiant/notifai 7.0.2 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +2 -1
- package/dist/commands-devices.js +20 -8
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +85 -27
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +27 -0
- package/dist/commands-guidance.js +163 -0
- package/dist/commands-guidance.js.map +1 -0
- package/dist/commands-hooks.d.ts +16 -5
- package/dist/commands-hooks.js +433 -38
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +10 -18
- package/dist/commands-init.js +139 -54
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +66 -8
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +0 -1
- package/dist/commands-send.js +105 -39
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +8 -0
- package/dist/commands-setup-proof.js +31 -2
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +30 -14
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -5
- package/dist/config.js +37 -8
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +50 -0
- package/dist/guidance-content.js +240 -0
- package/dist/guidance-content.js.map +1 -0
- package/dist/guidance.d.ts +69 -0
- package/dist/guidance.js +173 -0
- package/dist/guidance.js.map +1 -0
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +68 -7
- package/dist/hooks.js +238 -92
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +14 -9
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +5 -487
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +14 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.d.ts +49 -0
- package/dist/program.js +576 -0
- package/dist/program.js.map +1 -0
- package/dist/send.d.ts +12 -10
- package/dist/send.js +65 -29
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +15 -0
- package/dist/session-activation.js +35 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/session-labels.d.ts +9 -3
- package/dist/session-labels.js +38 -7
- package/dist/session-labels.js.map +1 -1
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +6 -4
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-devices.ts +28 -9
- package/src/commands-doctor.ts +116 -25
- package/src/commands-guidance.ts +216 -0
- package/src/commands-hooks.ts +513 -42
- package/src/commands-init.ts +170 -60
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +72 -8
- package/src/commands-send.ts +123 -38
- package/src/commands-setup-proof.ts +36 -2
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1490 -197
- package/src/commands.ts +6 -0
- package/src/config-schema.ts +32 -16
- package/src/config.test.ts +57 -2
- package/src/config.ts +49 -10
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +256 -0
- package/src/guidance.test.ts +299 -0
- package/src/guidance.ts +233 -0
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +783 -170
- package/src/hooks.ts +306 -131
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +14 -9
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +6 -614
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +14 -1
- package/src/program.test.ts +237 -0
- package/src/program.ts +751 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +81 -34
- package/src/session-activation.ts +45 -0
- package/src/session-labels.test.ts +120 -3
- package/src/session-labels.ts +38 -7
- package/src/skill.test.ts +153 -12
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands.test.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
configUnsetCommand,
|
|
43
43
|
contradictingAnswer,
|
|
44
44
|
describeHookFailure,
|
|
45
|
+
devicesCommand,
|
|
45
46
|
doctorCommand,
|
|
46
47
|
EXIT,
|
|
47
48
|
hooksInstallCommand,
|
|
@@ -62,17 +63,26 @@ import {
|
|
|
62
63
|
import {
|
|
63
64
|
applyPlan,
|
|
64
65
|
buildHookConfig,
|
|
66
|
+
hookCommand,
|
|
65
67
|
codexHookIdentityHash,
|
|
66
68
|
codexTrustKey,
|
|
67
69
|
findInstallations,
|
|
70
|
+
handlerEvent,
|
|
71
|
+
settingsFile,
|
|
68
72
|
} from './install-hooks.js'
|
|
69
73
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
70
74
|
import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
|
|
71
75
|
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
|
|
72
|
-
import {
|
|
76
|
+
import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
|
|
77
|
+
import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
|
|
78
|
+
import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
73
79
|
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
74
80
|
import type { Tone } from './ui/theme.js'
|
|
75
81
|
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
resetLatestPublishedCliVersionForTest()
|
|
84
|
+
})
|
|
85
|
+
|
|
76
86
|
/**
|
|
77
87
|
* The release tag this build pins the agent skill to.
|
|
78
88
|
*
|
|
@@ -129,6 +139,17 @@ const currentCompatibility: CompatibilityResponse = {
|
|
|
129
139
|
replacement_available: false,
|
|
130
140
|
rollout_complete: false,
|
|
131
141
|
},
|
|
142
|
+
{
|
|
143
|
+
platform: 'android',
|
|
144
|
+
recommended_version: null,
|
|
145
|
+
recommended_build: null,
|
|
146
|
+
minimum_receive_build: null,
|
|
147
|
+
minimum_answer_build: null,
|
|
148
|
+
deprecation: null,
|
|
149
|
+
sunset: null,
|
|
150
|
+
replacement_available: false,
|
|
151
|
+
rollout_complete: false,
|
|
152
|
+
},
|
|
132
153
|
],
|
|
133
154
|
server_capabilities: ['answer', 'agent_acknowledgement'],
|
|
134
155
|
}
|
|
@@ -316,6 +337,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
316
337
|
hookAdapterHome: path.join(testRoot, 'home'),
|
|
317
338
|
cwd: os.tmpdir(),
|
|
318
339
|
clientFactory: () => withCompatibilityDefaults(client),
|
|
340
|
+
fetchImpl: async () => new Response('{}', { status: 503 }),
|
|
319
341
|
}
|
|
320
342
|
}
|
|
321
343
|
|
|
@@ -370,6 +392,8 @@ const reply: ReplyView = {
|
|
|
370
392
|
device_id: 'dev_test',
|
|
371
393
|
device_name: 'iPhone',
|
|
372
394
|
text: 'yes, after the migration',
|
|
395
|
+
answers: [],
|
|
396
|
+
source: null,
|
|
373
397
|
created_at: '2026-08-01T18:01:00.000Z',
|
|
374
398
|
}
|
|
375
399
|
|
|
@@ -453,6 +477,93 @@ describe('command contracts', () => {
|
|
|
453
477
|
expect(io.outLines[0]).toBe('macos capability contract v1 (payload limit 4096 bytes)')
|
|
454
478
|
})
|
|
455
479
|
|
|
480
|
+
it('passes the selected Android platform through to the capability client', async () => {
|
|
481
|
+
const io = new CapturedIo()
|
|
482
|
+
let requestedPlatform: string | undefined
|
|
483
|
+
const document = CAPABILITIES_V1.describe('android')!
|
|
484
|
+
const client = {
|
|
485
|
+
capabilities: async (platform?: string) => {
|
|
486
|
+
requestedPlatform = platform
|
|
487
|
+
return document
|
|
488
|
+
},
|
|
489
|
+
} as unknown as ApiClient
|
|
490
|
+
|
|
491
|
+
expect(await capabilitiesCommand(makeDeps(io, client), { platform: 'android' })).toBe(
|
|
492
|
+
EXIT.ok,
|
|
493
|
+
)
|
|
494
|
+
expect(requestedPlatform).toBe('android')
|
|
495
|
+
expect(io.outLines[0]).toBe(
|
|
496
|
+
'android capability contract v1 (payload limit 4096 bytes)',
|
|
497
|
+
)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('filters Device Installations by Android in human and JSON output', async () => {
|
|
501
|
+
const devices = [
|
|
502
|
+
{
|
|
503
|
+
device_id: 'dev_ios',
|
|
504
|
+
display_name: 'iPhone',
|
|
505
|
+
platform: 'ios' as const,
|
|
506
|
+
permission_status: 'authorized',
|
|
507
|
+
registration_healthy: true,
|
|
508
|
+
app_version: '1.0.0',
|
|
509
|
+
app_build: '1',
|
|
510
|
+
os_version: '19',
|
|
511
|
+
capabilities: ['answer'] as const,
|
|
512
|
+
support: currentSupport,
|
|
513
|
+
support_state: 'current' as const,
|
|
514
|
+
derived_status: 'working' as const,
|
|
515
|
+
status_message: null,
|
|
516
|
+
last_seen_at: null,
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
device_id: 'dev_android',
|
|
520
|
+
display_name: 'Pixel',
|
|
521
|
+
platform: 'android' as const,
|
|
522
|
+
permission_status: 'authorized',
|
|
523
|
+
registration_healthy: true,
|
|
524
|
+
app_version: '1.0.0',
|
|
525
|
+
app_build: '2',
|
|
526
|
+
os_version: '16',
|
|
527
|
+
capabilities: ['answer'] as const,
|
|
528
|
+
support: currentSupport,
|
|
529
|
+
support_state: 'current' as const,
|
|
530
|
+
derived_status: 'working' as const,
|
|
531
|
+
status_message: null,
|
|
532
|
+
last_seen_at: null,
|
|
533
|
+
},
|
|
534
|
+
]
|
|
535
|
+
const client = { listDevices: async () => ({ devices }) } as unknown as ApiClient
|
|
536
|
+
|
|
537
|
+
const human = new CapturedIo()
|
|
538
|
+
expect(
|
|
539
|
+
await devicesCommand(makeDeps(human, client), { platform: 'android' }),
|
|
540
|
+
).toBe(EXIT.ok)
|
|
541
|
+
expect(human.outLines).toEqual(['dev_android Pixel android Working'])
|
|
542
|
+
|
|
543
|
+
const json = new CapturedIo()
|
|
544
|
+
expect(
|
|
545
|
+
await devicesCommand(makeDeps(json, client), { platform: 'android', json: true }),
|
|
546
|
+
).toBe(EXIT.ok)
|
|
547
|
+
expect(JSON.parse(json.outLines[0]!)['devices']).toEqual([devices[1]])
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('rejects an unknown Device Installation platform filter locally', async () => {
|
|
551
|
+
const io = new CapturedIo()
|
|
552
|
+
let calls = 0
|
|
553
|
+
const client = {
|
|
554
|
+
listDevices: async () => {
|
|
555
|
+
calls += 1
|
|
556
|
+
return { devices: [] }
|
|
557
|
+
},
|
|
558
|
+
} as unknown as ApiClient
|
|
559
|
+
|
|
560
|
+
expect(await devicesCommand(makeDeps(io, client), { platform: 'linux' })).toBe(
|
|
561
|
+
EXIT.usage,
|
|
562
|
+
)
|
|
563
|
+
expect(calls).toBe(0)
|
|
564
|
+
expect(io.errLines.join('\n')).toContain('ios or macos or android')
|
|
565
|
+
})
|
|
566
|
+
|
|
456
567
|
it('rejects an invalid draft before calling submit', async () => {
|
|
457
568
|
const io = new CapturedIo()
|
|
458
569
|
let submitCalls = 0
|
|
@@ -474,6 +585,188 @@ describe('command contracts', () => {
|
|
|
474
585
|
expect(io.errLines.join('\n')).toContain('project')
|
|
475
586
|
})
|
|
476
587
|
|
|
588
|
+
it('prints the exact idempotency key needed after an ambiguous submit failure', async () => {
|
|
589
|
+
const io = new CapturedIo()
|
|
590
|
+
let attemptedKey = ''
|
|
591
|
+
const client = {
|
|
592
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
593
|
+
attemptedKey = body.idempotency_key
|
|
594
|
+
throw new NetworkError('Connection closed before a response arrived')
|
|
595
|
+
},
|
|
596
|
+
} as unknown as ApiClient
|
|
597
|
+
|
|
598
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
|
|
599
|
+
const deps = makeDeps(io, client)
|
|
600
|
+
deps.env = {
|
|
601
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
602
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
603
|
+
}
|
|
604
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send' })
|
|
605
|
+
expect(
|
|
606
|
+
await sendCommand(deps, {
|
|
607
|
+
kind: 'done',
|
|
608
|
+
title: 'Checks finished',
|
|
609
|
+
body: 'All checks passed.',
|
|
610
|
+
}),
|
|
611
|
+
).toBe(EXIT.network)
|
|
612
|
+
|
|
613
|
+
expect(attemptedKey).toMatch(/^cli-/)
|
|
614
|
+
expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
|
|
615
|
+
expect(
|
|
616
|
+
readLogRecords(deps.env, { event: ['send.attempt'] }).records.at(-1)?.data,
|
|
617
|
+
).toMatchObject({
|
|
618
|
+
idempotency_key: attemptedKey,
|
|
619
|
+
kind: 'done',
|
|
620
|
+
title_chars: 'Checks finished'.length,
|
|
621
|
+
session_id: null,
|
|
622
|
+
})
|
|
623
|
+
rmSync(root, { recursive: true, force: true })
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('prints the same generated key for an ambiguous server failure', async () => {
|
|
627
|
+
const io = new CapturedIo()
|
|
628
|
+
let attemptedKey = ''
|
|
629
|
+
const client = {
|
|
630
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
631
|
+
attemptedKey = body.idempotency_key
|
|
632
|
+
throw new ApiCallError(503, 'temporarily_unavailable', 'Try again later.')
|
|
633
|
+
},
|
|
634
|
+
} as unknown as ApiClient
|
|
635
|
+
|
|
636
|
+
expect(
|
|
637
|
+
await sendCommand(makeDeps(io, client), {
|
|
638
|
+
kind: 'failed',
|
|
639
|
+
title: 'Validation could not finish',
|
|
640
|
+
body: 'The service failed while accepting the result.',
|
|
641
|
+
}),
|
|
642
|
+
).toBe(EXIT.network)
|
|
643
|
+
expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it('routes a send with no active Companion devices into the setup coordinator', async () => {
|
|
647
|
+
const io = new CapturedIo()
|
|
648
|
+
let attemptedKey = ''
|
|
649
|
+
const client = {
|
|
650
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
651
|
+
attemptedKey = body.idempotency_key
|
|
652
|
+
throw new ApiCallError(
|
|
653
|
+
409,
|
|
654
|
+
'no_active_devices',
|
|
655
|
+
'No enabled active Device Installation can receive this request.',
|
|
656
|
+
'Open the Companion App and enable notifications.',
|
|
657
|
+
)
|
|
658
|
+
},
|
|
659
|
+
} as unknown as ApiClient
|
|
660
|
+
|
|
661
|
+
expect(
|
|
662
|
+
await sendCommand(makeDeps(io, client), {
|
|
663
|
+
kind: 'done',
|
|
664
|
+
title: 'Checks finished',
|
|
665
|
+
body: 'All checks passed.',
|
|
666
|
+
}),
|
|
667
|
+
).toBe(EXIT.failed)
|
|
668
|
+
|
|
669
|
+
expect(io.errLines.join('\n')).toMatch(/next:.*`notifai init`.*Companion/i)
|
|
670
|
+
expect(io.errLines.join('\n')).toContain(`--idempotency-key ${attemptedKey}`)
|
|
671
|
+
expect(io.errLines.join('\n')).toMatch(/verification request does not replace this Agent Event/i)
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
it('routes a send from an unpaired machine into the same setup coordinator', async () => {
|
|
675
|
+
const io = new CapturedIo()
|
|
676
|
+
const deps = makeDeps(io, {} as ApiClient)
|
|
677
|
+
deps.store.load = () => null
|
|
678
|
+
|
|
679
|
+
expect(
|
|
680
|
+
await sendCommand(deps, {
|
|
681
|
+
kind: 'done',
|
|
682
|
+
title: 'Checks finished',
|
|
683
|
+
body: 'All checks passed.',
|
|
684
|
+
}),
|
|
685
|
+
).toBe(EXIT.auth)
|
|
686
|
+
|
|
687
|
+
expect(io.errLines.join('\n')).toMatch(/`notifai init`.*login.*device setup/i)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('authors and validates an Android-specific Notification Request', async () => {
|
|
691
|
+
const io = new CapturedIo()
|
|
692
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
693
|
+
const client = {
|
|
694
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
695
|
+
submitted = body
|
|
696
|
+
return receipt
|
|
697
|
+
},
|
|
698
|
+
} as unknown as ApiClient
|
|
699
|
+
|
|
700
|
+
expect(
|
|
701
|
+
await sendCommand(makeDeps(io, client), {
|
|
702
|
+
kind: 'done',
|
|
703
|
+
title: 'Android build finished',
|
|
704
|
+
body: 'All checks passed.',
|
|
705
|
+
platform: 'android',
|
|
706
|
+
sound: 'none',
|
|
707
|
+
data: ['run_id=42'],
|
|
708
|
+
}),
|
|
709
|
+
).toBe(EXIT.ok)
|
|
710
|
+
expect(submitted?.draft.platform).toEqual({
|
|
711
|
+
android: { sound: null, custom_data: { run_id: '42' } },
|
|
712
|
+
})
|
|
713
|
+
expect(io.errLines).toEqual([])
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
it('prints Android downgrade warnings before submitting', async () => {
|
|
717
|
+
const io = new CapturedIo()
|
|
718
|
+
let submitCalls = 0
|
|
719
|
+
const client = {
|
|
720
|
+
submit: async () => {
|
|
721
|
+
submitCalls += 1
|
|
722
|
+
return receipt
|
|
723
|
+
},
|
|
724
|
+
} as unknown as ApiClient
|
|
725
|
+
|
|
726
|
+
expect(
|
|
727
|
+
await sendCommand(makeDeps(io, client), {
|
|
728
|
+
kind: 'update',
|
|
729
|
+
title: 'Android evidence',
|
|
730
|
+
body: 'See the attached result.',
|
|
731
|
+
platform: 'android',
|
|
732
|
+
image: ['med_result'],
|
|
733
|
+
threadId: 'android-results',
|
|
734
|
+
}),
|
|
735
|
+
).toBe(EXIT.ok)
|
|
736
|
+
expect(submitCalls).toBe(1)
|
|
737
|
+
expect(io.errLines).toEqual(
|
|
738
|
+
expect.arrayContaining([
|
|
739
|
+
expect.stringContaining('presentation.media'),
|
|
740
|
+
expect.stringContaining('platform.android.thread_id'),
|
|
741
|
+
]),
|
|
742
|
+
)
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
it('rejects an Android interruption level before submission', async () => {
|
|
746
|
+
const io = new CapturedIo()
|
|
747
|
+
let submitCalls = 0
|
|
748
|
+
const client = {
|
|
749
|
+
submit: async () => {
|
|
750
|
+
submitCalls += 1
|
|
751
|
+
return receipt
|
|
752
|
+
},
|
|
753
|
+
} as unknown as ApiClient
|
|
754
|
+
|
|
755
|
+
expect(
|
|
756
|
+
await sendCommand(makeDeps(io, client), {
|
|
757
|
+
kind: 'update',
|
|
758
|
+
title: 'Android update',
|
|
759
|
+
body: 'Ready.',
|
|
760
|
+
platform: 'android',
|
|
761
|
+
level: 'active',
|
|
762
|
+
}),
|
|
763
|
+
).toBe(EXIT.usage)
|
|
764
|
+
expect(submitCalls).toBe(0)
|
|
765
|
+
expect(io.errLines.join('\n')).toContain(
|
|
766
|
+
'Android does not support caller-selected interruption levels',
|
|
767
|
+
)
|
|
768
|
+
})
|
|
769
|
+
|
|
477
770
|
it('infers Project and exact Claude Source Context without printing the opaque id', async () => {
|
|
478
771
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'Notifai Project '))
|
|
479
772
|
const io = new CapturedIo()
|
|
@@ -557,6 +850,48 @@ describe('command contracts', () => {
|
|
|
557
850
|
})
|
|
558
851
|
})
|
|
559
852
|
|
|
853
|
+
it('uses the exact Orca worktree title for an active Codex session send', async () => {
|
|
854
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
|
|
855
|
+
const io = new CapturedIo()
|
|
856
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
857
|
+
const client = {
|
|
858
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
859
|
+
submitted = body
|
|
860
|
+
return receipt
|
|
861
|
+
},
|
|
862
|
+
} as unknown as ApiClient
|
|
863
|
+
const worktreeId = `repo-123::${cwd}`
|
|
864
|
+
const deps = {
|
|
865
|
+
...makeDeps(io, client),
|
|
866
|
+
cwd,
|
|
867
|
+
env: {
|
|
868
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
869
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
870
|
+
CODEX_THREAD_ID: 'orca-codex-thread',
|
|
871
|
+
TERM_PROGRAM: 'Orca',
|
|
872
|
+
ORCA_WORKTREE_ID: worktreeId,
|
|
873
|
+
},
|
|
874
|
+
orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
|
|
875
|
+
expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
|
|
876
|
+
return 'Worker - semantic session implementation'
|
|
877
|
+
},
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
expect(
|
|
881
|
+
await sendCommand(deps, {
|
|
882
|
+
title: 'Resolver implemented',
|
|
883
|
+
body: 'The worktree title belongs to every harness Orca starts in it.',
|
|
884
|
+
kind: 'done',
|
|
885
|
+
}),
|
|
886
|
+
).toBe(EXIT.ok)
|
|
887
|
+
|
|
888
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
889
|
+
session_id: 'orca-codex-thread',
|
|
890
|
+
session_label: 'Worker - semantic session implementation',
|
|
891
|
+
harness: 'codex',
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
|
|
560
895
|
it('falls back safely when Orca returns a private path as its title', async () => {
|
|
561
896
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-private-title-'))
|
|
562
897
|
const io = new CapturedIo()
|
|
@@ -801,7 +1136,7 @@ describe('command contracts', () => {
|
|
|
801
1136
|
})
|
|
802
1137
|
|
|
803
1138
|
it('keeps only the current public notification-authoring flags', () => {
|
|
804
|
-
const source = readFileSync(new URL('./
|
|
1139
|
+
const source = readFileSync(new URL('./program.ts', import.meta.url), 'utf8')
|
|
805
1140
|
expect(source).not.toContain(".option('--badge")
|
|
806
1141
|
expect(source).not.toContain(".option('--relevance")
|
|
807
1142
|
expect(source).not.toContain(".option('--target-content-id")
|
|
@@ -823,33 +1158,6 @@ describe('command contracts', () => {
|
|
|
823
1158
|
expect(source).not.toContain('Kind profiles apply automatically')
|
|
824
1159
|
})
|
|
825
1160
|
|
|
826
|
-
it('rejects a question nobody will wait for', async () => {
|
|
827
|
-
// --reply asks; --no-block declares nothing will wait. The answer would be
|
|
828
|
-
// captured server-side and then reachable only by hand, so the user taps a
|
|
829
|
-
// real button and nothing happens — worse than never asking, because it
|
|
830
|
-
// spends their attention and their trust in the channel.
|
|
831
|
-
const io = new CapturedIo()
|
|
832
|
-
let submitCalls = 0
|
|
833
|
-
const client = {
|
|
834
|
-
submit: async () => {
|
|
835
|
-
submitCalls += 1
|
|
836
|
-
return receipt
|
|
837
|
-
},
|
|
838
|
-
} as unknown as ApiClient
|
|
839
|
-
|
|
840
|
-
expect(
|
|
841
|
-
await sendCommand(makeDeps(io, client), {
|
|
842
|
-
title: 'Question',
|
|
843
|
-
body: 'Deploy?',
|
|
844
|
-
reply: true,
|
|
845
|
-
replyWindow: 3_600,
|
|
846
|
-
noBlock: true,
|
|
847
|
-
}),
|
|
848
|
-
).toBe(EXIT.usage)
|
|
849
|
-
expect(submitCalls).toBe(0)
|
|
850
|
-
expect(io.errLines.join('\n')).toContain('notifai ask')
|
|
851
|
-
})
|
|
852
|
-
|
|
853
1161
|
it('maps reply flags into the draft and waits for the answer', async () => {
|
|
854
1162
|
const io = new CapturedIo()
|
|
855
1163
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -907,7 +1215,7 @@ describe('command contracts', () => {
|
|
|
907
1215
|
|
|
908
1216
|
expect(await sendCommand(makeDeps(io, client), flags)).toBe(EXIT.ok)
|
|
909
1217
|
expect(io.errLines).toEqual([
|
|
910
|
-
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --
|
|
1218
|
+
'Heads up: this notification ends with a question but has no reply action. Add --reply (and optionally --choice) so it can be answered from the notification.',
|
|
911
1219
|
])
|
|
912
1220
|
})
|
|
913
1221
|
|
|
@@ -929,7 +1237,7 @@ describe('command contracts', () => {
|
|
|
929
1237
|
expect(io.errLines).toEqual([])
|
|
930
1238
|
})
|
|
931
1239
|
|
|
932
|
-
it('rejects --
|
|
1240
|
+
it('rejects --choice without the --reply action it configures', async () => {
|
|
933
1241
|
const io = new CapturedIo()
|
|
934
1242
|
let submitCalls = 0
|
|
935
1243
|
const client = {
|
|
@@ -943,12 +1251,12 @@ describe('command contracts', () => {
|
|
|
943
1251
|
await sendCommand(makeDeps(io, client), { kind: 'update',
|
|
944
1252
|
title: 'Deploy?',
|
|
945
1253
|
body: 'Choose when ready.',
|
|
946
|
-
|
|
1254
|
+
choice: ['Now', 'Later'],
|
|
947
1255
|
}),
|
|
948
1256
|
).toBe(EXIT.usage)
|
|
949
1257
|
expect(submitCalls).toBe(0)
|
|
950
1258
|
expect(io.errLines).toEqual([
|
|
951
|
-
'Use --reply with --reply-timeout, --reply-window,
|
|
1259
|
+
'Use --reply with --reply-timeout, --reply-window, or --choice.',
|
|
952
1260
|
])
|
|
953
1261
|
})
|
|
954
1262
|
|
|
@@ -1015,8 +1323,11 @@ describe('command contracts', () => {
|
|
|
1015
1323
|
request_id: receipt.request_id,
|
|
1016
1324
|
sequence: reply.seq,
|
|
1017
1325
|
device: reply.device_name,
|
|
1018
|
-
|
|
1326
|
+
text_chars: reply.text.length,
|
|
1327
|
+
answers_count: reply.answers.length,
|
|
1019
1328
|
})
|
|
1329
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
1330
|
+
expect(raw).not.toContain(reply.text)
|
|
1020
1331
|
})
|
|
1021
1332
|
|
|
1022
1333
|
it('backs off and retries a transient network error while waiting', async () => {
|
|
@@ -1148,13 +1459,14 @@ describe('command contracts', () => {
|
|
|
1148
1459
|
})
|
|
1149
1460
|
})
|
|
1150
1461
|
|
|
1151
|
-
it('prints
|
|
1462
|
+
it('prints one combined reply_result object on exit 3, nothing before the wait', async () => {
|
|
1152
1463
|
const io = new CapturedIo()
|
|
1153
1464
|
let now = 0
|
|
1154
1465
|
const client = {
|
|
1155
1466
|
submit: async () => receipt,
|
|
1156
1467
|
replies: async (_requestId: string, options: { waitSeconds: number }) => {
|
|
1157
|
-
|
|
1468
|
+
// Nothing on stdout mid-wait: the single object arrives at the end.
|
|
1469
|
+
expect(io.outLines).toHaveLength(0)
|
|
1158
1470
|
now += options.waitSeconds * 1_000
|
|
1159
1471
|
return replyResponse()
|
|
1160
1472
|
},
|
|
@@ -1170,11 +1482,12 @@ describe('command contracts', () => {
|
|
|
1170
1482
|
json: true,
|
|
1171
1483
|
}),
|
|
1172
1484
|
).toBe(EXIT.noReply)
|
|
1173
|
-
expect(io.outLines).toHaveLength(
|
|
1485
|
+
expect(io.outLines).toHaveLength(1)
|
|
1174
1486
|
// `degraded` is part of the shape on every reply wait, not only when it is
|
|
1175
1487
|
// true: an agent must be able to read it without knowing it might be absent.
|
|
1176
|
-
expect(JSON.parse(io.outLines[
|
|
1488
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1177
1489
|
type: 'reply_result',
|
|
1490
|
+
receipt,
|
|
1178
1491
|
request_id: receipt.request_id,
|
|
1179
1492
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
1180
1493
|
replies: [],
|
|
@@ -1189,7 +1502,7 @@ describe('command contracts', () => {
|
|
|
1189
1502
|
expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
|
|
1190
1503
|
})
|
|
1191
1504
|
|
|
1192
|
-
it('prints
|
|
1505
|
+
it('prints one combined reply_result object with the receipt when answered', async () => {
|
|
1193
1506
|
const io = new CapturedIo()
|
|
1194
1507
|
const client = {
|
|
1195
1508
|
submit: async () => receipt,
|
|
@@ -1205,10 +1518,10 @@ describe('command contracts', () => {
|
|
|
1205
1518
|
json: true,
|
|
1206
1519
|
}),
|
|
1207
1520
|
).toBe(EXIT.ok)
|
|
1208
|
-
expect(io.outLines).toHaveLength(
|
|
1209
|
-
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1210
|
-
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
1521
|
+
expect(io.outLines).toHaveLength(1)
|
|
1522
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1211
1523
|
type: 'reply_result',
|
|
1524
|
+
receipt,
|
|
1212
1525
|
request_id: receipt.request_id,
|
|
1213
1526
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
1214
1527
|
replies: [reply],
|
|
@@ -1220,6 +1533,41 @@ describe('command contracts', () => {
|
|
|
1220
1533
|
})
|
|
1221
1534
|
})
|
|
1222
1535
|
|
|
1536
|
+
it('still prints the one reply_result object when the wait itself faults', async () => {
|
|
1537
|
+
const io = new CapturedIo()
|
|
1538
|
+
const client = {
|
|
1539
|
+
submit: async () => receipt,
|
|
1540
|
+
replies: async () => {
|
|
1541
|
+
throw new ApiCallError(404, 'not_found', 'no such request')
|
|
1542
|
+
},
|
|
1543
|
+
} as unknown as ApiClient
|
|
1544
|
+
|
|
1545
|
+
expect(
|
|
1546
|
+
await sendCommand(makeDeps(io, client), {
|
|
1547
|
+
title: 'Question',
|
|
1548
|
+
body: 'Deploy?',
|
|
1549
|
+
reply: true,
|
|
1550
|
+
replyTimeout: 10,
|
|
1551
|
+
json: true,
|
|
1552
|
+
}),
|
|
1553
|
+
).toBe(EXIT.failed)
|
|
1554
|
+
expect(io.outLines).toHaveLength(1)
|
|
1555
|
+
// The send is durable even though the wait failed; `degraded: true` says
|
|
1556
|
+
// "could not find out", not "no answer".
|
|
1557
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1558
|
+
type: 'reply_result',
|
|
1559
|
+
receipt,
|
|
1560
|
+
request_id: receipt.request_id,
|
|
1561
|
+
replies: [],
|
|
1562
|
+
agent_acknowledgement_required: true,
|
|
1563
|
+
agent_acknowledgement_text_required: true,
|
|
1564
|
+
agent_acknowledgement: null,
|
|
1565
|
+
acknowledgement_command: null,
|
|
1566
|
+
degraded: true,
|
|
1567
|
+
})
|
|
1568
|
+
expect(io.errLines.join('\n')).toContain('reply wait failed')
|
|
1569
|
+
})
|
|
1570
|
+
|
|
1223
1571
|
it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
|
|
1224
1572
|
const io = new CapturedIo()
|
|
1225
1573
|
let submitted: { requestId: string; text: string } | undefined
|
|
@@ -1270,6 +1618,10 @@ describe('command contracts', () => {
|
|
|
1270
1618
|
'acknowledgement.attempted',
|
|
1271
1619
|
'acknowledgement.outcome',
|
|
1272
1620
|
])
|
|
1621
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
1622
|
+
expect(raw).not.toContain('I will deploy staging now.')
|
|
1623
|
+
expect(events[0]?.data).toMatchObject({ characters: 'I will deploy staging now.'.length })
|
|
1624
|
+
expect(events[1]?.data).toMatchObject({ text_chars: 'I will deploy staging now.'.length })
|
|
1273
1625
|
})
|
|
1274
1626
|
|
|
1275
1627
|
it('clears a matching active-session obligation after acknowledgement success', async () => {
|
|
@@ -1488,6 +1840,113 @@ describe('command contracts', () => {
|
|
|
1488
1840
|
})
|
|
1489
1841
|
})
|
|
1490
1842
|
|
|
1843
|
+
it('close --pending withdraws an unpushed registration so a later Stop cannot send it', async () => {
|
|
1844
|
+
const io = new CapturedIo()
|
|
1845
|
+
const client = {
|
|
1846
|
+
closeReplies: async () => {
|
|
1847
|
+
throw new Error('unpushed questions must not call the server')
|
|
1848
|
+
},
|
|
1849
|
+
} as unknown as ApiClient
|
|
1850
|
+
const deps = makeDeps(io, client)
|
|
1851
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-unpushed-'))
|
|
1852
|
+
deps.cwd = root
|
|
1853
|
+
deps.env = {
|
|
1854
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1855
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1856
|
+
}
|
|
1857
|
+
writeSessionState('close-unpushed', deps.env, {
|
|
1858
|
+
pending: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1859
|
+
})
|
|
1860
|
+
writeProjectSession(root, deps.env, 'close-unpushed', Date.now(), 'codex')
|
|
1861
|
+
|
|
1862
|
+
expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
|
|
1863
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1864
|
+
session_id: 'close-unpushed',
|
|
1865
|
+
withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1866
|
+
closed: [],
|
|
1867
|
+
})
|
|
1868
|
+
expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
|
|
1869
|
+
})
|
|
1870
|
+
|
|
1871
|
+
it('close withdraws one unpushed question by the ask id', async () => {
|
|
1872
|
+
const io = new CapturedIo()
|
|
1873
|
+
const client = {
|
|
1874
|
+
closeReplies: async () => {
|
|
1875
|
+
throw new Error('unpushed questions must not call the server')
|
|
1876
|
+
},
|
|
1877
|
+
} as unknown as ApiClient
|
|
1878
|
+
const deps = makeDeps(io, client)
|
|
1879
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-one-unpushed-'))
|
|
1880
|
+
deps.cwd = root
|
|
1881
|
+
deps.env = {
|
|
1882
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1883
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1884
|
+
}
|
|
1885
|
+
writeSessionState('close-one', deps.env, {
|
|
1886
|
+
pending: [
|
|
1887
|
+
{ question: 'Ship it?', question_id: 'q_local' },
|
|
1888
|
+
{ question: 'Keep this?', question_id: 'q_keep' },
|
|
1889
|
+
],
|
|
1890
|
+
})
|
|
1891
|
+
writeProjectSession(root, deps.env, 'close-one', Date.now(), 'codex')
|
|
1892
|
+
|
|
1893
|
+
expect(await closeCommand(deps, 'q_local', { json: true })).toBe(EXIT.ok)
|
|
1894
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1895
|
+
session_id: 'close-one',
|
|
1896
|
+
withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1897
|
+
closed: [],
|
|
1898
|
+
})
|
|
1899
|
+
expect(readSessionState('close-one', deps.env).pending?.map((entry) => entry.question_id)).toEqual([
|
|
1900
|
+
'q_keep',
|
|
1901
|
+
])
|
|
1902
|
+
})
|
|
1903
|
+
|
|
1904
|
+
it('close --pending closes live questions and leaves nothing for a later Stop to push', async () => {
|
|
1905
|
+
const io = new CapturedIo()
|
|
1906
|
+
const closed: string[] = []
|
|
1907
|
+
const client = {
|
|
1908
|
+
closeReplies: async (requestId: string) => {
|
|
1909
|
+
closed.push(requestId)
|
|
1910
|
+
return replyResponse([])
|
|
1911
|
+
},
|
|
1912
|
+
} as unknown as ApiClient
|
|
1913
|
+
const deps = makeDeps(io, client)
|
|
1914
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-pending-mix-'))
|
|
1915
|
+
deps.cwd = root
|
|
1916
|
+
deps.env = {
|
|
1917
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1918
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1919
|
+
}
|
|
1920
|
+
writeSessionState('close-mix', deps.env, {
|
|
1921
|
+
pending: [
|
|
1922
|
+
{ question: 'Unpushed?', question_id: 'q_local' },
|
|
1923
|
+
{
|
|
1924
|
+
question: 'Already on a device?',
|
|
1925
|
+
request_id: receipt.request_id,
|
|
1926
|
+
collapse_key: 'question-live',
|
|
1927
|
+
device_ids: ['dev_iphone'],
|
|
1928
|
+
},
|
|
1929
|
+
],
|
|
1930
|
+
})
|
|
1931
|
+
writeProjectSession(root, deps.env, 'close-mix', Date.now(), 'codex')
|
|
1932
|
+
|
|
1933
|
+
expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
|
|
1934
|
+
expect(closed).toEqual([receipt.request_id])
|
|
1935
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
|
|
1936
|
+
session_id: 'close-mix',
|
|
1937
|
+
withdrawn: [{ question: 'Unpushed?', question_id: 'q_local' }],
|
|
1938
|
+
closed: [receipt.request_id],
|
|
1939
|
+
})
|
|
1940
|
+
expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
|
|
1941
|
+
})
|
|
1942
|
+
|
|
1943
|
+
it('close requires a request id or --pending', async () => {
|
|
1944
|
+
const io = new CapturedIo()
|
|
1945
|
+
const client = {} as unknown as ApiClient
|
|
1946
|
+
expect(await closeCommand(makeDeps(io, client), undefined, {})).toBe(EXIT.usage)
|
|
1947
|
+
expect(io.errLines.join('\n')).toContain('Pass a request id or --pending.')
|
|
1948
|
+
})
|
|
1949
|
+
|
|
1491
1950
|
it('passes the replies cursor and prints replies for later retrieval', async () => {
|
|
1492
1951
|
const io = new CapturedIo()
|
|
1493
1952
|
let requested: { waitSeconds: number; afterSeq: number } | undefined
|
|
@@ -1566,8 +2025,9 @@ describe('command contracts', () => {
|
|
|
1566
2025
|
writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
|
|
1567
2026
|
|
|
1568
2027
|
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
|
|
1569
|
-
expect(io.outLines[0]).toBe('
|
|
1570
|
-
expect(io.outLines
|
|
2028
|
+
expect(io.outLines[0]).toBe('unpushed question: Not yet asked?')
|
|
2029
|
+
expect(io.outLines.join('\n')).toContain('pending request req_first')
|
|
2030
|
+
expect(io.outLines.join('\n')).toContain('yes, after the migration')
|
|
1571
2031
|
expect(io.outLines.join('\n')).toContain('req_second')
|
|
1572
2032
|
})
|
|
1573
2033
|
|
|
@@ -1764,13 +2224,188 @@ describe('command contracts', () => {
|
|
|
1764
2224
|
})
|
|
1765
2225
|
})
|
|
1766
2226
|
|
|
2227
|
+
describe('credential origin pinning', () => {
|
|
2228
|
+
it('never sends the machine bearer to a flag or env origin override', async () => {
|
|
2229
|
+
const io = new CapturedIo()
|
|
2230
|
+
const seen: { baseUrl: string; bearer: string | null }[] = []
|
|
2231
|
+
const client = {
|
|
2232
|
+
submit: async () => receipt,
|
|
2233
|
+
} as unknown as ApiClient
|
|
2234
|
+
const deps = makeDeps(io, client)
|
|
2235
|
+
deps.env = { ...deps.env, NOTIFAI_BASE_URL: 'https://attacker.example' }
|
|
2236
|
+
deps.clientFactory = (baseUrl, bearer) => {
|
|
2237
|
+
seen.push({ baseUrl, bearer })
|
|
2238
|
+
return withCompatibilityDefaults(client)
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
expect(
|
|
2242
|
+
await sendCommand(deps, {
|
|
2243
|
+
kind: 'update',
|
|
2244
|
+
title: 'T',
|
|
2245
|
+
body: 'B',
|
|
2246
|
+
baseUrl: 'https://attacker.flag',
|
|
2247
|
+
}),
|
|
2248
|
+
).toBe(EXIT.ok)
|
|
2249
|
+
|
|
2250
|
+
expect(seen).toEqual([
|
|
2251
|
+
{
|
|
2252
|
+
baseUrl: 'https://test.notifai.invalid',
|
|
2253
|
+
bearer: 'Bearer nfm_mac_test.test-secret',
|
|
2254
|
+
},
|
|
2255
|
+
])
|
|
2256
|
+
expect(io.errLines.join('\n')).toContain('Ignoring base_url from flag')
|
|
2257
|
+
expect(io.errLines.join('\n')).not.toContain('test-secret')
|
|
2258
|
+
expect(io.errLines.join('\n')).not.toContain('nfm_')
|
|
2259
|
+
expect(io.errLines.join('\n')).not.toContain('https://attacker.example')
|
|
2260
|
+
expect(io.errLines.join('\n')).not.toContain('https://attacker.flag')
|
|
2261
|
+
})
|
|
2262
|
+
|
|
2263
|
+
it('lets unsigned-in login target an origin override', async () => {
|
|
2264
|
+
const io = new CapturedIo()
|
|
2265
|
+
const seen: { baseUrl: string; bearer: string | null }[] = []
|
|
2266
|
+
let beginBody: Parameters<ApiClient['beginPairing']>[0] | null = null
|
|
2267
|
+
let now = 0
|
|
2268
|
+
const client = {
|
|
2269
|
+
beginPairing: async (body: Parameters<ApiClient['beginPairing']>[0]) => {
|
|
2270
|
+
beginBody = body
|
|
2271
|
+
return {
|
|
2272
|
+
pairing_id: 'pair_test',
|
|
2273
|
+
code: 'ABCD-EFGH',
|
|
2274
|
+
approve_url: 'https://selfhost.example/pair/ABCD-EFGH',
|
|
2275
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2276
|
+
poll_interval_seconds: 1,
|
|
2277
|
+
}
|
|
2278
|
+
},
|
|
2279
|
+
pollPairing: async () => ({ status: 'approved', machine_id: 'mac_new' }),
|
|
2280
|
+
} as unknown as ApiClient
|
|
2281
|
+
const saved: { baseUrl?: string } = {}
|
|
2282
|
+
const deps: CommandDeps = {
|
|
2283
|
+
...makeDeps(io, client),
|
|
2284
|
+
now: () => now,
|
|
2285
|
+
sleep: async (milliseconds: number) => {
|
|
2286
|
+
now += milliseconds
|
|
2287
|
+
},
|
|
2288
|
+
store: {
|
|
2289
|
+
load: () => null,
|
|
2290
|
+
save: (credential) => {
|
|
2291
|
+
saved.baseUrl = credential.baseUrl
|
|
2292
|
+
},
|
|
2293
|
+
clear: () => {},
|
|
2294
|
+
describe: () => 'test credential store',
|
|
2295
|
+
},
|
|
2296
|
+
clientFactory: (baseUrl, bearer) => {
|
|
2297
|
+
seen.push({ baseUrl, bearer })
|
|
2298
|
+
return withCompatibilityDefaults(client)
|
|
2299
|
+
},
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
expect(await loginCommand(deps, { baseUrl: 'https://selfhost.example', open: false })).toBe(
|
|
2303
|
+
EXIT.ok,
|
|
2304
|
+
)
|
|
2305
|
+
expect(seen[0]).toEqual({ baseUrl: 'https://selfhost.example', bearer: null })
|
|
2306
|
+
expect(saved.baseUrl).toBe('https://selfhost.example')
|
|
2307
|
+
expect(beginBody).toMatchObject({
|
|
2308
|
+
machine_name: expect.any(String),
|
|
2309
|
+
credential_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2310
|
+
poll_verifier_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2311
|
+
confirmation_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2312
|
+
})
|
|
2313
|
+
expect(io.outLines[1]).toMatch(
|
|
2314
|
+
/^Approve this machine at: https:\/\/selfhost\.example\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
2315
|
+
)
|
|
2316
|
+
})
|
|
2317
|
+
|
|
2318
|
+
it('stops login when the approving account has no product access', async () => {
|
|
2319
|
+
const io = new CapturedIo()
|
|
2320
|
+
let now = 0
|
|
2321
|
+
const client = {
|
|
2322
|
+
beginPairing: async () => ({
|
|
2323
|
+
pairing_id: 'pair_test',
|
|
2324
|
+
code: 'ABCD-EFGH',
|
|
2325
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2326
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2327
|
+
poll_interval_seconds: 1,
|
|
2328
|
+
}),
|
|
2329
|
+
pollPairing: async () => ({
|
|
2330
|
+
status: 'no_active_plan',
|
|
2331
|
+
next_action: 'Ask the account owner for Alpha access.',
|
|
2332
|
+
}),
|
|
2333
|
+
} as unknown as ApiClient
|
|
2334
|
+
const deps: CommandDeps = {
|
|
2335
|
+
...makeDeps(io, client),
|
|
2336
|
+
now: () => now,
|
|
2337
|
+
sleep: async (milliseconds: number) => {
|
|
2338
|
+
now += milliseconds
|
|
2339
|
+
},
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
2343
|
+
expect(io.errLines).toEqual([
|
|
2344
|
+
'This account has no active plan or temporary Alpha access.',
|
|
2345
|
+
'next: Ask the account owner for Alpha access.',
|
|
2346
|
+
'After access is granted, run `notifai login` again.',
|
|
2347
|
+
])
|
|
2348
|
+
})
|
|
2349
|
+
|
|
2350
|
+
it('keeps notification titles out of send logs', async () => {
|
|
2351
|
+
const io = new CapturedIo()
|
|
2352
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-log-'))
|
|
2353
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
2354
|
+
const deps = makeDeps(io, client)
|
|
2355
|
+
deps.env = {
|
|
2356
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
2357
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
2358
|
+
}
|
|
2359
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send', settings: { level: 'debug' } })
|
|
2360
|
+
|
|
2361
|
+
expect(
|
|
2362
|
+
await sendCommand(deps, {
|
|
2363
|
+
kind: 'update',
|
|
2364
|
+
title: 'SECRET_SEND_TITLE',
|
|
2365
|
+
body: 'SECRET_SEND_BODY',
|
|
2366
|
+
}),
|
|
2367
|
+
).toBe(EXIT.ok)
|
|
2368
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
2369
|
+
expect(raw).not.toContain('SECRET_SEND_TITLE')
|
|
2370
|
+
expect(raw).not.toContain('SECRET_SEND_BODY')
|
|
2371
|
+
const submitted = readLogRecords(deps.env, { event: ['send.submitted'] }).records
|
|
2372
|
+
expect(submitted[0]?.data).toMatchObject({
|
|
2373
|
+
request_id: receipt.request_id,
|
|
2374
|
+
title_chars: 'SECRET_SEND_TITLE'.length,
|
|
2375
|
+
})
|
|
2376
|
+
})
|
|
2377
|
+
|
|
2378
|
+
it('refuses a shell-escaped backslash-n body unless the literal flag is set', async () => {
|
|
2379
|
+
const io = new CapturedIo()
|
|
2380
|
+
expect(
|
|
2381
|
+
await sendCommand(makeDeps(io, {} as ApiClient), {
|
|
2382
|
+
kind: 'update',
|
|
2383
|
+
title: 'Escaped',
|
|
2384
|
+
body: 'line1\\nline2',
|
|
2385
|
+
}),
|
|
2386
|
+
).toBe(EXIT.usage)
|
|
2387
|
+
expect(io.errLines.join('\n')).toContain('--body-file -')
|
|
2388
|
+
expect(io.errLines.join('\n')).toContain('--literal-backslash-n')
|
|
2389
|
+
|
|
2390
|
+
io.errLines = []
|
|
2391
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
2392
|
+
expect(
|
|
2393
|
+
await sendCommand(makeDeps(io, client), {
|
|
2394
|
+
kind: 'update',
|
|
2395
|
+
title: 'Escaped',
|
|
2396
|
+
body: 'line1\\nline2',
|
|
2397
|
+
literalBackslashN: true,
|
|
2398
|
+
}),
|
|
2399
|
+
).toBe(EXIT.ok)
|
|
2400
|
+
})
|
|
2401
|
+
})
|
|
2402
|
+
|
|
1767
2403
|
describe('delivery evidence status', () => {
|
|
1768
2404
|
function snapshot(
|
|
1769
2405
|
companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
|
|
1770
2406
|
): EvidenceSnapshot {
|
|
1771
2407
|
return {
|
|
1772
2408
|
request_id: 'req_status_test',
|
|
1773
|
-
event: 'tests_passed',
|
|
1774
2409
|
accepted_at: '2026-08-05T13:05:48.000Z',
|
|
1775
2410
|
overall: 'provider_accepted_all',
|
|
1776
2411
|
deliveries: [
|
|
@@ -1866,6 +2501,53 @@ describe('delivery evidence status', () => {
|
|
|
1866
2501
|
expect(said).toContain('Reply received: yes')
|
|
1867
2502
|
expect(said).toContain('answers are on the server')
|
|
1868
2503
|
})
|
|
2504
|
+
|
|
2505
|
+
it('treats an ordinary status Companion Receipt as doctor delivery proof', async () => {
|
|
2506
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-status-proof-'))
|
|
2507
|
+
const evidence = snapshot({
|
|
2508
|
+
state: 'observed',
|
|
2509
|
+
observed_at: '2026-08-05T13:17:17.000Z',
|
|
2510
|
+
latency_ms: 1_000,
|
|
2511
|
+
})
|
|
2512
|
+
const device = {
|
|
2513
|
+
device_id: evidence.deliveries[0]!.device_id,
|
|
2514
|
+
display_name: 'iPhone',
|
|
2515
|
+
platform: 'ios' as const,
|
|
2516
|
+
permission_status: 'authorized',
|
|
2517
|
+
registration_healthy: true,
|
|
2518
|
+
app_version: '0.1.0',
|
|
2519
|
+
app_build: '42',
|
|
2520
|
+
os_version: '19.0',
|
|
2521
|
+
capabilities: ['answer'] as const,
|
|
2522
|
+
support: currentSupport,
|
|
2523
|
+
support_state: 'current' as const,
|
|
2524
|
+
derived_status: 'working' as const,
|
|
2525
|
+
status_message: null,
|
|
2526
|
+
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2527
|
+
}
|
|
2528
|
+
const client = {
|
|
2529
|
+
health: async () => true,
|
|
2530
|
+
listDevices: async () => ({ devices: [device] }),
|
|
2531
|
+
accessStatus: async () => ({
|
|
2532
|
+
status: 'active',
|
|
2533
|
+
reason: 'alpha_grant',
|
|
2534
|
+
expires_at: null,
|
|
2535
|
+
email: 'proof@example.com',
|
|
2536
|
+
}),
|
|
2537
|
+
evidence: async () => evidence,
|
|
2538
|
+
} as unknown as ApiClient
|
|
2539
|
+
const io = new CapturedIo()
|
|
2540
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
2541
|
+
|
|
2542
|
+
expect(await statusCommand(deps, evidence.request_id, { json: true })).toBe(EXIT.ok)
|
|
2543
|
+
|
|
2544
|
+
const doctorIo = new CapturedIo()
|
|
2545
|
+
expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.ok)
|
|
2546
|
+
const report = JSON.parse(doctorIo.outLines.join('\n')) as {
|
|
2547
|
+
states: { id: string; status: string }[]
|
|
2548
|
+
}
|
|
2549
|
+
expect(report.states.find((state) => state.id === 'proof')).toMatchObject({ status: 'ready' })
|
|
2550
|
+
})
|
|
1869
2551
|
})
|
|
1870
2552
|
|
|
1871
2553
|
describe('Cursor hook commands', () => {
|
|
@@ -1891,12 +2573,17 @@ describe('Cursor hook commands', () => {
|
|
|
1891
2573
|
expect(Object.keys(installed.hooks).sort()).toEqual([
|
|
1892
2574
|
'beforeSubmitPrompt',
|
|
1893
2575
|
'sessionEnd',
|
|
2576
|
+
'sessionStart',
|
|
1894
2577
|
'stop',
|
|
1895
2578
|
])
|
|
1896
2579
|
expect(installed.hooks['beforeSubmitPrompt']?.[0]?.command).toContain(
|
|
1897
2580
|
'hook user-prompt-submit --owner notifai --harness cursor',
|
|
1898
2581
|
)
|
|
1899
2582
|
expect(installed.hooks['stop']?.[0]).toMatchObject({
|
|
2583
|
+
command: expect.stringContaining('hook activation-stop --owner notifai --harness cursor'),
|
|
2584
|
+
loop_limit: 1,
|
|
2585
|
+
})
|
|
2586
|
+
expect(installed.hooks['stop']?.[1]).toMatchObject({
|
|
1900
2587
|
command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
|
|
1901
2588
|
loop_limit: 3,
|
|
1902
2589
|
})
|
|
@@ -1932,7 +2619,7 @@ describe('Cursor hook commands', () => {
|
|
|
1932
2619
|
expect(io.outLines).toContain(
|
|
1933
2620
|
`ok Question routing: cursor project (${path.join(cwd, '.cursor', 'hooks.json')})`,
|
|
1934
2621
|
)
|
|
1935
|
-
expect(io.outLines.some((line) => line.includes('Cursor:
|
|
2622
|
+
expect(io.outLines.some((line) => line.includes('Cursor: start one fresh conversation'))).toBe(true)
|
|
1936
2623
|
})
|
|
1937
2624
|
|
|
1938
2625
|
it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
|
|
@@ -2011,13 +2698,7 @@ describe('Codex hook representation', () => {
|
|
|
2011
2698
|
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
2012
2699
|
})
|
|
2013
2700
|
|
|
2014
|
-
|
|
2015
|
-
* The foreign `hooks.json` stays byte-identical and Notifai lands inline.
|
|
2016
|
-
* Codex will now warn that the layer uses both representations; the install
|
|
2017
|
-
* output says why, because that warning is Codex reporting a merge, not a
|
|
2018
|
-
* fault, and a silent one would read as Notifai's bug.
|
|
2019
|
-
*/
|
|
2020
|
-
it('leaves a foreign hooks.json alone and installs inline instead', () => {
|
|
2701
|
+
it('joins a populated hooks.json without introducing a second representation', () => {
|
|
2021
2702
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
|
|
2022
2703
|
const layer = path.join(cwd, '.codex')
|
|
2023
2704
|
const json = path.join(layer, 'hooks.json')
|
|
@@ -2035,17 +2716,12 @@ describe('Codex hook representation', () => {
|
|
|
2035
2716
|
|
|
2036
2717
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2037
2718
|
|
|
2038
|
-
const
|
|
2039
|
-
expect(
|
|
2040
|
-
expect(
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
expect(
|
|
2044
|
-
expect(after).toContain('--owner notifai')
|
|
2045
|
-
expect(io.outLines.join('\n')).toContain(toml)
|
|
2046
|
-
expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
|
|
2047
|
-
expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
|
|
2048
|
-
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
2719
|
+
const afterJson = readFileSync(json, 'utf8')
|
|
2720
|
+
expect(afterJson).toContain('gdh-stop')
|
|
2721
|
+
expect(afterJson).toContain('--owner notifai')
|
|
2722
|
+
expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
|
|
2723
|
+
expect(io.outLines.join('\n')).toContain(json)
|
|
2724
|
+
expect(io.outLines.join('\n')).not.toMatch(/both representations|not a Notifai fault/i)
|
|
2049
2725
|
})
|
|
2050
2726
|
|
|
2051
2727
|
it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
|
|
@@ -2062,6 +2738,50 @@ describe('Codex hook representation', () => {
|
|
|
2062
2738
|
expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
|
|
2063
2739
|
})
|
|
2064
2740
|
|
|
2741
|
+
it('leaves config.toml byte-identical on a no-op reinstall', () => {
|
|
2742
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-noop-install-'))
|
|
2743
|
+
const io = new CapturedIo()
|
|
2744
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2745
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2746
|
+
const toml = path.join(cwd, '.codex', 'config.toml')
|
|
2747
|
+
const before = readFileSync(toml, 'utf8')
|
|
2748
|
+
io.outLines = []
|
|
2749
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2750
|
+
expect(readFileSync(toml, 'utf8')).toBe(before)
|
|
2751
|
+
})
|
|
2752
|
+
|
|
2753
|
+
it('deletes an emptied hooks.json instead of leaving an empty residue', () => {
|
|
2754
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-empty-json-'))
|
|
2755
|
+
const layer = path.join(cwd, '.codex')
|
|
2756
|
+
const json = path.join(layer, 'hooks.json')
|
|
2757
|
+
mkdirSync(layer, { recursive: true })
|
|
2758
|
+
applyPlan(json, {
|
|
2759
|
+
hooks: {
|
|
2760
|
+
Stop: [{ hooks: [{ type: 'command', command: hookCommand(scriptPath, 'stop', 'codex') }] }],
|
|
2761
|
+
},
|
|
2762
|
+
})
|
|
2763
|
+
const io = new CapturedIo()
|
|
2764
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2765
|
+
expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
|
|
2766
|
+
expect(existsSync(json)).toBe(false)
|
|
2767
|
+
})
|
|
2768
|
+
|
|
2769
|
+
it('global install does not leave a dead project .codex after uninstalling that layer', () => {
|
|
2770
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-global-migrate-'))
|
|
2771
|
+
const io = new CapturedIo()
|
|
2772
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2773
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2774
|
+
expect(existsSync(path.join(cwd, '.codex'))).toBe(true)
|
|
2775
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath })).toBe(
|
|
2776
|
+
EXIT.ok,
|
|
2777
|
+
)
|
|
2778
|
+
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
|
|
2779
|
+
const leftover = existsSync(path.join(cwd, '.codex'))
|
|
2780
|
+
? readdirSync(path.join(cwd, '.codex'))
|
|
2781
|
+
: []
|
|
2782
|
+
expect(leftover.every((name) => name !== 'hooks.json')).toBe(true)
|
|
2783
|
+
})
|
|
2784
|
+
|
|
2065
2785
|
/**
|
|
2066
2786
|
* Notifai in one file, someone else's hooks in the other: every handler
|
|
2067
2787
|
* fires once, so doctor has nothing to report and the foreign file is not
|
|
@@ -2155,7 +2875,7 @@ describe('harness activation guidance', () => {
|
|
|
2155
2875
|
const execPath = process.execPath
|
|
2156
2876
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
2157
2877
|
|
|
2158
|
-
it('
|
|
2878
|
+
it('requires a fresh Claude Code session so SessionStart can activate it', () => {
|
|
2159
2879
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
|
|
2160
2880
|
const io = new CapturedIo()
|
|
2161
2881
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
@@ -2166,11 +2886,11 @@ describe('harness activation guidance', () => {
|
|
|
2166
2886
|
|
|
2167
2887
|
const output = io.outLines.join('\n')
|
|
2168
2888
|
expect(output).toContain('Installed claude-code hooks in')
|
|
2169
|
-
expect(output).toContain('
|
|
2889
|
+
expect(output).toContain('Start one fresh Claude Code session, send one prompt, then run `notifai doctor`.')
|
|
2170
2890
|
expect(output).not.toMatch(/timeout|asynchronous|600s/i)
|
|
2171
2891
|
})
|
|
2172
2892
|
|
|
2173
|
-
it('
|
|
2893
|
+
it('names Codex trust and fresh-session activation in the correct order', () => {
|
|
2174
2894
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
|
|
2175
2895
|
const io = new CapturedIo()
|
|
2176
2896
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
@@ -2180,8 +2900,8 @@ describe('harness activation guidance', () => {
|
|
|
2180
2900
|
)
|
|
2181
2901
|
|
|
2182
2902
|
const output = io.outLines.join('\n')
|
|
2183
|
-
expect(output).toContain('
|
|
2184
|
-
expect(output).
|
|
2903
|
+
expect(output).toContain('Approve the Notifai handlers in `/hooks` if Codex asks')
|
|
2904
|
+
expect(output).toMatch(/approve[\s\S]*start one fresh Codex session[\s\S]*`notifai doctor`/i)
|
|
2185
2905
|
})
|
|
2186
2906
|
|
|
2187
2907
|
it('names the installed harness in the close, never a different one', () => {
|
|
@@ -2193,7 +2913,7 @@ describe('harness activation guidance', () => {
|
|
|
2193
2913
|
|
|
2194
2914
|
const output = io.outLines.join('\n')
|
|
2195
2915
|
expect(output).toContain('Installed cursor hooks in')
|
|
2196
|
-
expect(output).toContain('
|
|
2916
|
+
expect(output).toContain('Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.')
|
|
2197
2917
|
expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
|
|
2198
2918
|
expect(output).not.toMatch(/timeout|worktree|fails closed/i)
|
|
2199
2919
|
})
|
|
@@ -2256,7 +2976,7 @@ describe('harness activation guidance', () => {
|
|
|
2256
2976
|
)
|
|
2257
2977
|
|
|
2258
2978
|
expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
|
|
2259
|
-
expect(io.outLines.join('\n')).toContain('
|
|
2979
|
+
expect(io.outLines.join('\n')).toContain('Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.')
|
|
2260
2980
|
expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
|
|
2261
2981
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
2262
2982
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
@@ -2401,6 +3121,141 @@ describe('stable hook installation', () => {
|
|
|
2401
3121
|
findInstallations(cwd, env, deps.hookAdapterHome).filter((item) => item.harness === 'codex'),
|
|
2402
3122
|
).toEqual([expect.objectContaining({ global: true })])
|
|
2403
3123
|
})
|
|
3124
|
+
|
|
3125
|
+
it('refreshes stale global lifecycle coverage instead of declining a project install', () => {
|
|
3126
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-stale-'))
|
|
3127
|
+
const io = new CapturedIo()
|
|
3128
|
+
const env = isolatedEnv(cwd)
|
|
3129
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3130
|
+
const execPath = process.execPath
|
|
3131
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3132
|
+
|
|
3133
|
+
expect(
|
|
3134
|
+
hooksInstallCommand(deps, {
|
|
3135
|
+
harness: 'cursor',
|
|
3136
|
+
global: true,
|
|
3137
|
+
execPath,
|
|
3138
|
+
scriptPath,
|
|
3139
|
+
}),
|
|
3140
|
+
).toBe(EXIT.ok)
|
|
3141
|
+
const globalFile = settingsFile('cursor', true, cwd, env)
|
|
3142
|
+
const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3143
|
+
hooks: { stop: Array<{ command?: string }> }
|
|
3144
|
+
}
|
|
3145
|
+
settings.hooks.stop = settings.hooks.stop.filter(
|
|
3146
|
+
(handler) => !handler.command?.includes(' hook activation-stop '),
|
|
3147
|
+
)
|
|
3148
|
+
writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
3149
|
+
|
|
3150
|
+
io.outLines = []
|
|
3151
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3152
|
+
expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
|
|
3153
|
+
const installation = findInstallations(cwd, env, deps.hookAdapterHome).find(
|
|
3154
|
+
(item) => item.harness === 'cursor' && item.global,
|
|
3155
|
+
)
|
|
3156
|
+
expect(installation?.handlers.map((handler) => handlerEvent(handler.command))).toContain(
|
|
3157
|
+
'activation-stop',
|
|
3158
|
+
)
|
|
3159
|
+
expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
|
|
3160
|
+
})
|
|
3161
|
+
|
|
3162
|
+
it('refreshes a stale global Stop shape instead of declining a project install', () => {
|
|
3163
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-shape-'))
|
|
3164
|
+
const io = new CapturedIo()
|
|
3165
|
+
const env = isolatedEnv(cwd)
|
|
3166
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3167
|
+
const execPath = process.execPath
|
|
3168
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3169
|
+
|
|
3170
|
+
expect(
|
|
3171
|
+
hooksInstallCommand(deps, {
|
|
3172
|
+
harness: 'cursor',
|
|
3173
|
+
global: true,
|
|
3174
|
+
execPath,
|
|
3175
|
+
scriptPath,
|
|
3176
|
+
}),
|
|
3177
|
+
).toBe(EXIT.ok)
|
|
3178
|
+
const globalFile = settingsFile('cursor', true, cwd, env)
|
|
3179
|
+
const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3180
|
+
hooks: { stop: Array<{ command?: string; timeout?: number }> }
|
|
3181
|
+
}
|
|
3182
|
+
const stop = settings.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))
|
|
3183
|
+
expect(stop).toBeDefined()
|
|
3184
|
+
stop!.timeout = 1
|
|
3185
|
+
writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
3186
|
+
|
|
3187
|
+
io.outLines = []
|
|
3188
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3189
|
+
expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
|
|
3190
|
+
const refreshed = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3191
|
+
hooks: { stop: Array<{ command?: string; timeout?: number }> }
|
|
3192
|
+
}
|
|
3193
|
+
expect(
|
|
3194
|
+
refreshed.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))?.timeout,
|
|
3195
|
+
).toBeGreaterThan(1)
|
|
3196
|
+
expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
|
|
3197
|
+
})
|
|
3198
|
+
|
|
3199
|
+
it('consolidates duplicate global Codex representations before declining a project install', () => {
|
|
3200
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-codex-duplicate-'))
|
|
3201
|
+
const io = new CapturedIo()
|
|
3202
|
+
const env = isolatedEnv(cwd)
|
|
3203
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3204
|
+
const execPath = process.execPath
|
|
3205
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3206
|
+
|
|
3207
|
+
expect(
|
|
3208
|
+
hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
|
|
3209
|
+
).toBe(EXIT.ok)
|
|
3210
|
+
const json = path.join(env.CODEX_HOME!, 'hooks.json')
|
|
3211
|
+
applyPlan(json, {
|
|
3212
|
+
hooks: {
|
|
3213
|
+
...buildHookConfig({ adapterPath: hookAdapterPath(deps.hookAdapterHome), harness: 'codex' }),
|
|
3214
|
+
PostToolUse: [{ hooks: [{ type: 'command', command: 'foreign-post-tool' }] }],
|
|
3215
|
+
},
|
|
3216
|
+
})
|
|
3217
|
+
|
|
3218
|
+
io.outLines = []
|
|
3219
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3220
|
+
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
3221
|
+
expect(readFileSync(json, 'utf8')).toContain('foreign-post-tool')
|
|
3222
|
+
expect(readFileSync(json, 'utf8')).not.toContain('--owner notifai')
|
|
3223
|
+
expect(
|
|
3224
|
+
findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
3225
|
+
(installation) => installation.harness === 'codex',
|
|
3226
|
+
),
|
|
3227
|
+
).toEqual([expect.objectContaining({ global: true })])
|
|
3228
|
+
expect(io.outLines.join('\n')).toMatch(/Installed codex hooks/)
|
|
3229
|
+
})
|
|
3230
|
+
|
|
3231
|
+
it('refreshes an obsolete global OpenCode plugin instead of treating empty event requirements as coverage', () => {
|
|
3232
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-opencode-stale-'))
|
|
3233
|
+
const io = new CapturedIo()
|
|
3234
|
+
const env = isolatedEnv(cwd)
|
|
3235
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3236
|
+
const execPath = process.execPath
|
|
3237
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3238
|
+
|
|
3239
|
+
expect(
|
|
3240
|
+
hooksInstallCommand(deps, {
|
|
3241
|
+
harness: 'opencode',
|
|
3242
|
+
global: true,
|
|
3243
|
+
execPath,
|
|
3244
|
+
scriptPath,
|
|
3245
|
+
}),
|
|
3246
|
+
).toBe(EXIT.ok)
|
|
3247
|
+
const globalFile = settingsFile('opencode', true, cwd, env)
|
|
3248
|
+
const current = readFileSync(globalFile, 'utf8')
|
|
3249
|
+
writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 10', 'const ADAPTER_VERSION = 8'))
|
|
3250
|
+
|
|
3251
|
+
io.outLines = []
|
|
3252
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3253
|
+
expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
|
|
3254
|
+
const refreshed = readFileSync(globalFile, 'utf8')
|
|
3255
|
+
expect(refreshed).toContain('const ADAPTER_VERSION = 10')
|
|
3256
|
+
expect(refreshed).toContain('experimental.chat.system.transform')
|
|
3257
|
+
expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
|
|
3258
|
+
})
|
|
2404
3259
|
})
|
|
2405
3260
|
|
|
2406
3261
|
describe('projectSlugFrom', () => {
|
|
@@ -2736,7 +3591,7 @@ describe('interactive command UX', () => {
|
|
|
2736
3591
|
beginPairing: async () => ({
|
|
2737
3592
|
pairing_id: 'pair_test',
|
|
2738
3593
|
code: 'ABCD-EFGH',
|
|
2739
|
-
approve_url: 'https://
|
|
3594
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2740
3595
|
expires_at: new Date(10_000).toISOString(),
|
|
2741
3596
|
poll_interval_seconds: 1,
|
|
2742
3597
|
}),
|
|
@@ -2766,7 +3621,9 @@ describe('interactive command UX', () => {
|
|
|
2766
3621
|
expect(io.notes).toEqual([
|
|
2767
3622
|
{
|
|
2768
3623
|
title: 'Approve this machine',
|
|
2769
|
-
message:
|
|
3624
|
+
message: expect.stringMatching(
|
|
3625
|
+
/^Code: ABCD-EFGH\nhttps:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
3626
|
+
),
|
|
2770
3627
|
},
|
|
2771
3628
|
])
|
|
2772
3629
|
expect(io.spinnerEvents).toEqual([
|
|
@@ -2785,7 +3642,7 @@ describe('interactive command UX', () => {
|
|
|
2785
3642
|
beginPairing: async () => ({
|
|
2786
3643
|
pairing_id: 'pair_test',
|
|
2787
3644
|
code: 'ABCD-EFGH',
|
|
2788
|
-
approve_url: 'https://
|
|
3645
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2789
3646
|
expires_at: new Date(10_000).toISOString(),
|
|
2790
3647
|
poll_interval_seconds: 1,
|
|
2791
3648
|
}),
|
|
@@ -2802,7 +3659,9 @@ describe('interactive command UX', () => {
|
|
|
2802
3659
|
expect(await loginCommand(deps, { open: false })).toBe(EXIT.ok)
|
|
2803
3660
|
expect(io.outLines.slice(0, 3)).toEqual([
|
|
2804
3661
|
'Pairing code: ABCD-EFGH',
|
|
2805
|
-
|
|
3662
|
+
expect.stringMatching(
|
|
3663
|
+
/^Approve this machine at: https:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
3664
|
+
),
|
|
2806
3665
|
'Waiting for approval…',
|
|
2807
3666
|
])
|
|
2808
3667
|
})
|
|
@@ -2943,6 +3802,26 @@ describe('interactive command UX', () => {
|
|
|
2943
3802
|
expect(io.intros).toEqual([])
|
|
2944
3803
|
expect(io.checks).toEqual([])
|
|
2945
3804
|
})
|
|
3805
|
+
|
|
3806
|
+
it('consults npm latest from an interactive doctor without failing the run', async () => {
|
|
3807
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-registry-'))
|
|
3808
|
+
const io = new InteractiveIo()
|
|
3809
|
+
const client = {
|
|
3810
|
+
health: async () => true,
|
|
3811
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3812
|
+
} as unknown as ApiClient
|
|
3813
|
+
const deps: CommandDeps = {
|
|
3814
|
+
...makeDeps(io, client),
|
|
3815
|
+
cwd,
|
|
3816
|
+
env: isolatedEnv(cwd),
|
|
3817
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
3818
|
+
fetchImpl: async () => new Response(JSON.stringify({ latest: '99.0.0' }), { status: 200 }),
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
3822
|
+
expect(io.outLines).toContain('A newer Notifai is available.')
|
|
3823
|
+
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
3824
|
+
})
|
|
2946
3825
|
})
|
|
2947
3826
|
|
|
2948
3827
|
describe('compatibility-first update guidance', () => {
|
|
@@ -3175,15 +4054,21 @@ describe('init', () => {
|
|
|
3175
4054
|
display_name: 'Mac',
|
|
3176
4055
|
platform: 'macos' as const,
|
|
3177
4056
|
}
|
|
4057
|
+
const readyAndroid = {
|
|
4058
|
+
...readyIphone,
|
|
4059
|
+
device_id: 'dev_android',
|
|
4060
|
+
display_name: 'Pixel',
|
|
4061
|
+
platform: 'android' as const,
|
|
4062
|
+
os_version: '16',
|
|
4063
|
+
}
|
|
3178
4064
|
|
|
3179
4065
|
function setupEvidence(
|
|
3180
4066
|
requestId: string,
|
|
3181
4067
|
companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
|
|
3182
|
-
device = readyIphone,
|
|
4068
|
+
device: typeof readyIphone | typeof readyAndroid = readyIphone,
|
|
3183
4069
|
): EvidenceSnapshot {
|
|
3184
4070
|
return {
|
|
3185
4071
|
request_id: requestId,
|
|
3186
|
-
event: 'setup_verified',
|
|
3187
4072
|
accepted_at: '2026-08-05T18:00:00.000Z',
|
|
3188
4073
|
overall: 'provider_accepted_all',
|
|
3189
4074
|
deliveries: [
|
|
@@ -3215,15 +4100,18 @@ describe('init', () => {
|
|
|
3215
4100
|
}
|
|
3216
4101
|
}
|
|
3217
4102
|
|
|
3218
|
-
function setupReceipt(
|
|
4103
|
+
function setupReceipt(
|
|
4104
|
+
requestId = 'req_setup',
|
|
4105
|
+
device: typeof readyIphone | typeof readyAndroid = readyIphone,
|
|
4106
|
+
): SubmissionReceipt {
|
|
3219
4107
|
return {
|
|
3220
4108
|
...receipt,
|
|
3221
4109
|
request_id: requestId,
|
|
3222
4110
|
deliveries: [
|
|
3223
4111
|
{
|
|
3224
4112
|
...receipt.deliveries[0]!,
|
|
3225
|
-
device_id:
|
|
3226
|
-
device_name:
|
|
4113
|
+
device_id: device.device_id,
|
|
4114
|
+
device_name: device.display_name,
|
|
3227
4115
|
},
|
|
3228
4116
|
],
|
|
3229
4117
|
}
|
|
@@ -3418,18 +4306,22 @@ describe('init', () => {
|
|
|
3418
4306
|
} as unknown as ApiClient
|
|
3419
4307
|
const nativeSkills: NativeSkills = {
|
|
3420
4308
|
add: async () => 0,
|
|
4309
|
+
remove: async () => 0,
|
|
3421
4310
|
list: async (scope) => ({
|
|
3422
|
-
skills:
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
4311
|
+
skills:
|
|
4312
|
+
scope === 'project'
|
|
4313
|
+
? [
|
|
4314
|
+
{
|
|
4315
|
+
name: 'notifai',
|
|
4316
|
+
scope,
|
|
4317
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
4318
|
+
source: 'Raidiant-io/notifai',
|
|
4319
|
+
sourceType: 'github',
|
|
4320
|
+
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
4321
|
+
ref: RELEASE_REF,
|
|
4322
|
+
},
|
|
4323
|
+
]
|
|
4324
|
+
: [],
|
|
3433
4325
|
}),
|
|
3434
4326
|
}
|
|
3435
4327
|
const readiness = await assessReadiness(
|
|
@@ -3455,51 +4347,242 @@ describe('init', () => {
|
|
|
3455
4347
|
const calls: SkillScope[] = []
|
|
3456
4348
|
const nativeSkills: NativeSkills = {
|
|
3457
4349
|
add: async () => 0,
|
|
4350
|
+
remove: async () => 0,
|
|
3458
4351
|
list: async (selected) => {
|
|
3459
4352
|
calls.push(selected)
|
|
3460
|
-
return { skills: [managedSkill(selected, cwd)] }
|
|
4353
|
+
return { skills: selected === scope ? [managedSkill(selected, cwd)] : [] }
|
|
3461
4354
|
},
|
|
3462
4355
|
}
|
|
3463
4356
|
|
|
3464
|
-
const readiness = await assessReadiness(
|
|
3465
|
-
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
3466
|
-
{ skillScope: scope },
|
|
3467
|
-
)
|
|
3468
|
-
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
3469
|
-
status: 'ready',
|
|
3470
|
-
detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
|
|
3471
|
-
})
|
|
3472
|
-
expect(calls).toEqual([
|
|
3473
|
-
},
|
|
3474
|
-
)
|
|
4357
|
+
const readiness = await assessReadiness(
|
|
4358
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
4359
|
+
{ skillScope: scope },
|
|
4360
|
+
)
|
|
4361
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4362
|
+
status: 'ready',
|
|
4363
|
+
detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
|
|
4364
|
+
})
|
|
4365
|
+
expect(calls).toEqual(['project', 'global'])
|
|
4366
|
+
},
|
|
4367
|
+
)
|
|
4368
|
+
|
|
4369
|
+
it.each(['project', 'global'] as const)(
|
|
4370
|
+
'does not trust unmanaged same-path content in the %s scope',
|
|
4371
|
+
async (scope) => {
|
|
4372
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-unmanaged-${scope}-`))
|
|
4373
|
+
const io = new CapturedIo()
|
|
4374
|
+
const client = {
|
|
4375
|
+
health: async () => true,
|
|
4376
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
4377
|
+
listDevices: async () => ({ devices: [] }),
|
|
4378
|
+
} as unknown as ApiClient
|
|
4379
|
+
const nativeSkills: NativeSkills = {
|
|
4380
|
+
add: async () => 0,
|
|
4381
|
+
remove: async () => 0,
|
|
4382
|
+
list: async (selected) => ({
|
|
4383
|
+
skills:
|
|
4384
|
+
selected === scope
|
|
4385
|
+
? [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }]
|
|
4386
|
+
: [],
|
|
4387
|
+
}),
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
const readiness = await assessReadiness(
|
|
4391
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
4392
|
+
{ skillScope: scope },
|
|
4393
|
+
)
|
|
4394
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4395
|
+
status: 'optional-gap',
|
|
4396
|
+
detail: `not installed from ${SKILLS_SOURCE} in ${scope} scope`,
|
|
4397
|
+
})
|
|
4398
|
+
},
|
|
4399
|
+
)
|
|
4400
|
+
|
|
4401
|
+
it('reports a duplicate notifai skill across project and global scope', async () => {
|
|
4402
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-duplicate-'))
|
|
4403
|
+
const io = new CapturedIo()
|
|
4404
|
+
const client = {
|
|
4405
|
+
health: async () => true,
|
|
4406
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
4407
|
+
listDevices: async () => ({ devices: [] }),
|
|
4408
|
+
} as unknown as ApiClient
|
|
4409
|
+
const nativeSkills: NativeSkills = {
|
|
4410
|
+
add: async () => 0,
|
|
4411
|
+
remove: async () => 0,
|
|
4412
|
+
list: async (scope) => ({
|
|
4413
|
+
skills: [
|
|
4414
|
+
{
|
|
4415
|
+
...managedSkill(scope, cwd),
|
|
4416
|
+
ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
|
|
4417
|
+
path:
|
|
4418
|
+
scope === 'project'
|
|
4419
|
+
? path.join(cwd, '.agents', 'skills', 'notifai')
|
|
4420
|
+
: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
|
|
4421
|
+
},
|
|
4422
|
+
],
|
|
4423
|
+
}),
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
const readiness = await assessReadiness({ ...makeDeps(io, client), cwd, nativeSkills })
|
|
4427
|
+
const skill = readiness.states.find((state) => state.id === 'skill')
|
|
4428
|
+
expect(skill).toMatchObject({
|
|
4429
|
+
status: 'gap',
|
|
4430
|
+
detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
4431
|
+
technical: {
|
|
4432
|
+
project: {
|
|
4433
|
+
ref: RELEASE_REF,
|
|
4434
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
4435
|
+
current: true,
|
|
4436
|
+
},
|
|
4437
|
+
global: {
|
|
4438
|
+
ref: 'v0.2.1',
|
|
4439
|
+
path: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
|
|
4440
|
+
current: false,
|
|
4441
|
+
},
|
|
4442
|
+
resolution: 'both-listed',
|
|
4443
|
+
},
|
|
4444
|
+
})
|
|
4445
|
+
|
|
4446
|
+
expect(await doctorCommand({ ...makeDeps(io, client), cwd, nativeSkills }, { json: true })).toBe(
|
|
4447
|
+
EXIT.failed,
|
|
4448
|
+
)
|
|
4449
|
+
const payload = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
4450
|
+
ok: boolean
|
|
4451
|
+
states: Array<{ id: string; status: string; detail: string }>
|
|
4452
|
+
}
|
|
4453
|
+
expect(payload.ok).toBe(false)
|
|
4454
|
+
expect(payload.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4455
|
+
status: 'gap',
|
|
4456
|
+
detail: expect.stringContaining('harness lists both'),
|
|
4457
|
+
})
|
|
4458
|
+
})
|
|
4459
|
+
|
|
4460
|
+
it('does not treat a current pin as ready when a stale duplicate is also installed', async () => {
|
|
4461
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-shadow-'))
|
|
4462
|
+
const io = new CapturedIo()
|
|
4463
|
+
const client = {
|
|
4464
|
+
health: async () => true,
|
|
4465
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
4466
|
+
listDevices: async () => ({ devices: [] }),
|
|
4467
|
+
} as unknown as ApiClient
|
|
4468
|
+
const nativeSkills: NativeSkills = {
|
|
4469
|
+
add: async () => 0,
|
|
4470
|
+
remove: async () => 0,
|
|
4471
|
+
list: async (scope) => ({
|
|
4472
|
+
skills: [
|
|
4473
|
+
{
|
|
4474
|
+
...managedSkill(scope, cwd),
|
|
4475
|
+
ref: scope === 'global' ? RELEASE_REF : 'v0.2.1',
|
|
4476
|
+
},
|
|
4477
|
+
],
|
|
4478
|
+
}),
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
const readiness = await assessReadiness(
|
|
4482
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
4483
|
+
{ skillScope: 'global' },
|
|
4484
|
+
)
|
|
4485
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4486
|
+
status: 'gap',
|
|
4487
|
+
detail: `project (v0.2.1) and global (${RELEASE_REF}) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
4488
|
+
})
|
|
4489
|
+
})
|
|
4490
|
+
|
|
4491
|
+
it('uninstalls the other skill scope before installing the chosen one', async () => {
|
|
4492
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-prevented-'))
|
|
4493
|
+
const io = new CapturedIo()
|
|
4494
|
+
const removed: SkillScope[] = []
|
|
4495
|
+
const added: Array<SkillScope | undefined> = []
|
|
4496
|
+
let globalPresent = true
|
|
4497
|
+
const nativeSkills: NativeSkills = {
|
|
4498
|
+
add: async (options) => {
|
|
4499
|
+
added.push(options.scope)
|
|
4500
|
+
return 0
|
|
4501
|
+
},
|
|
4502
|
+
remove: async (options) => {
|
|
4503
|
+
removed.push(options.scope)
|
|
4504
|
+
if (options.scope === 'global') globalPresent = false
|
|
4505
|
+
return 0
|
|
4506
|
+
},
|
|
4507
|
+
list: async (scope) => ({
|
|
4508
|
+
skills: scope === 'global' && globalPresent ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
|
|
4509
|
+
}),
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
expect(
|
|
4513
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4514
|
+
skills: true,
|
|
4515
|
+
setupScope: 'project',
|
|
4516
|
+
hooks: false,
|
|
4517
|
+
}),
|
|
4518
|
+
).toBe(EXIT.ok)
|
|
4519
|
+
expect(removed).toEqual(['global'])
|
|
4520
|
+
expect(added).toEqual(['project'])
|
|
4521
|
+
})
|
|
3475
4522
|
|
|
3476
|
-
it
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
}
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
4523
|
+
it('clears a pre-existing skill duplicate by keeping the chosen setup scope', async () => {
|
|
4524
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-clear-'))
|
|
4525
|
+
const io = new CapturedIo()
|
|
4526
|
+
const removed: SkillScope[] = []
|
|
4527
|
+
const present = new Set<SkillScope>(['project', 'global'])
|
|
4528
|
+
const nativeSkills: NativeSkills = {
|
|
4529
|
+
add: async (options) => {
|
|
4530
|
+
if (options.scope !== undefined) present.add(options.scope)
|
|
4531
|
+
return 0
|
|
4532
|
+
},
|
|
4533
|
+
remove: async (options) => {
|
|
4534
|
+
removed.push(options.scope)
|
|
4535
|
+
present.delete(options.scope)
|
|
4536
|
+
return 0
|
|
4537
|
+
},
|
|
4538
|
+
list: async (scope) => ({
|
|
4539
|
+
skills: present.has(scope)
|
|
4540
|
+
? [
|
|
4541
|
+
{
|
|
4542
|
+
...managedSkill(scope, cwd),
|
|
4543
|
+
ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
|
|
4544
|
+
},
|
|
4545
|
+
]
|
|
4546
|
+
: [],
|
|
4547
|
+
}),
|
|
4548
|
+
}
|
|
3492
4549
|
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
)
|
|
4550
|
+
expect(
|
|
4551
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4552
|
+
skills: true,
|
|
4553
|
+
setupScope: 'project',
|
|
4554
|
+
hooks: false,
|
|
4555
|
+
}),
|
|
4556
|
+
).toBe(EXIT.ok)
|
|
4557
|
+
expect(removed).toEqual(['global'])
|
|
4558
|
+
expect([...present]).toEqual(['project'])
|
|
4559
|
+
})
|
|
4560
|
+
|
|
4561
|
+
it('updates the existing skill scope instead of adding a second copy', async () => {
|
|
4562
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-update-existing-'))
|
|
4563
|
+
const io = new InteractiveIo()
|
|
4564
|
+
const received: Array<SkillScope | undefined> = []
|
|
4565
|
+
const installed = true
|
|
4566
|
+
const nativeSkills: NativeSkills = {
|
|
4567
|
+
add: async (options) => {
|
|
4568
|
+
received.push(options.scope)
|
|
4569
|
+
return 0
|
|
4570
|
+
},
|
|
4571
|
+
remove: async () => 0,
|
|
4572
|
+
list: async (scope) => ({
|
|
4573
|
+
skills: installed && scope === 'global' ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
|
|
4574
|
+
}),
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
expect(
|
|
4578
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4579
|
+
skills: true,
|
|
4580
|
+
hooks: false,
|
|
4581
|
+
}),
|
|
4582
|
+
).toBe(EXIT.ok)
|
|
4583
|
+
expect(received).toEqual(['global'])
|
|
4584
|
+
expect(io.outLines.join('\n')).toContain('global scope')
|
|
4585
|
+
})
|
|
3503
4586
|
|
|
3504
4587
|
it('requires an explicit skill scope before unattended installation', async () => {
|
|
3505
4588
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
|
|
@@ -3510,6 +4593,7 @@ describe('init', () => {
|
|
|
3510
4593
|
addCalls += 1
|
|
3511
4594
|
return 0
|
|
3512
4595
|
},
|
|
4596
|
+
remove: async () => 0,
|
|
3513
4597
|
list: async () => ({ skills: [] }),
|
|
3514
4598
|
}
|
|
3515
4599
|
|
|
@@ -3517,7 +4601,7 @@ describe('init', () => {
|
|
|
3517
4601
|
EXIT.usage,
|
|
3518
4602
|
)
|
|
3519
4603
|
expect(addCalls).toBe(0)
|
|
3520
|
-
expect(io.errLines.join('\n')).toContain('--
|
|
4604
|
+
expect(io.errLines.join('\n')).toContain('--setup-scope project')
|
|
3521
4605
|
})
|
|
3522
4606
|
|
|
3523
4607
|
it('rejects an invalid unattended skill scope instead of guessing', async () => {
|
|
@@ -3545,6 +4629,7 @@ describe('init', () => {
|
|
|
3545
4629
|
installed = true
|
|
3546
4630
|
return 0
|
|
3547
4631
|
},
|
|
4632
|
+
remove: async () => 0,
|
|
3548
4633
|
list: async (selected) => ({
|
|
3549
4634
|
skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
|
|
3550
4635
|
}),
|
|
@@ -3561,27 +4646,82 @@ describe('init', () => {
|
|
|
3561
4646
|
},
|
|
3562
4647
|
)
|
|
3563
4648
|
|
|
3564
|
-
it('
|
|
3565
|
-
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-
|
|
4649
|
+
it('asks one setup-scope question and passes that scope to the native installer', async () => {
|
|
4650
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-setup-scope-ask-'))
|
|
3566
4651
|
const io = new InteractiveIo()
|
|
4652
|
+
io.selectAnswer = 'project'
|
|
3567
4653
|
const calls: { submit: number } = { submit: 0 }
|
|
3568
|
-
let receivedScope: SkillScope | undefined
|
|
4654
|
+
let receivedScope: SkillScope | undefined
|
|
3569
4655
|
const nativeSkills: NativeSkills = {
|
|
3570
4656
|
add: async (options) => {
|
|
3571
4657
|
receivedScope = options.scope
|
|
3572
4658
|
return 0
|
|
3573
4659
|
},
|
|
4660
|
+
remove: async () => 0,
|
|
3574
4661
|
list: async () => ({ skills: [] }),
|
|
3575
4662
|
}
|
|
3576
4663
|
|
|
3577
4664
|
expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
|
|
3578
4665
|
EXIT.ok,
|
|
3579
4666
|
)
|
|
3580
|
-
expect(
|
|
4667
|
+
expect(io.prompts[0]).toContain('this project only, or to every project on this machine')
|
|
4668
|
+
expect(receivedScope).toBe('project')
|
|
3581
4669
|
expect(calls.submit).toBe(1)
|
|
3582
4670
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
3583
4671
|
})
|
|
3584
4672
|
|
|
4673
|
+
it('accepts --setup-scope as the unattended skill, hooks, and config scope', async () => {
|
|
4674
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-flag-'))
|
|
4675
|
+
const io = new CapturedIo()
|
|
4676
|
+
let receivedScope: SkillScope | undefined
|
|
4677
|
+
const nativeSkills: NativeSkills = {
|
|
4678
|
+
add: async (options) => {
|
|
4679
|
+
receivedScope = options.scope
|
|
4680
|
+
return 0
|
|
4681
|
+
},
|
|
4682
|
+
remove: async () => 0,
|
|
4683
|
+
list: async (scope) => ({
|
|
4684
|
+
skills: receivedScope === scope ? [managedSkill(scope, cwd)] : [],
|
|
4685
|
+
}),
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
expect(
|
|
4689
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4690
|
+
skills: true,
|
|
4691
|
+
setupScope: 'project',
|
|
4692
|
+
hooks: false,
|
|
4693
|
+
}),
|
|
4694
|
+
).toBe(EXIT.ok)
|
|
4695
|
+
expect(receivedScope).toBe('project')
|
|
4696
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(true)
|
|
4697
|
+
})
|
|
4698
|
+
|
|
4699
|
+
it('stamps project identity outside the repo when setup-scope is global', async () => {
|
|
4700
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-global-config-'))
|
|
4701
|
+
const io = new CapturedIo()
|
|
4702
|
+
const deps: CommandDeps = {
|
|
4703
|
+
...makeDeps(io, {} as ApiClient),
|
|
4704
|
+
cwd,
|
|
4705
|
+
env: isolatedEnv(cwd),
|
|
4706
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4707
|
+
}
|
|
4708
|
+
|
|
4709
|
+
expect(await initCommand(deps, { setupScope: 'global', skills: false, hooks: false })).toBe(EXIT.failed)
|
|
4710
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
|
|
4711
|
+
expect(readFileSync(personalProjectConfigPath(cwd, deps.env), 'utf8')).toMatch(/project = "/)
|
|
4712
|
+
})
|
|
4713
|
+
|
|
4714
|
+
it('rejects disagreeing --setup-scope and --skills-scope', async () => {
|
|
4715
|
+
const io = new CapturedIo()
|
|
4716
|
+
expect(
|
|
4717
|
+
await initCommand(
|
|
4718
|
+
{ ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-scope-conflict-')) },
|
|
4719
|
+
{ skills: true, setupScope: 'project', skillsScope: 'global' },
|
|
4720
|
+
),
|
|
4721
|
+
).toBe(EXIT.usage)
|
|
4722
|
+
expect(io.errLines.join('\n')).toContain('disagree')
|
|
4723
|
+
})
|
|
4724
|
+
|
|
3585
4725
|
it('installs every detected project harness when --hooks is set', async () => {
|
|
3586
4726
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
|
|
3587
4727
|
mkdirSync(path.join(cwd, '.claude'))
|
|
@@ -3590,6 +4730,7 @@ describe('init', () => {
|
|
|
3590
4730
|
const calls = { submit: 0 }
|
|
3591
4731
|
const nativeSkills: NativeSkills = {
|
|
3592
4732
|
add: async () => 0,
|
|
4733
|
+
remove: async () => 0,
|
|
3593
4734
|
list: async () => ({ skills: [] }),
|
|
3594
4735
|
}
|
|
3595
4736
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
@@ -3614,6 +4755,7 @@ describe('init', () => {
|
|
|
3614
4755
|
const calls = { submit: 0 }
|
|
3615
4756
|
const nativeSkills: NativeSkills = {
|
|
3616
4757
|
add: async () => 0,
|
|
4758
|
+
remove: async () => 0,
|
|
3617
4759
|
list: async () => ({ skills: [] }),
|
|
3618
4760
|
}
|
|
3619
4761
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
@@ -3631,6 +4773,7 @@ describe('init', () => {
|
|
|
3631
4773
|
const calls: { submit: number } = { submit: 0 }
|
|
3632
4774
|
const nativeSkills: NativeSkills = {
|
|
3633
4775
|
add: async () => 1,
|
|
4776
|
+
remove: async () => 0,
|
|
3634
4777
|
list: async () => ({ skills: [] }),
|
|
3635
4778
|
}
|
|
3636
4779
|
|
|
@@ -3735,7 +4878,9 @@ describe('init', () => {
|
|
|
3735
4878
|
expect(out).toContain('Next: Your devices')
|
|
3736
4879
|
expect(out).toContain('https://test.notifai.invalid/support')
|
|
3737
4880
|
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
3738
|
-
expect(out).toContain(
|
|
4881
|
+
expect(out).toContain(
|
|
4882
|
+
'no active Companion device registered yet; install Notifai via https://test.notifai.invalid/support',
|
|
4883
|
+
)
|
|
3739
4884
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3740
4885
|
})
|
|
3741
4886
|
|
|
@@ -3769,6 +4914,7 @@ describe('init', () => {
|
|
|
3769
4914
|
nativeSkills: {
|
|
3770
4915
|
list: async () => ({ skills: [] }),
|
|
3771
4916
|
add: async () => 0,
|
|
4917
|
+
remove: async () => 0,
|
|
3772
4918
|
} satisfies NativeSkills,
|
|
3773
4919
|
}
|
|
3774
4920
|
|
|
@@ -3784,7 +4930,7 @@ describe('init', () => {
|
|
|
3784
4930
|
})
|
|
3785
4931
|
|
|
3786
4932
|
it.each([
|
|
3787
|
-
['denied', '
|
|
4933
|
+
['denied', "the device's Settings"],
|
|
3788
4934
|
['not_determined', 'allow its notification prompt'],
|
|
3789
4935
|
])('gives one permission-specific next action for %s', async (permission, expected) => {
|
|
3790
4936
|
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
|
|
@@ -3804,12 +4950,12 @@ describe('init', () => {
|
|
|
3804
4950
|
|
|
3805
4951
|
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3806
4952
|
const out = io.outLines.join('\n')
|
|
3807
|
-
expect(out).toContain(`iPhone (${permission})`)
|
|
4953
|
+
expect(out).toContain(`iPhone (ios, ${permission})`)
|
|
3808
4954
|
expect(out).toContain(expected)
|
|
3809
4955
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3810
4956
|
})
|
|
3811
4957
|
|
|
3812
|
-
it('ignores a dormant Mac installation while waiting for an
|
|
4958
|
+
it('ignores a dormant Mac installation while waiting for an active Companion, then proves the iPhone receipt', async () => {
|
|
3813
4959
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
|
|
3814
4960
|
const io = new InteractiveIo()
|
|
3815
4961
|
let now = 0
|
|
@@ -3856,7 +5002,9 @@ describe('init', () => {
|
|
|
3856
5002
|
])
|
|
3857
5003
|
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
|
|
3858
5004
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
3859
|
-
expect(io.spinnerEvents).toContain(
|
|
5005
|
+
expect(io.spinnerEvents).toContain(
|
|
5006
|
+
'message:Waiting for a Companion App to sign in and register…',
|
|
5007
|
+
)
|
|
3860
5008
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
3861
5009
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
3862
5010
|
expect(io.outLines.join('\n')).toContain(
|
|
@@ -3864,7 +5012,7 @@ describe('init', () => {
|
|
|
3864
5012
|
)
|
|
3865
5013
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
3866
5014
|
expect(submitCalls).toBe(1)
|
|
3867
|
-
expect(submittedDraft?.draft
|
|
5015
|
+
expect(submittedDraft?.draft).not.toHaveProperty('event')
|
|
3868
5016
|
expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
|
|
3869
5017
|
|
|
3870
5018
|
io.outLines = []
|
|
@@ -4022,6 +5170,52 @@ describe('init', () => {
|
|
|
4022
5170
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
4023
5171
|
})
|
|
4024
5172
|
|
|
5173
|
+
it('replaces a stale unknown setup proof instead of rechecking it forever', async () => {
|
|
5174
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-stale-'))
|
|
5175
|
+
const io = new CapturedIo()
|
|
5176
|
+
let submitCalls = 0
|
|
5177
|
+
const client = {
|
|
5178
|
+
health: async () => true,
|
|
5179
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5180
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
5181
|
+
submit: async () => {
|
|
5182
|
+
submitCalls += 1
|
|
5183
|
+
return setupReceipt('req_fresh')
|
|
5184
|
+
},
|
|
5185
|
+
evidence: async (requestId: string) =>
|
|
5186
|
+
setupEvidence(
|
|
5187
|
+
requestId,
|
|
5188
|
+
requestId === 'req_fresh'
|
|
5189
|
+
? {
|
|
5190
|
+
state: 'observed',
|
|
5191
|
+
observed_at: '2026-08-25T00:00:02.000Z',
|
|
5192
|
+
latency_ms: 1_000,
|
|
5193
|
+
}
|
|
5194
|
+
: { state: 'unknown', observed_at: null, latency_ms: null },
|
|
5195
|
+
),
|
|
5196
|
+
} as unknown as ApiClient
|
|
5197
|
+
const now = Date.parse('2026-08-25T00:00:00.000Z')
|
|
5198
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
5199
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "stale-proof"\n')
|
|
5200
|
+
const deps: CommandDeps = {
|
|
5201
|
+
...makeDeps(io, client),
|
|
5202
|
+
cwd,
|
|
5203
|
+
env: isolatedEnv(cwd),
|
|
5204
|
+
now: () => now,
|
|
5205
|
+
}
|
|
5206
|
+
writeSetupProof(deps, {
|
|
5207
|
+
request_id: 'req_old',
|
|
5208
|
+
device_id: readyIphone.device_id,
|
|
5209
|
+
project: 'stale-proof',
|
|
5210
|
+
started_at: new Date(now - SETUP_PROOF_STALE_MS - 1).toISOString(),
|
|
5211
|
+
})
|
|
5212
|
+
|
|
5213
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
5214
|
+
expect(submitCalls).toBe(1)
|
|
5215
|
+
expect(io.outLines.join('\n')).toContain('older than 24h without a Companion Receipt')
|
|
5216
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5217
|
+
})
|
|
5218
|
+
|
|
4025
5219
|
it('reports a proof-state write failure instead of crashing or sending twice', async () => {
|
|
4026
5220
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-unwritable-'))
|
|
4027
5221
|
const io = new CapturedIo()
|
|
@@ -4047,6 +5241,51 @@ describe('init', () => {
|
|
|
4047
5241
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
4048
5242
|
})
|
|
4049
5243
|
|
|
5244
|
+
it('treats Android as active Companion readiness and sends a platform-correct proof', async () => {
|
|
5245
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-android-proof-'))
|
|
5246
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
5247
|
+
writeFileSync(
|
|
5248
|
+
path.join(cwd, '.notifai', 'config.toml'),
|
|
5249
|
+
'project = "android-project"\ninterruption_level = "passive"\n',
|
|
5250
|
+
)
|
|
5251
|
+
const io = new CapturedIo()
|
|
5252
|
+
let submitted: SubmitNotificationRequestT | null = null
|
|
5253
|
+
const client = {
|
|
5254
|
+
health: async () => true,
|
|
5255
|
+
listDevices: async () => ({ devices: [readyAndroid] }),
|
|
5256
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
5257
|
+
submitted = body
|
|
5258
|
+
return setupReceipt('req_android_setup', readyAndroid)
|
|
5259
|
+
},
|
|
5260
|
+
evidence: async (requestId: string) =>
|
|
5261
|
+
setupEvidence(
|
|
5262
|
+
requestId,
|
|
5263
|
+
{
|
|
5264
|
+
state: 'observed',
|
|
5265
|
+
observed_at: '2026-08-05T18:00:02.000Z',
|
|
5266
|
+
latency_ms: 1_000,
|
|
5267
|
+
},
|
|
5268
|
+
readyAndroid,
|
|
5269
|
+
),
|
|
5270
|
+
} as unknown as ApiClient
|
|
5271
|
+
const deps = {
|
|
5272
|
+
...makeDeps(io, client),
|
|
5273
|
+
cwd,
|
|
5274
|
+
env: isolatedEnv(cwd),
|
|
5275
|
+
}
|
|
5276
|
+
|
|
5277
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
5278
|
+
expect(submitted?.draft.targets).toEqual({
|
|
5279
|
+
mode: 'selected',
|
|
5280
|
+
device_ids: [readyAndroid.device_id],
|
|
5281
|
+
})
|
|
5282
|
+
expect(submitted?.draft.platform).toEqual({ android: { sound: null } })
|
|
5283
|
+
expect(io.outLines.join('\n')).toContain(
|
|
5284
|
+
"Companion Receipt (the app's delivery confirmation) observed from Pixel.",
|
|
5285
|
+
)
|
|
5286
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5287
|
+
})
|
|
5288
|
+
|
|
4050
5289
|
it('does not treat a macOS-only installation as active-release readiness', async () => {
|
|
4051
5290
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
|
|
4052
5291
|
const io = new CapturedIo()
|
|
@@ -4072,11 +5311,11 @@ describe('init', () => {
|
|
|
4072
5311
|
expect(submitCalls).toBe(0)
|
|
4073
5312
|
const out = io.outLines.join('\n')
|
|
4074
5313
|
expect(out).toContain('Next: Your devices')
|
|
4075
|
-
expect(out).toContain('no
|
|
5314
|
+
expect(out).toContain('no active Companion device registered yet')
|
|
4076
5315
|
expect(out).not.toContain('All set.')
|
|
4077
5316
|
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
4078
5317
|
|
|
4079
|
-
// A human doctor must render the same
|
|
5318
|
+
// A human doctor must render the same active-Companion readiness failure.
|
|
4080
5319
|
const doctorIo = new PlainInteractiveIo()
|
|
4081
5320
|
expect(
|
|
4082
5321
|
await doctorCommand(
|
|
@@ -4090,43 +5329,9 @@ describe('init', () => {
|
|
|
4090
5329
|
).toBe(EXIT.failed)
|
|
4091
5330
|
const doctorOut = doctorIo.outLines.join('\n')
|
|
4092
5331
|
expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
|
|
4093
|
-
expect(doctorOut).toContain('no
|
|
4094
|
-
expect(doctorOut).toContain(
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
it('stops login when the approval page reports no Alpha access', async () => {
|
|
4098
|
-
const io = new CapturedIo()
|
|
4099
|
-
let now = 0
|
|
4100
|
-
let polls = 0
|
|
4101
|
-
const client = {
|
|
4102
|
-
beginPairing: async () => ({
|
|
4103
|
-
pairing_id: 'pair_no_plan',
|
|
4104
|
-
code: 'NOPE-PLAN',
|
|
4105
|
-
approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
|
|
4106
|
-
expires_at: new Date(60_000).toISOString(),
|
|
4107
|
-
poll_interval_seconds: 1,
|
|
4108
|
-
}),
|
|
4109
|
-
pollPairing: async () => {
|
|
4110
|
-
polls += 1
|
|
4111
|
-
return {
|
|
4112
|
-
status: 'no_active_plan' as const,
|
|
4113
|
-
next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
4114
|
-
}
|
|
4115
|
-
},
|
|
4116
|
-
} as unknown as ApiClient
|
|
4117
|
-
const deps = {
|
|
4118
|
-
...makeDeps(io, client),
|
|
4119
|
-
now: () => now,
|
|
4120
|
-
sleep: async (milliseconds: number) => {
|
|
4121
|
-
now += milliseconds
|
|
4122
|
-
},
|
|
4123
|
-
}
|
|
4124
|
-
|
|
4125
|
-
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
4126
|
-
expect(polls).toBe(1)
|
|
4127
|
-
expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
|
|
4128
|
-
expect(io.errLines.join('\n')).toContain(
|
|
4129
|
-
'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
5332
|
+
expect(doctorOut).toContain('no active Companion device registered yet')
|
|
5333
|
+
expect(doctorOut).toContain(
|
|
5334
|
+
'Delivery proof: not checked — no iPhone or Android Companion App is ready',
|
|
4130
5335
|
)
|
|
4131
5336
|
})
|
|
4132
5337
|
|
|
@@ -4308,6 +5513,54 @@ describe('readiness assessment cost', () => {
|
|
|
4308
5513
|
detail: `installed from ${SKILLS_SOURCE} in the project scope`,
|
|
4309
5514
|
})
|
|
4310
5515
|
})
|
|
5516
|
+
|
|
5517
|
+
it('detects a lock-file duplicate across project and global scope', async () => {
|
|
5518
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-duplicate-'))
|
|
5519
|
+
const env = { ...isolatedEnv(cwd), PATH: '/nonexistent' }
|
|
5520
|
+
writeFileSync(
|
|
5521
|
+
path.join(cwd, 'skills-lock.json'),
|
|
5522
|
+
`${JSON.stringify({
|
|
5523
|
+
skills: {
|
|
5524
|
+
notifai: {
|
|
5525
|
+
source: 'Raidiant-io/notifai',
|
|
5526
|
+
sourceType: 'github',
|
|
5527
|
+
ref: RELEASE_REF,
|
|
5528
|
+
},
|
|
5529
|
+
},
|
|
5530
|
+
})}\n`,
|
|
5531
|
+
)
|
|
5532
|
+
mkdirSync(path.join(env.XDG_STATE_HOME!, 'skills'), { recursive: true })
|
|
5533
|
+
writeFileSync(
|
|
5534
|
+
path.join(env.XDG_STATE_HOME!, 'skills', '.skill-lock.json'),
|
|
5535
|
+
`${JSON.stringify({
|
|
5536
|
+
skills: {
|
|
5537
|
+
notifai: {
|
|
5538
|
+
source: 'Raidiant-io/notifai',
|
|
5539
|
+
sourceType: 'github',
|
|
5540
|
+
ref: 'v0.2.1',
|
|
5541
|
+
},
|
|
5542
|
+
},
|
|
5543
|
+
})}\n`,
|
|
5544
|
+
)
|
|
5545
|
+
const io = new CapturedIo()
|
|
5546
|
+
const client = {
|
|
5547
|
+
health: async () => true,
|
|
5548
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5549
|
+
listDevices: async () => ({ devices: [] }),
|
|
5550
|
+
} as unknown as ApiClient
|
|
5551
|
+
|
|
5552
|
+
const readiness = await assessReadiness({
|
|
5553
|
+
...makeDeps(io, client),
|
|
5554
|
+
cwd,
|
|
5555
|
+
env,
|
|
5556
|
+
nativeSkills: realNativeSkills,
|
|
5557
|
+
})
|
|
5558
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
5559
|
+
status: 'gap',
|
|
5560
|
+
detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
5561
|
+
technical: { resolution: 'both-listed' },
|
|
5562
|
+
})
|
|
5563
|
+
})
|
|
4311
5564
|
})
|
|
4312
5565
|
|
|
4313
5566
|
describe('an outage is not an answer', () => {
|
|
@@ -4387,7 +5640,8 @@ describe('an outage is not an answer', () => {
|
|
|
4387
5640
|
json: true,
|
|
4388
5641
|
})
|
|
4389
5642
|
|
|
4390
|
-
|
|
5643
|
+
expect(io.outLines).toHaveLength(1)
|
|
5644
|
+
const payload = JSON.parse(io.outLines[0] ?? '{}') as { degraded: boolean }
|
|
4391
5645
|
expect(payload.degraded).toBe(true)
|
|
4392
5646
|
})
|
|
4393
5647
|
|
|
@@ -4507,9 +5761,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4507
5761
|
io.outLines = []
|
|
4508
5762
|
|
|
4509
5763
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
4510
|
-
expect(io.outLines).
|
|
4511
|
-
'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
4512
|
-
)
|
|
5764
|
+
expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
|
|
4513
5765
|
expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
|
|
4514
5766
|
session_id: 'codex-current-thread',
|
|
4515
5767
|
harness: 'codex',
|
|
@@ -4568,7 +5820,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4568
5820
|
const pending = readSessionState('codex-media-thread', env).pending?.[0]
|
|
4569
5821
|
expect(uploaded).toEqual(['med_ask_1', 'med_ask_2'])
|
|
4570
5822
|
expect(pending?.body).toBe(
|
|
4571
|
-
'
|
|
5823
|
+
'Compare  with .',
|
|
4572
5824
|
)
|
|
4573
5825
|
expect(pending?.media).toEqual([
|
|
4574
5826
|
{ media_id: 'med_ask_1', alt: 'First option' },
|
|
@@ -4731,7 +5983,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4731
5983
|
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
4732
5984
|
expect(fired?.status).toBe('optional-gap')
|
|
4733
5985
|
expect(fired?.detail).toMatch(/Claude Code/)
|
|
4734
|
-
expect(fired?.detail).toMatch(/Codex:
|
|
5986
|
+
expect(fired?.detail).toMatch(/Codex: approve[\s\S]*start one fresh session/i)
|
|
4735
5987
|
|
|
4736
5988
|
io.errLines = []
|
|
4737
5989
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
@@ -4851,6 +6103,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4851
6103
|
} as unknown as ApiClient
|
|
4852
6104
|
const nativeSkills: NativeSkills = {
|
|
4853
6105
|
add: async () => 0,
|
|
6106
|
+
remove: async () => 0,
|
|
4854
6107
|
list: async (scope) => ({
|
|
4855
6108
|
skills:
|
|
4856
6109
|
scope === 'global'
|
|
@@ -5154,6 +6407,28 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5154
6407
|
expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
|
|
5155
6408
|
})
|
|
5156
6409
|
|
|
6410
|
+
it('diagnoses an older hook install that lacks proactive lifecycle handlers', async () => {
|
|
6411
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-missing-lifecycle-'))
|
|
6412
|
+
const io = new PlainInteractiveIo()
|
|
6413
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
6414
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
6415
|
+
EXIT.ok,
|
|
6416
|
+
)
|
|
6417
|
+
const settingsPath = path.join(cwd, '.claude', 'settings.local.json')
|
|
6418
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
|
|
6419
|
+
hooks: Record<string, unknown>
|
|
6420
|
+
}
|
|
6421
|
+
delete settings.hooks.SessionStart
|
|
6422
|
+
delete settings.hooks.SubagentStart
|
|
6423
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
|
|
6424
|
+
|
|
6425
|
+
const readiness = await assessReadiness(deps)
|
|
6426
|
+
const current = readiness.states.find((state) => state.id === 'hooks-stale')
|
|
6427
|
+
expect(current?.status).toBe('gap')
|
|
6428
|
+
expect(current?.detail).toMatch(/missing session-start, subagent-start/i)
|
|
6429
|
+
expect(current?.detail).toMatch(/notifai hooks install/i)
|
|
6430
|
+
})
|
|
6431
|
+
|
|
5157
6432
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
5158
6433
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
|
|
5159
6434
|
const io = new PlainInteractiveIo()
|
|
@@ -5305,7 +6580,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5305
6580
|
expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
|
|
5306
6581
|
})
|
|
5307
6582
|
|
|
5308
|
-
it('tells Claude Code to
|
|
6583
|
+
it('tells Claude Code to start fresh so activation is model-visible', () => {
|
|
5309
6584
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
|
|
5310
6585
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
5311
6586
|
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
@@ -5319,11 +6594,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5319
6594
|
|
|
5320
6595
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5321
6596
|
const said = io.errLines.join(' ')
|
|
5322
|
-
expect(said).toMatch(/
|
|
6597
|
+
expect(said).toMatch(/Claude Code: start one fresh session, send one prompt/i)
|
|
5323
6598
|
expect(said).not.toMatch(/Run `notifai hooks install` and send one prompt/)
|
|
5324
6599
|
})
|
|
5325
6600
|
|
|
5326
|
-
it('gives Cursor its
|
|
6601
|
+
it('gives Cursor its fresh-conversation and first-Stop activation path', () => {
|
|
5327
6602
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
|
|
5328
6603
|
const io = new CapturedIo()
|
|
5329
6604
|
const deps = {
|
|
@@ -5336,7 +6611,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5336
6611
|
io.outLines = []
|
|
5337
6612
|
|
|
5338
6613
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5339
|
-
expect(io.errLines.join(' ')).toMatch(/Cursor:
|
|
6614
|
+
expect(io.errLines.join(' ')).toMatch(/Cursor: start one fresh conversation[\s\S]*finish its first turn[\s\S]*`notifai doctor`/)
|
|
5340
6615
|
})
|
|
5341
6616
|
|
|
5342
6617
|
it('keeps OpenCode activation separate from unsupported answer continuation', () => {
|
|
@@ -5591,7 +6866,7 @@ describe('command failures carrying server details', () => {
|
|
|
5591
6866
|
})
|
|
5592
6867
|
|
|
5593
6868
|
describe('question sets', () => {
|
|
5594
|
-
it('maps --
|
|
6869
|
+
it('maps --multi into the single question', async () => {
|
|
5595
6870
|
const io = new CapturedIo()
|
|
5596
6871
|
let submitted: SubmitNotificationRequestT | undefined
|
|
5597
6872
|
const client = {
|
|
@@ -5608,8 +6883,8 @@ describe('question sets', () => {
|
|
|
5608
6883
|
body: 'Which fronts?',
|
|
5609
6884
|
reply: true,
|
|
5610
6885
|
replyTimeout: 30,
|
|
5611
|
-
|
|
5612
|
-
|
|
6886
|
+
choice: ['CLI', 'Server', 'Apps'],
|
|
6887
|
+
multi: true,
|
|
5613
6888
|
}),
|
|
5614
6889
|
).toBe(EXIT.ok)
|
|
5615
6890
|
expect(submitted?.draft.reply?.questions?.[0]).toMatchObject({
|
|
@@ -5643,7 +6918,10 @@ describe('question sets', () => {
|
|
|
5643
6918
|
expect(submitted?.draft.reply?.questions[0]?.text).toBe('Which environment?')
|
|
5644
6919
|
})
|
|
5645
6920
|
|
|
5646
|
-
it('
|
|
6921
|
+
it('keeps the question out of a body that carries context', () => {
|
|
6922
|
+
// The question already travels as the title and as structured questions;
|
|
6923
|
+
// repeating it in the body showed it twice on the lock screen and the
|
|
6924
|
+
// reply screen.
|
|
5647
6925
|
expect(
|
|
5648
6926
|
buildQuestions(
|
|
5649
6927
|
{ body: '## Why\nThe release window closes today.' },
|
|
@@ -5651,12 +6929,28 @@ describe('question sets', () => {
|
|
|
5651
6929
|
),
|
|
5652
6930
|
).toMatchObject({
|
|
5653
6931
|
ok: true,
|
|
5654
|
-
body: '
|
|
6932
|
+
body: '## Why\nThe release window closes today.',
|
|
5655
6933
|
questions: [{ text: 'Which environment?' }],
|
|
5656
6934
|
})
|
|
5657
6935
|
})
|
|
5658
6936
|
|
|
5659
|
-
it('
|
|
6937
|
+
it('lets the question stand in for the body only when there is no context', () => {
|
|
6938
|
+
expect(buildQuestions({}, 'Which environment?')).toMatchObject({
|
|
6939
|
+
ok: true,
|
|
6940
|
+
body: 'Which environment?',
|
|
6941
|
+
})
|
|
6942
|
+
expect(
|
|
6943
|
+
buildQuestions(
|
|
6944
|
+
{ form: JSON.stringify({ questions: [{ text: 'Deploy where?' }, { text: 'What should I monitor?' }] }) },
|
|
6945
|
+
undefined,
|
|
6946
|
+
),
|
|
6947
|
+
).toMatchObject({
|
|
6948
|
+
ok: true,
|
|
6949
|
+
body: '1. Deploy where?\n2. What should I monitor?',
|
|
6950
|
+
})
|
|
6951
|
+
})
|
|
6952
|
+
|
|
6953
|
+
it('keeps form questions out of a body that carries form context', () => {
|
|
5660
6954
|
expect(
|
|
5661
6955
|
buildQuestions(
|
|
5662
6956
|
{
|
|
@@ -5669,8 +6963,7 @@ describe('question sets', () => {
|
|
|
5669
6963
|
),
|
|
5670
6964
|
).toMatchObject({
|
|
5671
6965
|
ok: true,
|
|
5672
|
-
body:
|
|
5673
|
-
'1. Deploy where?\n2. What should I monitor?\n\n## Context\nTraffic is elevated.',
|
|
6966
|
+
body: '## Context\nTraffic is elevated.',
|
|
5674
6967
|
})
|
|
5675
6968
|
})
|
|
5676
6969
|
|