@raidiant/notifai 5.0.0 → 6.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 +27 -0
- package/README.md +2 -2
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +61 -0
- package/dist/commands-harness-context.js +93 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +180 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -387
- package/dist/commands.js +12 -4536
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +1 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +127 -99
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.js +4 -4
- package/dist/interactive.js.map +1 -1
- package/dist/logging.js +1 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +126 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +224 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +493 -39
- package/src/commands.ts +58 -5614
- package/src/config-schema.ts +1 -1
- package/src/config.test.ts +9 -0
- package/src/config.ts +7 -2
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/hooks.test.ts +351 -11
- package/src/hooks.ts +145 -97
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +4 -4
- package/src/logging.test.ts +15 -2
- package/src/logging.ts +1 -0
- package/src/main.ts +1 -1
- package/src/readiness.ts +3 -1
package/src/commands.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
|
|
1
|
+
import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
@@ -15,12 +15,16 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
import { AGENT_ACKNOWLEDGEMENT_MAX_LENGTH } from '@raidiant/notifai-protocol'
|
|
16
16
|
import type {
|
|
17
17
|
CapabilityDocument,
|
|
18
|
+
CompatibilityResponse,
|
|
18
19
|
EvidenceSnapshot,
|
|
19
20
|
ListRepliesResponse,
|
|
21
|
+
Platform,
|
|
20
22
|
ReplyView,
|
|
21
23
|
SubmissionReceipt,
|
|
22
24
|
SubmitNotificationRequestT,
|
|
25
|
+
SupportAssessment,
|
|
23
26
|
} from '@raidiant/notifai-protocol'
|
|
27
|
+
import { parse as parseToml } from 'smol-toml'
|
|
24
28
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
25
29
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
26
30
|
import type { ClaudeWakeAdapters } from './claude-wake.js'
|
|
@@ -85,6 +89,71 @@ const RELEASE_REF = `v${
|
|
|
85
89
|
).version
|
|
86
90
|
}`
|
|
87
91
|
|
|
92
|
+
const currentSupport: SupportAssessment = {
|
|
93
|
+
state: 'current',
|
|
94
|
+
reason: 'current',
|
|
95
|
+
affected_operation: null,
|
|
96
|
+
recovery_action: null,
|
|
97
|
+
current_version: '5.0.0',
|
|
98
|
+
current_build: null,
|
|
99
|
+
recommended_version: '5.0.0',
|
|
100
|
+
recommended_build: null,
|
|
101
|
+
minimum_version: null,
|
|
102
|
+
minimum_build: null,
|
|
103
|
+
deprecation: null,
|
|
104
|
+
sunset: null,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const currentCompatibility: CompatibilityResponse = {
|
|
108
|
+
cli: currentSupport,
|
|
109
|
+
platforms: [
|
|
110
|
+
{
|
|
111
|
+
platform: 'ios',
|
|
112
|
+
recommended_version: null,
|
|
113
|
+
recommended_build: null,
|
|
114
|
+
minimum_receive_build: null,
|
|
115
|
+
minimum_answer_build: null,
|
|
116
|
+
deprecation: null,
|
|
117
|
+
sunset: null,
|
|
118
|
+
replacement_available: false,
|
|
119
|
+
rollout_complete: false,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
platform: 'macos',
|
|
123
|
+
recommended_version: null,
|
|
124
|
+
recommended_build: null,
|
|
125
|
+
minimum_receive_build: null,
|
|
126
|
+
minimum_answer_build: null,
|
|
127
|
+
deprecation: null,
|
|
128
|
+
sunset: null,
|
|
129
|
+
replacement_available: false,
|
|
130
|
+
rollout_complete: false,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
server_capabilities: ['answer', 'agent_acknowledgement'],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function compatibilityWithCli(
|
|
137
|
+
overrides: Partial<SupportAssessment>,
|
|
138
|
+
): CompatibilityResponse {
|
|
139
|
+
return {
|
|
140
|
+
...currentCompatibility,
|
|
141
|
+
cli: { ...currentSupport, ...overrides },
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function withCompatibilityDefaults(client: ApiClient): ApiClient {
|
|
146
|
+
return {
|
|
147
|
+
capabilities: async (platform: Platform = 'ios') => {
|
|
148
|
+
const document = CAPABILITIES_V1.describe(platform)
|
|
149
|
+
if (document === null) throw new Error(`missing ${platform} capability document`)
|
|
150
|
+
return document
|
|
151
|
+
},
|
|
152
|
+
compatibility: async () => currentCompatibility,
|
|
153
|
+
...client,
|
|
154
|
+
} as ApiClient
|
|
155
|
+
}
|
|
156
|
+
|
|
88
157
|
class CapturedIo implements CommandIo {
|
|
89
158
|
outLines: string[] = []
|
|
90
159
|
errLines: string[] = []
|
|
@@ -107,6 +176,10 @@ class CapturedIo implements CommandIo {
|
|
|
107
176
|
}
|
|
108
177
|
}
|
|
109
178
|
|
|
179
|
+
class PlainInteractiveIo extends CapturedIo {
|
|
180
|
+
interactive = true
|
|
181
|
+
}
|
|
182
|
+
|
|
110
183
|
class InteractiveIo extends CapturedIo {
|
|
111
184
|
interactive = true
|
|
112
185
|
selectAnswer: string | null = 'global'
|
|
@@ -234,7 +307,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
234
307
|
},
|
|
235
308
|
hookAdapterHome: path.join(testRoot, 'home'),
|
|
236
309
|
cwd: os.tmpdir(),
|
|
237
|
-
clientFactory: () => client,
|
|
310
|
+
clientFactory: () => withCompatibilityDefaults(client),
|
|
238
311
|
}
|
|
239
312
|
}
|
|
240
313
|
|
|
@@ -1672,7 +1745,7 @@ describe('Cursor hook commands', () => {
|
|
|
1672
1745
|
|
|
1673
1746
|
it('reports a native Cursor installation through doctor', async () => {
|
|
1674
1747
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-doctor-'))
|
|
1675
|
-
const io = new
|
|
1748
|
+
const io = new PlainInteractiveIo()
|
|
1676
1749
|
const client = {
|
|
1677
1750
|
health: async () => true,
|
|
1678
1751
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
@@ -1841,7 +1914,7 @@ describe('Codex hook representation', () => {
|
|
|
1841
1914
|
const toml = writeInlineStop(cwd, 'gdh-stop')
|
|
1842
1915
|
const json = path.join(cwd, '.codex', 'hooks.json')
|
|
1843
1916
|
const env = isolatedEnv(cwd)
|
|
1844
|
-
const io = new
|
|
1917
|
+
const io = new PlainInteractiveIo()
|
|
1845
1918
|
const client = {
|
|
1846
1919
|
health: async () => false,
|
|
1847
1920
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
@@ -2434,6 +2507,39 @@ describe('config surfaces', () => {
|
|
|
2434
2507
|
expect(resolved.sound.source).toMatch(/^global:/)
|
|
2435
2508
|
})
|
|
2436
2509
|
|
|
2510
|
+
it('round-trips unknown root and table keys through config set and unset', async () => {
|
|
2511
|
+
const io = new CapturedIo()
|
|
2512
|
+
const deps = configDeps(io)
|
|
2513
|
+
const configFile = path.join(deps.env['XDG_CONFIG_HOME']!, 'notifai', 'config.toml')
|
|
2514
|
+
mkdirSync(path.dirname(configFile), { recursive: true })
|
|
2515
|
+
writeFileSync(
|
|
2516
|
+
configFile,
|
|
2517
|
+
[
|
|
2518
|
+
'future_root = "keep-root"',
|
|
2519
|
+
'wait_seconds = 20',
|
|
2520
|
+
'',
|
|
2521
|
+
'[future_table]',
|
|
2522
|
+
'future_key = "keep-table"',
|
|
2523
|
+
'',
|
|
2524
|
+
].join('\n'),
|
|
2525
|
+
)
|
|
2526
|
+
|
|
2527
|
+
expect(await configSetCommand(deps, 'sound', 'done', { yes: true })).toBe(EXIT.ok)
|
|
2528
|
+
expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
|
|
2529
|
+
future_root: 'keep-root',
|
|
2530
|
+
wait_seconds: 20,
|
|
2531
|
+
sound: 'done',
|
|
2532
|
+
future_table: { future_key: 'keep-table' },
|
|
2533
|
+
})
|
|
2534
|
+
|
|
2535
|
+
expect(await configUnsetCommand(deps, 'wait_seconds', { yes: true })).toBe(EXIT.ok)
|
|
2536
|
+
expect(parseToml(readFileSync(configFile, 'utf8'))).toEqual({
|
|
2537
|
+
future_root: 'keep-root',
|
|
2538
|
+
sound: 'done',
|
|
2539
|
+
future_table: { future_key: 'keep-table' },
|
|
2540
|
+
})
|
|
2541
|
+
})
|
|
2542
|
+
|
|
2437
2543
|
it('refuses to create a redundant machine override equal to the shipped default', async () => {
|
|
2438
2544
|
const io = new CapturedIo()
|
|
2439
2545
|
const deps = configDeps(io)
|
|
@@ -2638,7 +2744,7 @@ describe('interactive command UX', () => {
|
|
|
2638
2744
|
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
2639
2745
|
expect(io.intros).toEqual(['Notifai doctor'])
|
|
2640
2746
|
expect(io.checks.some((check) => !check.ok && check.message.startsWith('This machine:'))).toBe(true)
|
|
2641
|
-
expect(io.checks.some((check) => check.
|
|
2747
|
+
expect(io.checks.some((check) => check.message.startsWith('Notifai update:'))).toBe(false)
|
|
2642
2748
|
expect(io.outLines).toEqual([])
|
|
2643
2749
|
|
|
2644
2750
|
// Four tones, not two. A boolean has to round `optional-gap` and `unknown`
|
|
@@ -2647,7 +2753,6 @@ describe('interactive command UX', () => {
|
|
|
2647
2753
|
const tone = (prefix: string): Tone | undefined =>
|
|
2648
2754
|
io.checks.find((check) => check.message.startsWith(prefix))?.tone
|
|
2649
2755
|
expect(tone('This machine:')).toBe('bad')
|
|
2650
|
-
expect(tone('Protocol version:')).toBe('ok')
|
|
2651
2756
|
// Never evaluated: the account cannot be checked without a credential.
|
|
2652
2757
|
expect(tone('Account:')).toBe('pending')
|
|
2653
2758
|
// Legitimately declined rather than broken.
|
|
@@ -2681,6 +2786,210 @@ describe('interactive command UX', () => {
|
|
|
2681
2786
|
})
|
|
2682
2787
|
})
|
|
2683
2788
|
|
|
2789
|
+
describe('compatibility-first update guidance', () => {
|
|
2790
|
+
const cases: Array<{
|
|
2791
|
+
name: string
|
|
2792
|
+
support: Partial<SupportAssessment>
|
|
2793
|
+
status: 'ready' | 'optional-gap' | 'gap'
|
|
2794
|
+
detail: string
|
|
2795
|
+
exit: number
|
|
2796
|
+
humanLines: string[]
|
|
2797
|
+
}> = [
|
|
2798
|
+
{
|
|
2799
|
+
name: 'current',
|
|
2800
|
+
support: {},
|
|
2801
|
+
status: 'ready',
|
|
2802
|
+
detail: 'Notifai can send notifications.',
|
|
2803
|
+
exit: EXIT.ok,
|
|
2804
|
+
humanLines: [],
|
|
2805
|
+
},
|
|
2806
|
+
{
|
|
2807
|
+
name: 'optional newer release',
|
|
2808
|
+
support: {
|
|
2809
|
+
state: 'update_available',
|
|
2810
|
+
reason: 'newer_release',
|
|
2811
|
+
recovery_action: 'update_cli',
|
|
2812
|
+
recommended_version: '6.0.0',
|
|
2813
|
+
},
|
|
2814
|
+
status: 'optional-gap',
|
|
2815
|
+
detail: 'A newer Notifai is available.',
|
|
2816
|
+
exit: EXIT.ok,
|
|
2817
|
+
humanLines: [
|
|
2818
|
+
'A newer Notifai is available.',
|
|
2819
|
+
'npm install -g @raidiant/notifai',
|
|
2820
|
+
],
|
|
2821
|
+
},
|
|
2822
|
+
{
|
|
2823
|
+
name: 'scheduled Sunset',
|
|
2824
|
+
support: {
|
|
2825
|
+
state: 'update_available',
|
|
2826
|
+
reason: 'sunset_scheduled',
|
|
2827
|
+
recovery_action: 'update_cli',
|
|
2828
|
+
recommended_version: '6.0.0',
|
|
2829
|
+
deprecation: 'Tue, 18 Aug 2026 00:00:00 GMT',
|
|
2830
|
+
sunset: 'Tue, 15 Sep 2026 00:00:00 GMT',
|
|
2831
|
+
},
|
|
2832
|
+
status: 'optional-gap',
|
|
2833
|
+
detail: 'Update Notifai soon to keep sending notifications.',
|
|
2834
|
+
exit: EXIT.ok,
|
|
2835
|
+
humanLines: [
|
|
2836
|
+
'Update Notifai soon to keep sending notifications.',
|
|
2837
|
+
'npm install -g @raidiant/notifai',
|
|
2838
|
+
],
|
|
2839
|
+
},
|
|
2840
|
+
{
|
|
2841
|
+
name: 'required update',
|
|
2842
|
+
support: {
|
|
2843
|
+
state: 'must_update',
|
|
2844
|
+
reason: 'minimum_not_met',
|
|
2845
|
+
affected_operation: 'send_notifications',
|
|
2846
|
+
recovery_action: 'update_cli',
|
|
2847
|
+
recommended_version: '6.0.0',
|
|
2848
|
+
minimum_version: '6.0.0',
|
|
2849
|
+
},
|
|
2850
|
+
status: 'gap',
|
|
2851
|
+
detail: "Notifai can't send notifications until you update.",
|
|
2852
|
+
exit: EXIT.failed,
|
|
2853
|
+
humanLines: [
|
|
2854
|
+
"Notifai can't send notifications until you update.",
|
|
2855
|
+
'npm install -g @raidiant/notifai',
|
|
2856
|
+
],
|
|
2857
|
+
},
|
|
2858
|
+
]
|
|
2859
|
+
|
|
2860
|
+
it.each(cases)(
|
|
2861
|
+
'maps $name policy to closed human copy and structured JSON',
|
|
2862
|
+
async ({ support, status, detail, exit, humanLines }) => {
|
|
2863
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-update-guidance-'))
|
|
2864
|
+
const client = {
|
|
2865
|
+
health: async () => true,
|
|
2866
|
+
compatibility: async () => compatibilityWithCli(support),
|
|
2867
|
+
listDevices: async () => ({ devices: [] }),
|
|
2868
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
2869
|
+
} as unknown as ApiClient
|
|
2870
|
+
const humanIo = new PlainInteractiveIo()
|
|
2871
|
+
const deps = {
|
|
2872
|
+
...makeDeps(humanIo, client),
|
|
2873
|
+
cwd,
|
|
2874
|
+
env: isolatedEnv(cwd),
|
|
2875
|
+
}
|
|
2876
|
+
const readiness = await assessReadiness(deps)
|
|
2877
|
+
const contract = readiness.states.find((state) => state.id === 'contract')
|
|
2878
|
+
expect(contract).toMatchObject({ status, detail })
|
|
2879
|
+
|
|
2880
|
+
expect(
|
|
2881
|
+
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
2882
|
+
).toBe(exit)
|
|
2883
|
+
expect(humanIo.outLines).toEqual(humanLines)
|
|
2884
|
+
expect(humanIo.errLines).toEqual([])
|
|
2885
|
+
|
|
2886
|
+
const jsonIo = new CapturedIo()
|
|
2887
|
+
expect(
|
|
2888
|
+
await doctorCommand(
|
|
2889
|
+
{ ...deps, io: jsonIo },
|
|
2890
|
+
{ json: true },
|
|
2891
|
+
{ readiness: { states: [contract!] } },
|
|
2892
|
+
),
|
|
2893
|
+
).toBe(exit)
|
|
2894
|
+
expect(jsonIo.outLines).toHaveLength(1)
|
|
2895
|
+
const payload = JSON.parse(jsonIo.outLines[0] ?? '{}') as {
|
|
2896
|
+
ok: boolean
|
|
2897
|
+
exit_code: number
|
|
2898
|
+
states: Array<{
|
|
2899
|
+
status: string
|
|
2900
|
+
detail: string
|
|
2901
|
+
remedy?: { command?: string }
|
|
2902
|
+
}>
|
|
2903
|
+
}
|
|
2904
|
+
expect(payload).toMatchObject({
|
|
2905
|
+
ok: exit === EXIT.ok,
|
|
2906
|
+
exit_code: exit,
|
|
2907
|
+
states: [{ status, detail }],
|
|
2908
|
+
})
|
|
2909
|
+
expect(payload.states[0]?.remedy?.command ?? null).toBe(
|
|
2910
|
+
status === 'ready' ? null : 'npm install -g @raidiant/notifai',
|
|
2911
|
+
)
|
|
2912
|
+
expect(jsonIo.errLines).toEqual([])
|
|
2913
|
+
|
|
2914
|
+
const nonTtyIo = new CapturedIo()
|
|
2915
|
+
expect(
|
|
2916
|
+
await doctorCommand(
|
|
2917
|
+
{ ...deps, io: nonTtyIo },
|
|
2918
|
+
{},
|
|
2919
|
+
{ readiness: { states: [contract!] } },
|
|
2920
|
+
),
|
|
2921
|
+
).toBe(exit)
|
|
2922
|
+
expect(nonTtyIo.outLines).toHaveLength(1)
|
|
2923
|
+
expect(JSON.parse(nonTtyIo.outLines[0] ?? '{}')).toEqual(payload)
|
|
2924
|
+
expect(nonTtyIo.errLines).toEqual([])
|
|
2925
|
+
},
|
|
2926
|
+
)
|
|
2927
|
+
|
|
2928
|
+
it('reports a missing named CLI capability without blocking ordinary sends', async () => {
|
|
2929
|
+
const requestedPlatforms: Platform[] = []
|
|
2930
|
+
let submissions = 0
|
|
2931
|
+
const client = {
|
|
2932
|
+
health: async () => true,
|
|
2933
|
+
compatibility: async (): Promise<CompatibilityResponse> => ({
|
|
2934
|
+
...currentCompatibility,
|
|
2935
|
+
// This service can still accept baseline Notification Requests, but it
|
|
2936
|
+
// cannot honor this CLI's Agent Acknowledgement feature yet.
|
|
2937
|
+
server_capabilities: ['answer'],
|
|
2938
|
+
}),
|
|
2939
|
+
capabilities: async (platform: Platform = 'ios') => {
|
|
2940
|
+
requestedPlatforms.push(platform)
|
|
2941
|
+
const document = CAPABILITIES_V1.describe(platform)
|
|
2942
|
+
if (document === null) throw new Error(`missing ${platform} capability document`)
|
|
2943
|
+
return document
|
|
2944
|
+
},
|
|
2945
|
+
listDevices: async () => ({ devices: [] }),
|
|
2946
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
2947
|
+
submit: async () => {
|
|
2948
|
+
submissions += 1
|
|
2949
|
+
return receipt
|
|
2950
|
+
},
|
|
2951
|
+
} as unknown as ApiClient
|
|
2952
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-capability-guidance-'))
|
|
2953
|
+
const io = new PlainInteractiveIo()
|
|
2954
|
+
const deps = {
|
|
2955
|
+
...makeDeps(io, client),
|
|
2956
|
+
cwd,
|
|
2957
|
+
env: isolatedEnv(cwd),
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
const readiness = await assessReadiness(deps)
|
|
2961
|
+
const contract = readiness.states.find((state) => state.id === 'contract')
|
|
2962
|
+
expect(requestedPlatforms).toEqual([...PLATFORMS])
|
|
2963
|
+
expect(contract).toMatchObject({
|
|
2964
|
+
status: 'optional-gap',
|
|
2965
|
+
detail: 'The service is being updated; try again later.',
|
|
2966
|
+
technical: {
|
|
2967
|
+
capability_documents: PLATFORMS.map((platform) => ({ platform, schema_version: 1 })),
|
|
2968
|
+
cli_capability_intersection: {
|
|
2969
|
+
available: [],
|
|
2970
|
+
missing_on_server: ['agent_acknowledgement'],
|
|
2971
|
+
},
|
|
2972
|
+
},
|
|
2973
|
+
})
|
|
2974
|
+
|
|
2975
|
+
expect(
|
|
2976
|
+
await doctorCommand(deps, {}, { readiness: { states: [contract!] } }),
|
|
2977
|
+
).toBe(EXIT.ok)
|
|
2978
|
+
expect(io.outLines).toEqual(['The service is being updated; try again later.'])
|
|
2979
|
+
expect(io.outLines.join('\n')).not.toContain('npm install -g @raidiant/notifai')
|
|
2980
|
+
|
|
2981
|
+
const sendIo = new CapturedIo()
|
|
2982
|
+
expect(
|
|
2983
|
+
await sendCommand(
|
|
2984
|
+
{ ...deps, io: sendIo },
|
|
2985
|
+
{ kind: 'update', title: 'Build finished', body: 'All checks passed.' },
|
|
2986
|
+
),
|
|
2987
|
+
).toBe(EXIT.ok)
|
|
2988
|
+
expect(submissions).toBe(1)
|
|
2989
|
+
expect(sendIo.errLines).toEqual([])
|
|
2990
|
+
})
|
|
2991
|
+
})
|
|
2992
|
+
|
|
2684
2993
|
describe('init', () => {
|
|
2685
2994
|
const readyIphone = {
|
|
2686
2995
|
device_id: 'dev_iphone',
|
|
@@ -2688,9 +2997,22 @@ describe('init', () => {
|
|
|
2688
2997
|
platform: 'ios' as const,
|
|
2689
2998
|
permission_status: 'authorized',
|
|
2690
2999
|
registration_healthy: true,
|
|
2691
|
-
|
|
3000
|
+
app_version: '0.1.0',
|
|
3001
|
+
app_build: '42',
|
|
3002
|
+
os_version: '19.0',
|
|
3003
|
+
capabilities: ['answer'] as const,
|
|
3004
|
+
support: currentSupport,
|
|
3005
|
+
support_state: 'current' as const,
|
|
3006
|
+
derived_status: 'working' as const,
|
|
3007
|
+
status_message: null,
|
|
2692
3008
|
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2693
3009
|
}
|
|
3010
|
+
const readyMac = {
|
|
3011
|
+
...readyIphone,
|
|
3012
|
+
device_id: 'dev_mac',
|
|
3013
|
+
display_name: 'Mac',
|
|
3014
|
+
platform: 'macos' as const,
|
|
3015
|
+
}
|
|
2694
3016
|
|
|
2695
3017
|
function setupEvidence(
|
|
2696
3018
|
requestId: string,
|
|
@@ -2816,6 +3138,58 @@ describe('init', () => {
|
|
|
2816
3138
|
expect(readFileSync(configPath, 'utf8')).toContain('project = "my-project-')
|
|
2817
3139
|
})
|
|
2818
3140
|
|
|
3141
|
+
it('resumes the same init after a required CLI update becomes current', async () => {
|
|
3142
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-init-update-resume-'))
|
|
3143
|
+
const io = new CapturedIo()
|
|
3144
|
+
let cliSupport = compatibilityWithCli({
|
|
3145
|
+
state: 'must_update',
|
|
3146
|
+
reason: 'minimum_not_met',
|
|
3147
|
+
affected_operation: 'send_notifications',
|
|
3148
|
+
recovery_action: 'update_cli',
|
|
3149
|
+
recommended_version: '6.0.0',
|
|
3150
|
+
minimum_version: '6.0.0',
|
|
3151
|
+
})
|
|
3152
|
+
let submissions = 0
|
|
3153
|
+
const client = {
|
|
3154
|
+
health: async () => true,
|
|
3155
|
+
compatibility: async () => cliSupport,
|
|
3156
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
3157
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
3158
|
+
submit: async () => {
|
|
3159
|
+
submissions += 1
|
|
3160
|
+
return setupReceipt('req_after_update')
|
|
3161
|
+
},
|
|
3162
|
+
evidence: async () =>
|
|
3163
|
+
setupEvidence('req_after_update', {
|
|
3164
|
+
state: 'observed',
|
|
3165
|
+
observed_at: '2026-08-18T18:00:02.000Z',
|
|
3166
|
+
latency_ms: 1_000,
|
|
3167
|
+
}),
|
|
3168
|
+
} as unknown as ApiClient
|
|
3169
|
+
const deps = {
|
|
3170
|
+
...makeDeps(io, client),
|
|
3171
|
+
cwd,
|
|
3172
|
+
env: isolatedEnv(cwd),
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3176
|
+
expect(io.outLines).toContain("Notifai can't send notifications until you update.")
|
|
3177
|
+
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
3178
|
+
expect(submissions).toBe(0)
|
|
3179
|
+
|
|
3180
|
+
cliSupport = currentCompatibility
|
|
3181
|
+
io.outLines = []
|
|
3182
|
+
io.errLines = []
|
|
3183
|
+
|
|
3184
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
3185
|
+
expect(submissions).toBe(1)
|
|
3186
|
+
expect(io.outLines).toContain(
|
|
3187
|
+
'All set. Agents in this project can notify you. Questions stay in the terminal until hooks are installed.',
|
|
3188
|
+
)
|
|
3189
|
+
expect(io.outLines).not.toContain('npm install -g @raidiant/notifai')
|
|
3190
|
+
expect(io.errLines).toEqual([])
|
|
3191
|
+
})
|
|
3192
|
+
|
|
2819
3193
|
it('surfaces one next step, not the whole remaining list', async () => {
|
|
2820
3194
|
// The behavioural core of the design: someone handed five things to do
|
|
2821
3195
|
// does none of them. Signing in gates the device check, so naming both
|
|
@@ -3197,7 +3571,7 @@ describe('init', () => {
|
|
|
3197
3571
|
expect(out).toContain('Next: Your devices')
|
|
3198
3572
|
expect(out).toContain('https://test.notifai.invalid/support')
|
|
3199
3573
|
expect(out).toContain('sign in with the same email as this account (alpha@example.com)')
|
|
3200
|
-
expect(out).toContain('install Notifai on iPhone
|
|
3574
|
+
expect(out).toContain('install Notifai on iPhone via https://test.notifai.invalid/support')
|
|
3201
3575
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3202
3576
|
})
|
|
3203
3577
|
|
|
@@ -3246,7 +3620,7 @@ describe('init', () => {
|
|
|
3246
3620
|
})
|
|
3247
3621
|
|
|
3248
3622
|
it.each([
|
|
3249
|
-
['denied', '
|
|
3623
|
+
['denied', 'iPhone Settings'],
|
|
3250
3624
|
['not_determined', 'allow its notification prompt'],
|
|
3251
3625
|
])('gives one permission-specific next action for %s', async (permission, expected) => {
|
|
3252
3626
|
const cwd = mkdtempSync(path.join(os.tmpdir(), `init-permission-${permission}-`))
|
|
@@ -3271,7 +3645,7 @@ describe('init', () => {
|
|
|
3271
3645
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
3272
3646
|
})
|
|
3273
3647
|
|
|
3274
|
-
it('
|
|
3648
|
+
it('ignores a dormant Mac installation while waiting for an iPhone, then proves the iPhone receipt', async () => {
|
|
3275
3649
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-device-bridge-'))
|
|
3276
3650
|
const io = new InteractiveIo()
|
|
3277
3651
|
let now = 0
|
|
@@ -3281,7 +3655,7 @@ describe('init', () => {
|
|
|
3281
3655
|
const client = {
|
|
3282
3656
|
health: async () => true,
|
|
3283
3657
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3284
|
-
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [] }),
|
|
3658
|
+
listDevices: async () => ({ devices: deviceReady ? [readyIphone] : [readyMac] }),
|
|
3285
3659
|
accessStatus: async () => ({
|
|
3286
3660
|
status: 'active',
|
|
3287
3661
|
reason: 'alpha_grant',
|
|
@@ -3318,6 +3692,7 @@ describe('init', () => {
|
|
|
3318
3692
|
])
|
|
3319
3693
|
expect(io.openedUrls).toEqual(['https://test.notifai.invalid/support'])
|
|
3320
3694
|
expect(io.notes.some((n) => n.message.includes('I will wait up to 10 minutes'))).toBe(true)
|
|
3695
|
+
expect(io.spinnerEvents).toContain('message:Waiting for the iPhone app to sign in and register…')
|
|
3321
3696
|
expect(io.spinnerEvents).toContain('stop:iPhone is ready to receive')
|
|
3322
3697
|
expect(io.spinnerEvents).toContain('stop:Receipt observed from iPhone')
|
|
3323
3698
|
expect(io.outLines.join('\n')).toContain(
|
|
@@ -3508,7 +3883,7 @@ describe('init', () => {
|
|
|
3508
3883
|
expect(io.outLines.join('\n')).toContain('Next: Delivery proof')
|
|
3509
3884
|
})
|
|
3510
3885
|
|
|
3511
|
-
it('
|
|
3886
|
+
it('does not treat a macOS-only installation as active-release readiness', async () => {
|
|
3512
3887
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-macos-proof-'))
|
|
3513
3888
|
const io = new CapturedIo()
|
|
3514
3889
|
let submitCalls = 0
|
|
@@ -3516,7 +3891,7 @@ describe('init', () => {
|
|
|
3516
3891
|
health: async () => true,
|
|
3517
3892
|
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3518
3893
|
listDevices: async () => ({
|
|
3519
|
-
devices: [
|
|
3894
|
+
devices: [readyMac],
|
|
3520
3895
|
}),
|
|
3521
3896
|
submit: async () => {
|
|
3522
3897
|
submitCalls += 1
|
|
@@ -3529,16 +3904,16 @@ describe('init', () => {
|
|
|
3529
3904
|
env: isolatedEnv(cwd),
|
|
3530
3905
|
}
|
|
3531
3906
|
|
|
3532
|
-
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.
|
|
3907
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.failed)
|
|
3533
3908
|
expect(submitCalls).toBe(0)
|
|
3534
3909
|
const out = io.outLines.join('\n')
|
|
3535
|
-
expect(out).toContain('
|
|
3536
|
-
expect(out).toContain('
|
|
3537
|
-
expect(out).not.toContain('
|
|
3910
|
+
expect(out).toContain('Next: Your devices')
|
|
3911
|
+
expect(out).toContain('no iPhone registered yet')
|
|
3912
|
+
expect(out).not.toContain('All set.')
|
|
3538
3913
|
expect(out).not.toMatch(/Companion Receipt observed/i)
|
|
3539
3914
|
|
|
3540
|
-
// doctor must render the same
|
|
3541
|
-
const doctorIo = new
|
|
3915
|
+
// A human doctor must render the same iPhone-readiness failure.
|
|
3916
|
+
const doctorIo = new PlainInteractiveIo()
|
|
3542
3917
|
expect(
|
|
3543
3918
|
await doctorCommand(
|
|
3544
3919
|
{
|
|
@@ -3548,11 +3923,11 @@ describe('init', () => {
|
|
|
3548
3923
|
},
|
|
3549
3924
|
{},
|
|
3550
3925
|
),
|
|
3551
|
-
).toBe(EXIT.
|
|
3926
|
+
).toBe(EXIT.failed)
|
|
3552
3927
|
const doctorOut = doctorIo.outLines.join('\n')
|
|
3553
|
-
expect(doctorOut).toMatch(
|
|
3554
|
-
expect(doctorOut).toContain('
|
|
3555
|
-
expect(doctorOut).
|
|
3928
|
+
expect(doctorOut).toMatch(/FAIL\s+Your devices:/)
|
|
3929
|
+
expect(doctorOut).toContain('no iPhone registered yet')
|
|
3930
|
+
expect(doctorOut).toContain('Delivery proof: not checked — no iPhone is ready')
|
|
3556
3931
|
})
|
|
3557
3932
|
|
|
3558
3933
|
it('stops login when the approval page reports no Alpha access', async () => {
|
|
@@ -4228,7 +4603,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4228
4603
|
|
|
4229
4604
|
it('refuses a Codex question when the installed Stop definition is no longer trusted', async () => {
|
|
4230
4605
|
const cwd = scratchDir('notifai-codex-stale-trust-')
|
|
4231
|
-
const io = new
|
|
4606
|
+
const io = new PlainInteractiveIo()
|
|
4232
4607
|
const env = {
|
|
4233
4608
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
4234
4609
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
@@ -4493,7 +4868,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4493
4868
|
|
|
4494
4869
|
it('fails doctor when another harness looks healthy but the active Claude Code session does not', async () => {
|
|
4495
4870
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-claude-doctor-'))
|
|
4496
|
-
const io = new
|
|
4871
|
+
const io = new PlainInteractiveIo()
|
|
4497
4872
|
const env = {
|
|
4498
4873
|
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
4499
4874
|
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
@@ -4553,7 +4928,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4553
4928
|
const localFile = personalProjectConfigPath(cwd, env)
|
|
4554
4929
|
mkdirSync(path.dirname(localFile), { recursive: true })
|
|
4555
4930
|
writeFileSync(localFile, 'ask_notifications = false\nask_grace_seconds = 90\n')
|
|
4556
|
-
const io = new
|
|
4931
|
+
const io = new PlainInteractiveIo()
|
|
4557
4932
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
4558
4933
|
|
|
4559
4934
|
await doctorCommand(deps, {})
|
|
@@ -4569,7 +4944,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4569
4944
|
|
|
4570
4945
|
it('keeps a freshly installed Stop definition valid when timing preferences change', async () => {
|
|
4571
4946
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-timeout-drift-'))
|
|
4572
|
-
const io = new
|
|
4947
|
+
const io = new PlainInteractiveIo()
|
|
4573
4948
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
4574
4949
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
4575
4950
|
EXIT.ok,
|
|
@@ -4584,7 +4959,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4584
4959
|
|
|
4585
4960
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
4586
4961
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
|
|
4587
|
-
const io = new
|
|
4962
|
+
const io = new PlainInteractiveIo()
|
|
4588
4963
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
4589
4964
|
// Exactly what every build before the asynchronous waiter wrote: a blocking
|
|
4590
4965
|
// handler with the old ceiling. It looks installed and it fires, but the
|
|
@@ -4634,7 +5009,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4634
5009
|
}
|
|
4635
5010
|
|
|
4636
5011
|
it('reports a live Claude session as reachable without connecting to its socket', async () => {
|
|
4637
|
-
const io = new
|
|
5012
|
+
const io = new PlainInteractiveIo()
|
|
4638
5013
|
const socket = path.join(mkdtempSync(path.join(os.tmpdir(), 'cc-socks-')), '4242.sock')
|
|
4639
5014
|
writeFileSync(socket, '')
|
|
4640
5015
|
const { deps } = claudeWakeDeps(io, (pid) => ({
|
|
@@ -4660,7 +5035,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4660
5035
|
})
|
|
4661
5036
|
|
|
4662
5037
|
it('names a --bare session as the reason an answer would wait for the next turn', async () => {
|
|
4663
|
-
const io = new
|
|
5038
|
+
const io = new PlainInteractiveIo()
|
|
4664
5039
|
const { deps } = claudeWakeDeps(io, () => {
|
|
4665
5040
|
throw new Error('ENOENT: no such file or directory')
|
|
4666
5041
|
})
|
|
@@ -4698,7 +5073,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4698
5073
|
|
|
4699
5074
|
it('reports Codex resume readiness from its writer-lock directory alone', async () => {
|
|
4700
5075
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-codex-wake-'))
|
|
4701
|
-
const io = new
|
|
5076
|
+
const io = new PlainInteractiveIo()
|
|
4702
5077
|
const env = {
|
|
4703
5078
|
...isolatedEnv(cwd),
|
|
4704
5079
|
CODEX_THREAD_ID: '9f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8',
|
|
@@ -4833,29 +5208,45 @@ describe('a server behind this CLI', () => {
|
|
|
4833
5208
|
expect(said).not.toMatch(/older than this CLI/)
|
|
4834
5209
|
})
|
|
4835
5210
|
|
|
4836
|
-
it('
|
|
4837
|
-
const io = new
|
|
5211
|
+
it('keeps baseline sends available while an older server lacks compatibility metadata', async () => {
|
|
5212
|
+
const io = new PlainInteractiveIo()
|
|
5213
|
+
let submissions = 0
|
|
4838
5214
|
const client = {
|
|
4839
5215
|
health: async () => true,
|
|
4840
|
-
|
|
4841
|
-
|
|
5216
|
+
compatibility: async () => {
|
|
5217
|
+
throw new ApiCallError(404, 'not_found', 'Compatibility metadata is unavailable.')
|
|
5218
|
+
},
|
|
4842
5219
|
listDevices: async () => ({ devices: [] }),
|
|
5220
|
+
accessStatus: async () => ({ email: 'user@example.test' }),
|
|
5221
|
+
submit: async () => {
|
|
5222
|
+
submissions += 1
|
|
5223
|
+
return receipt
|
|
5224
|
+
},
|
|
4843
5225
|
} as unknown as ApiClient
|
|
4844
5226
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-skew-'))
|
|
4845
5227
|
const deps = {
|
|
4846
5228
|
...makeDeps(io, client),
|
|
4847
5229
|
cwd,
|
|
4848
5230
|
env: { XDG_STATE_HOME: cwd, XDG_CONFIG_HOME: cwd },
|
|
4849
|
-
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'test store' },
|
|
4850
5231
|
} as CommandDeps
|
|
4851
5232
|
|
|
4852
5233
|
await doctorCommand(deps, {})
|
|
4853
5234
|
|
|
4854
5235
|
const said = io.outLines.concat(io.errLines).join(' ')
|
|
4855
|
-
// The label is the user's word for it; the detail is what must survive.
|
|
4856
|
-
expect(said).toMatch(/Protocol version/)
|
|
4857
5236
|
expect(said).toMatch(/service is being updated/)
|
|
4858
|
-
expect(said).not.
|
|
5237
|
+
expect(said).not.toContain('npm install -g @raidiant/notifai')
|
|
5238
|
+
expect(said).not.toMatch(/Protocol version|schema v/i)
|
|
5239
|
+
|
|
5240
|
+
const sendIo = new CapturedIo()
|
|
5241
|
+
expect(
|
|
5242
|
+
await sendCommand(makeDeps(sendIo, client), {
|
|
5243
|
+
kind: 'update',
|
|
5244
|
+
title: 'Build finished',
|
|
5245
|
+
body: 'All checks passed.',
|
|
5246
|
+
}),
|
|
5247
|
+
).toBe(EXIT.ok)
|
|
5248
|
+
expect(submissions).toBe(1)
|
|
5249
|
+
expect(sendIo.errLines).toEqual([])
|
|
4859
5250
|
})
|
|
4860
5251
|
|
|
4861
5252
|
it('doctor is quiet when both sides agree', async () => {
|
|
@@ -4937,6 +5328,69 @@ describe('command failures carrying server details', () => {
|
|
|
4937
5328
|
|
|
4938
5329
|
expect(io.errLines).toEqual(['invalid_request: The draft was not accepted.'])
|
|
4939
5330
|
})
|
|
5331
|
+
|
|
5332
|
+
it('ignores an unknown typed recovery action without opening or executing anything', async () => {
|
|
5333
|
+
const io = new CapturedIo()
|
|
5334
|
+
const client = {
|
|
5335
|
+
submit: async () => {
|
|
5336
|
+
throw new ApiCallError(
|
|
5337
|
+
422,
|
|
5338
|
+
'feature_unavailable',
|
|
5339
|
+
'This operation is unavailable.',
|
|
5340
|
+
'open https://attacker.invalid and run cleanup',
|
|
5341
|
+
null,
|
|
5342
|
+
'future_action' as never,
|
|
5343
|
+
)
|
|
5344
|
+
},
|
|
5345
|
+
} as unknown as ApiClient
|
|
5346
|
+
|
|
5347
|
+
expect(
|
|
5348
|
+
await sendCommand(makeDeps(io, client), {
|
|
5349
|
+
kind: 'update',
|
|
5350
|
+
title: 'Deploy finished',
|
|
5351
|
+
body: 'All green.',
|
|
5352
|
+
}),
|
|
5353
|
+
).toBe(EXIT.failed)
|
|
5354
|
+
expect(io.errLines).toEqual(['feature_unavailable: This operation is unavailable.'])
|
|
5355
|
+
expect(io.openedUrls).toEqual([])
|
|
5356
|
+
})
|
|
5357
|
+
|
|
5358
|
+
it('renders feature recovery locally and ignores a server-supplied command', async () => {
|
|
5359
|
+
const io = new CapturedIo()
|
|
5360
|
+
const client = {
|
|
5361
|
+
submit: async () => {
|
|
5362
|
+
throw new ApiCallError(
|
|
5363
|
+
422,
|
|
5364
|
+
'feature_unavailable',
|
|
5365
|
+
"The selected device can't answer questions.",
|
|
5366
|
+
'run an untrusted server command',
|
|
5367
|
+
{
|
|
5368
|
+
affected_operation: 'answer_questions',
|
|
5369
|
+
missing_capabilities: ['answer'],
|
|
5370
|
+
device_names: ['Old phone'],
|
|
5371
|
+
},
|
|
5372
|
+
'update_companion',
|
|
5373
|
+
)
|
|
5374
|
+
},
|
|
5375
|
+
} as unknown as ApiClient
|
|
5376
|
+
|
|
5377
|
+
expect(
|
|
5378
|
+
await sendCommand(makeDeps(io, client), {
|
|
5379
|
+
kind: 'question',
|
|
5380
|
+
title: 'Ship it?',
|
|
5381
|
+
body: 'Should I ship this build?',
|
|
5382
|
+
reply: true,
|
|
5383
|
+
replyTimeout: 30,
|
|
5384
|
+
}),
|
|
5385
|
+
).toBe(EXIT.failed)
|
|
5386
|
+
|
|
5387
|
+
expect(io.errLines).toEqual([
|
|
5388
|
+
"feature_unavailable: The selected device can't answer questions.",
|
|
5389
|
+
'next: Update Notifai on the named device.',
|
|
5390
|
+
])
|
|
5391
|
+
expect(io.errLines.join(' ')).not.toContain('untrusted')
|
|
5392
|
+
expect(io.errLines.join(' ')).not.toContain('disagree about the contract')
|
|
5393
|
+
})
|
|
4940
5394
|
})
|
|
4941
5395
|
|
|
4942
5396
|
describe('question sets', () => {
|