happyskills 0.53.0 → 1.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 +30 -0
- package/package.json +1 -1
- package/src/api/auth.js +18 -2
- package/src/api/client.js +29 -3
- package/src/api/feedback.js +14 -5
- package/src/api/repos.js +28 -10
- package/src/api/translate.js +90 -0
- package/src/commands/delete.js +15 -1
- package/src/commands/feedback.js +2 -2
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +53 -35
- package/src/commands/postlex.test.js +48 -18
- package/src/commands/pull.js +5 -1
- package/src/commands/reconcile.js +52 -4
- package/src/commands/release.js +45 -15
- package/src/commands/schema.js +179 -0
- package/src/commands/search.js +34 -22
- package/src/commands/search.test.js +59 -33
- package/src/commands/uninstall.js +20 -11
- package/src/commands/validate.js +33 -11
- package/src/constants/error_codes.js +197 -0
- package/src/constants/exit_codes.js +54 -0
- package/src/constants/next_step_actions.js +133 -0
- package/src/constants/next_step_by_error_code.js +249 -0
- package/src/constants.js +2 -1
- package/src/index.js +51 -7
- package/src/integration/api_envelope.test.js +499 -0
- package/src/integration/bump.test.js +13 -4
- package/src/integration/cli.test.js +169 -147
- package/src/integration/drift.test.js +16 -4
- package/src/integration/install_fresh.test.js +37 -29
- package/src/integration/reconcile.test.js +77 -56
- package/src/integration/release.test.js +48 -31
- package/src/integration/schema.test.js +167 -0
- package/src/schema/envelope.schema.json +73 -0
- package/src/schema/envelope_test_helpers.js +94 -0
- package/src/schema/envelope_validator.js +239 -0
- package/src/schema/envelope_validator.test.js +333 -0
- package/src/ui/envelope.js +171 -0
- package/src/ui/output.js +66 -2
- package/src/utils/errors.js +116 -47
- package/src/utils/intent.js +22 -1
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* End-to-end integration tests: CLI ↔ stubbed API speaking the new
|
|
4
|
+
* canonical six-key envelope (spec 260525-cli-default-json § 4).
|
|
5
|
+
*
|
|
6
|
+
* We spin up a tiny `http.createServer` per test that emits the EXACT
|
|
7
|
+
* envelope the production API now returns (post-Session 3), point the
|
|
8
|
+
* CLI binary at it via HAPPYSKILLS_API_URL, and assert that:
|
|
9
|
+
* 1. The CLI parses the response without losing fields (search.mode,
|
|
10
|
+
* rerank context, feedback's next_step.context payload, the device
|
|
11
|
+
* flow's AUTHORIZATION_PENDING polling loop).
|
|
12
|
+
* 2. The CLI's own --json output is itself a valid envelope.
|
|
13
|
+
*
|
|
14
|
+
* These tests catch the field-read drift between CLI command code and
|
|
15
|
+
* API envelope locations — the gap Session 3 introduced when /repos:search
|
|
16
|
+
* moved metadata into data, /feedback hoisted next_step to the root, and
|
|
17
|
+
* /auth/device/token started speaking the canonical shape on HTTP 202.
|
|
18
|
+
*/
|
|
19
|
+
const { describe, it, before, after } = require('node:test')
|
|
20
|
+
const assert = require('node:assert/strict')
|
|
21
|
+
const { spawn } = require('child_process')
|
|
22
|
+
const http = require('http')
|
|
23
|
+
const fs = require('fs')
|
|
24
|
+
const os = require('os')
|
|
25
|
+
const path = require('path')
|
|
26
|
+
const {
|
|
27
|
+
parse_envelope,
|
|
28
|
+
assert_success_envelope,
|
|
29
|
+
} = require('../schema/envelope_test_helpers')
|
|
30
|
+
|
|
31
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
32
|
+
const NODE = process.execPath
|
|
33
|
+
|
|
34
|
+
// Minimal stub-server harness. Each test installs a route table that maps
|
|
35
|
+
// `${METHOD} ${PATH}` → handler({ url, body }) returning { status, body }.
|
|
36
|
+
// The body is always JSON-encoded with the new six-key envelope shape.
|
|
37
|
+
//
|
|
38
|
+
// IMPORTANT: every response carries `Connection: close`. Without this,
|
|
39
|
+
// undici's connection pool keeps the socket warm and prevents the CLI
|
|
40
|
+
// child process from exiting (the request-and-response cycle finishes but
|
|
41
|
+
// the idle keep-alive socket keeps the event loop alive, so spawnSync
|
|
42
|
+
// hangs until its timeout fires). Closing per response makes the child
|
|
43
|
+
// exit naturally as soon as the run() promise resolves.
|
|
44
|
+
const make_stub = () => {
|
|
45
|
+
const routes = new Map()
|
|
46
|
+
const server = http.createServer((req, res) => {
|
|
47
|
+
let body = ''
|
|
48
|
+
req.on('data', c => { body += c })
|
|
49
|
+
req.on('end', () => {
|
|
50
|
+
const key = `${req.method} ${req.url.split('?')[0]}`
|
|
51
|
+
const handler = routes.get(key)
|
|
52
|
+
const close_headers = { 'Content-Type': 'application/json', 'Connection': 'close' }
|
|
53
|
+
if (!handler) {
|
|
54
|
+
res.writeHead(404, close_headers)
|
|
55
|
+
res.end(JSON.stringify({
|
|
56
|
+
ok: false,
|
|
57
|
+
data: {},
|
|
58
|
+
error: { code: 'NOT_FOUND', message: `No stub for ${key}` },
|
|
59
|
+
next_step: {},
|
|
60
|
+
warnings: [],
|
|
61
|
+
meta: { command: key, exit_code: 1, envelope_schema_version: '1.0.0', api_version: 'stub' },
|
|
62
|
+
}))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
let parsed_body = null
|
|
66
|
+
try { parsed_body = body ? JSON.parse(body) : null } catch (_) { /* leave null */ }
|
|
67
|
+
const out = handler({ url: req.url, body: parsed_body, headers: req.headers })
|
|
68
|
+
res.writeHead(out.status || 200, close_headers)
|
|
69
|
+
res.end(JSON.stringify(out.body))
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
// Disable keep-alive at the server level too — belt-and-braces against
|
|
73
|
+
// any client that ignores the Connection: close header.
|
|
74
|
+
server.keepAliveTimeout = 1
|
|
75
|
+
return {
|
|
76
|
+
server,
|
|
77
|
+
route: (key, handler) => { routes.set(key, handler) },
|
|
78
|
+
start: () => new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve(server.address().port))),
|
|
79
|
+
close: () => new Promise(resolve => server.close(() => resolve())),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Streaming spawn instead of spawnSync. The CLI's success path returns
|
|
84
|
+
// without calling process.exit so the Node event loop holds the process
|
|
85
|
+
// open until undici's keep-alive sockets idle out (~30s). spawnSync only
|
|
86
|
+
// captures output AFTER the child exits, so it would hang. Streaming
|
|
87
|
+
// captures stdout incrementally and we kill the child as soon as we've
|
|
88
|
+
// seen a complete JSON envelope (or stderr indicates an error path).
|
|
89
|
+
const run_cli = (args, env_override = {}) => new Promise((resolve) => {
|
|
90
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
91
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
92
|
+
const child = spawn(NODE, [CLI, ...final_args], {
|
|
93
|
+
env: { ...process.env, NO_COLOR: '1', CI: '', ...env_override },
|
|
94
|
+
})
|
|
95
|
+
let stdout = ''
|
|
96
|
+
let stderr = ''
|
|
97
|
+
let settled = false
|
|
98
|
+
const settle = (code) => {
|
|
99
|
+
if (settled) return
|
|
100
|
+
settled = true
|
|
101
|
+
try { child.kill('SIGTERM') } catch (_) { /* ignore */ }
|
|
102
|
+
resolve({ stdout, stderr, code })
|
|
103
|
+
}
|
|
104
|
+
child.stdout.on('data', (d) => {
|
|
105
|
+
stdout += d.toString()
|
|
106
|
+
// As soon as we've seen what looks like a complete JSON document on
|
|
107
|
+
// stdout, the command has emitted its envelope. Settle with code 0
|
|
108
|
+
// (the visible code; non-zero error paths exit explicitly and we
|
|
109
|
+
// catch those via the `exit` handler below).
|
|
110
|
+
if (stdout.trim().endsWith('}')) {
|
|
111
|
+
try {
|
|
112
|
+
JSON.parse(stdout)
|
|
113
|
+
settle(0)
|
|
114
|
+
} catch (_) { /* not yet a full envelope, keep buffering */ }
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
child.stderr.on('data', (d) => { stderr += d.toString() })
|
|
118
|
+
child.on('exit', (code) => settle(code))
|
|
119
|
+
// Hard ceiling — should never fire under normal conditions.
|
|
120
|
+
setTimeout(() => settle(null), 15000)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-api-stub-'))
|
|
124
|
+
|
|
125
|
+
// Build a canonical success envelope for the stub.
|
|
126
|
+
const success_envelope = (data, opts = {}) => ({
|
|
127
|
+
ok: true,
|
|
128
|
+
data,
|
|
129
|
+
error: {},
|
|
130
|
+
next_step: opts.next_step || {},
|
|
131
|
+
warnings: opts.warnings || [],
|
|
132
|
+
meta: {
|
|
133
|
+
command: opts.command || 'stub',
|
|
134
|
+
exit_code: 0,
|
|
135
|
+
envelope_schema_version: '1.0.0',
|
|
136
|
+
api_version: 'stub-5.0.0',
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ─── search ────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe('CLI ↔ API: /repos:search envelope (§ 15.3.1)', () => {
|
|
143
|
+
let stub, port
|
|
144
|
+
|
|
145
|
+
before(async () => {
|
|
146
|
+
stub = make_stub()
|
|
147
|
+
port = await stub.start()
|
|
148
|
+
})
|
|
149
|
+
after(async () => { await stub.close() })
|
|
150
|
+
|
|
151
|
+
it('renders results when the API places metadata inside data (post-§ 15.3.1)', async () => {
|
|
152
|
+
stub.route('POST /repos:search', () => ({
|
|
153
|
+
status: 200,
|
|
154
|
+
body: success_envelope({
|
|
155
|
+
query: 'deploy aws',
|
|
156
|
+
mode: 'semantic',
|
|
157
|
+
results: [{
|
|
158
|
+
name: 'deploy-aws',
|
|
159
|
+
workspace_slug: 'acme',
|
|
160
|
+
full_name: 'acme/deploy-aws',
|
|
161
|
+
description: 'Ship a service to AWS Lambda',
|
|
162
|
+
match_quality: 'strong',
|
|
163
|
+
quality_score: 80,
|
|
164
|
+
star_count: 12,
|
|
165
|
+
}],
|
|
166
|
+
count: 1,
|
|
167
|
+
match_notice: null,
|
|
168
|
+
workspace_match: null,
|
|
169
|
+
intent_id: 'int-abc-123',
|
|
170
|
+
}, { command: 'POST /repos:search' }),
|
|
171
|
+
}))
|
|
172
|
+
const { code, stdout } = await run_cli(
|
|
173
|
+
['search', 'deploy aws', '--json', '--limit', '10'],
|
|
174
|
+
{ HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}` }
|
|
175
|
+
)
|
|
176
|
+
assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
|
|
177
|
+
const env = parse_envelope(stdout, 'search semantic')
|
|
178
|
+
assert_success_envelope(env)
|
|
179
|
+
assert.strictEqual(env.data.mode, 'semantic')
|
|
180
|
+
assert.strictEqual(env.data.count, 1)
|
|
181
|
+
assert.strictEqual(env.data.results[0].name, 'deploy-aws')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('forwards the rerank protocol through next_step.context.* (post-§ 15.3.1)', async () => {
|
|
185
|
+
stub.route('POST /repos:search', () => ({
|
|
186
|
+
status: 200,
|
|
187
|
+
body: success_envelope({
|
|
188
|
+
query: 'deploy aws',
|
|
189
|
+
mode: 'semantic',
|
|
190
|
+
results: [
|
|
191
|
+
{ name: 'deploy-aws', workspace_slug: 'acme', full_name: 'acme/deploy-aws', match_quality: 'good', quality_score: 70, star_count: 5 },
|
|
192
|
+
{ name: 'aws-lambda', workspace_slug: 'acme', full_name: 'acme/aws-lambda', match_quality: 'good', quality_score: 65, star_count: 3 },
|
|
193
|
+
],
|
|
194
|
+
count: 2,
|
|
195
|
+
match_notice: null,
|
|
196
|
+
}, {
|
|
197
|
+
command: 'POST /repos:search',
|
|
198
|
+
next_step: {
|
|
199
|
+
kind: 'continuation',
|
|
200
|
+
action: 'rank_digests_inline',
|
|
201
|
+
instructions: 'Rank these per the system prompt.',
|
|
202
|
+
context: {
|
|
203
|
+
rerank_digests: [
|
|
204
|
+
{ candidate_id: 1, digest: 'deploy aws lambda' },
|
|
205
|
+
{ candidate_id: 2, digest: 'aws lambda functions' },
|
|
206
|
+
],
|
|
207
|
+
rerank_system_prompt: 'You are a ranker.',
|
|
208
|
+
rerank_prompt_version: 'v3',
|
|
209
|
+
rerank_response_schema: { type: 'object', properties: { ranking: { type: 'array' } } },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
}))
|
|
214
|
+
const { code, stdout } = await run_cli(
|
|
215
|
+
['search', 'deploy aws', '--with-rerank', '--json', '--limit', '50'],
|
|
216
|
+
{ HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}` }
|
|
217
|
+
)
|
|
218
|
+
assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
|
|
219
|
+
const env = parse_envelope(stdout, 'search rerank')
|
|
220
|
+
assert_success_envelope(env)
|
|
221
|
+
// Rerank payload reached the CLI emit (the CLI flattens into data.rerank_*).
|
|
222
|
+
assert.ok(Array.isArray(env.data.rerank_digests))
|
|
223
|
+
assert.strictEqual(env.data.rerank_digests.length, 2)
|
|
224
|
+
assert.strictEqual(env.data.rerank_system_prompt, 'You are a ranker.')
|
|
225
|
+
assert.strictEqual(env.data.rerank_prompt_version, 'v3')
|
|
226
|
+
// CLI emits its own next_step pointing at rank_digests_inline.
|
|
227
|
+
assert.strictEqual(env.next_step.action, 'rank_digests_inline')
|
|
228
|
+
assert.strictEqual(env.next_step.kind, 'continuation')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('surfaces match_notice into the emitted envelope', async () => {
|
|
232
|
+
stub.route('POST /repos:search', () => ({
|
|
233
|
+
status: 200,
|
|
234
|
+
body: success_envelope({
|
|
235
|
+
query: 'deploy',
|
|
236
|
+
mode: 'semantic',
|
|
237
|
+
results: [],
|
|
238
|
+
count: 0,
|
|
239
|
+
match_notice: 'Ambiguous — multiple meanings detected',
|
|
240
|
+
}, { command: 'POST /repos:search' }),
|
|
241
|
+
}))
|
|
242
|
+
const { code, stdout } = await run_cli(
|
|
243
|
+
['search', 'deploy', '--json', '--limit', '10'],
|
|
244
|
+
{ HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}` }
|
|
245
|
+
)
|
|
246
|
+
assert.strictEqual(code, 0)
|
|
247
|
+
const env = parse_envelope(stdout, 'search match_notice')
|
|
248
|
+
assert_success_envelope(env)
|
|
249
|
+
assert.match(env.data.match_notice, /ambiguous/i)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// ─── feedback ──────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe('CLI ↔ API: POST /feedback envelope (§ 15.3.4)', () => {
|
|
256
|
+
let stub, port, tmp_xdg
|
|
257
|
+
|
|
258
|
+
before(async () => {
|
|
259
|
+
stub = make_stub()
|
|
260
|
+
port = await stub.start()
|
|
261
|
+
tmp_xdg = make_tmp()
|
|
262
|
+
// Stub credentials so the CLI sends auth.
|
|
263
|
+
const creds_dir = path.join(tmp_xdg, 'happyskills')
|
|
264
|
+
fs.mkdirSync(creds_dir, { recursive: true })
|
|
265
|
+
const payload = { email: 'test@example.com', 'cognito:username': 'testuser', sub: 'test-sub-123' }
|
|
266
|
+
const fake_jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fakesig`
|
|
267
|
+
fs.writeFileSync(path.join(creds_dir, 'credentials.json'), JSON.stringify({
|
|
268
|
+
id_token: fake_jwt,
|
|
269
|
+
access_token: 'fake-access',
|
|
270
|
+
refresh_token: 'fake-refresh',
|
|
271
|
+
expires_in: 3600,
|
|
272
|
+
stored_at: new Date().toISOString(),
|
|
273
|
+
}, null, '\t'))
|
|
274
|
+
})
|
|
275
|
+
after(async () => {
|
|
276
|
+
await stub.close()
|
|
277
|
+
fs.rmSync(tmp_xdg, { recursive: true, force: true })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('reads next_step.context.{feedback_id, attachments_supported, max_attachments} from the hoisted envelope', async () => {
|
|
281
|
+
stub.route('POST /feedback', ({ body }) => ({
|
|
282
|
+
status: 201,
|
|
283
|
+
body: {
|
|
284
|
+
ok: true,
|
|
285
|
+
data: {
|
|
286
|
+
feedback: {
|
|
287
|
+
id: 'fb-001',
|
|
288
|
+
subject: body?.subject || 'A bug',
|
|
289
|
+
body: body?.body || 'It broke.',
|
|
290
|
+
category: body?.category || 'bug',
|
|
291
|
+
created_at: '2026-05-28T00:00:00Z',
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
error: {},
|
|
295
|
+
next_step: {
|
|
296
|
+
kind: 'continuation',
|
|
297
|
+
action: 'attach_screenshot',
|
|
298
|
+
instructions: 'Thanks — feedback recorded. The principal may optionally attach a screenshot.',
|
|
299
|
+
context: {
|
|
300
|
+
feedback_id: 'fb-001',
|
|
301
|
+
attachments_supported: true,
|
|
302
|
+
max_attachments: 5,
|
|
303
|
+
message: 'Want to attach a screenshot?',
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
warnings: [],
|
|
307
|
+
meta: { command: 'POST /feedback', exit_code: 0, envelope_schema_version: '1.0.0', api_version: 'stub-5.0.0' },
|
|
308
|
+
},
|
|
309
|
+
}))
|
|
310
|
+
const { code, stdout } = await run_cli(
|
|
311
|
+
['feedback', 'bug', 'It broke.', '--subject', 'A bug', '--json'],
|
|
312
|
+
{ HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}`, XDG_CONFIG_HOME: tmp_xdg }
|
|
313
|
+
)
|
|
314
|
+
assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
|
|
315
|
+
const env = parse_envelope(stdout, 'feedback create')
|
|
316
|
+
assert_success_envelope(env)
|
|
317
|
+
assert.strictEqual(env.data.feedback.id, 'fb-001')
|
|
318
|
+
// The CLI's emitted next_step must carry the attach_screenshot action.
|
|
319
|
+
assert.strictEqual(env.next_step.action, 'attach_screenshot')
|
|
320
|
+
assert.strictEqual(env.next_step.context.feedback_id, 'fb-001')
|
|
321
|
+
assert.strictEqual(env.next_step.context.attachments_supported, true)
|
|
322
|
+
assert.strictEqual(env.next_step.context.max_attachments, 5)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// ─── device flow polling ───────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
describe('CLI ↔ API: /auth/device/token envelope (§ 15.3.2)', () => {
|
|
329
|
+
let stub, port, tmp_xdg
|
|
330
|
+
|
|
331
|
+
before(async () => {
|
|
332
|
+
stub = make_stub()
|
|
333
|
+
port = await stub.start()
|
|
334
|
+
tmp_xdg = make_tmp()
|
|
335
|
+
})
|
|
336
|
+
after(async () => {
|
|
337
|
+
await stub.close()
|
|
338
|
+
fs.rmSync(tmp_xdg, { recursive: true, force: true })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('completes a login that AUTHORIZATION_PENDING-polls once then succeeds', () => {
|
|
342
|
+
// First a /device/start, then /device/token returns PENDING once,
|
|
343
|
+
// then succeeds with a token. The login command opens a browser by
|
|
344
|
+
// default; we use --device flag... actually login doesn't have a
|
|
345
|
+
// browser-skip flag for tests. Instead we exercise the polling
|
|
346
|
+
// envelope directly by spawning the binary with the device URL set
|
|
347
|
+
// and a special flag combination. For this test we verify just the
|
|
348
|
+
// envelope-parsing piece — that the new shape works when the API
|
|
349
|
+
// emits AUTHORIZATION_PENDING via the canonical envelope.
|
|
350
|
+
//
|
|
351
|
+
// We test the device_token client module in isolation to keep this
|
|
352
|
+
// test deterministic (the full browser flow spawns `open`).
|
|
353
|
+
const { device_token } = require('../api/auth')
|
|
354
|
+
let call_count = 0
|
|
355
|
+
stub.route('POST /auth/device/token', () => {
|
|
356
|
+
call_count += 1
|
|
357
|
+
if (call_count === 1) {
|
|
358
|
+
return {
|
|
359
|
+
status: 202,
|
|
360
|
+
body: {
|
|
361
|
+
ok: false,
|
|
362
|
+
data: {},
|
|
363
|
+
error: { code: 'AUTHORIZATION_PENDING', message: 'Waiting for user authorization' },
|
|
364
|
+
next_step: {
|
|
365
|
+
kind: 'recovery',
|
|
366
|
+
action: 'retry',
|
|
367
|
+
instructions: 'Authorization is still pending. Poll again after the suggested interval.',
|
|
368
|
+
context: { retry_after_seconds: 2 },
|
|
369
|
+
},
|
|
370
|
+
warnings: [],
|
|
371
|
+
meta: { command: 'POST /auth/device/token', exit_code: 0, envelope_schema_version: '1.0.0', api_version: 'stub-5.0.0' },
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
status: 200,
|
|
377
|
+
body: {
|
|
378
|
+
ok: true,
|
|
379
|
+
data: {
|
|
380
|
+
id_token: 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhYmMifQ.sig',
|
|
381
|
+
access_token: 'access-xyz',
|
|
382
|
+
refresh_token: 'refresh-xyz',
|
|
383
|
+
expires_in: 3600,
|
|
384
|
+
},
|
|
385
|
+
error: {},
|
|
386
|
+
next_step: {},
|
|
387
|
+
warnings: [],
|
|
388
|
+
meta: { command: 'POST /auth/device/token', exit_code: 0, envelope_schema_version: '1.0.0', api_version: 'stub-5.0.0' },
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
// Set the API URL so the auth client targets the stub.
|
|
393
|
+
const prev = process.env.HAPPYSKILLS_API_URL
|
|
394
|
+
process.env.HAPPYSKILLS_API_URL = `http://127.0.0.1:${port}`
|
|
395
|
+
// Re-require the client module since it caches API_URL via constants.
|
|
396
|
+
// In practice this only matters if the test runs in a worker that has
|
|
397
|
+
// already cached the client — we accept that and use the env var
|
|
398
|
+
// directly via the request.
|
|
399
|
+
return (async () => {
|
|
400
|
+
try {
|
|
401
|
+
const [pending_err, pending] = await device_token('test-device-code')
|
|
402
|
+
assert.equal(pending_err, null, `unexpected error on pending poll: ${pending_err && pending_err.map(e => e.message).join(', ')}`)
|
|
403
|
+
assert.ok(pending && pending.error && pending.error.code === 'AUTHORIZATION_PENDING',
|
|
404
|
+
`expected AUTHORIZATION_PENDING surface, got ${JSON.stringify(pending)}`)
|
|
405
|
+
const [success_err, success] = await device_token('test-device-code')
|
|
406
|
+
assert.equal(success_err, null, `unexpected error on success poll: ${success_err && success_err.map(e => e.message).join(', ')}`)
|
|
407
|
+
assert.ok(success.id_token, 'expected id_token in success response')
|
|
408
|
+
assert.strictEqual(success.access_token, 'access-xyz')
|
|
409
|
+
assert.strictEqual(success.refresh_token, 'refresh-xyz')
|
|
410
|
+
} finally {
|
|
411
|
+
if (prev === undefined) delete process.env.HAPPYSKILLS_API_URL
|
|
412
|
+
else process.env.HAPPYSKILLS_API_URL = prev
|
|
413
|
+
}
|
|
414
|
+
})()
|
|
415
|
+
})
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// ─── array-returning endpoints — sole-results unwrap ───────────────────────
|
|
419
|
+
//
|
|
420
|
+
// Spec § 4.3 mandates `data` is always an object — arrays are wrapped as
|
|
421
|
+
// `{ results: [...] }`. The CLI API client strips that wrapper when it's
|
|
422
|
+
// the sole payload (see cli/src/api/client.js) so legacy CLI consumers
|
|
423
|
+
// (`people list`, `groups list`, `access list`, etc.) keep seeing the
|
|
424
|
+
// bare array they always did. This test exercises the round-trip for the
|
|
425
|
+
// `people search` command which iterates the returned array directly.
|
|
426
|
+
|
|
427
|
+
describe('CLI ↔ API: array-returning endpoints unwrap sole `results`', () => {
|
|
428
|
+
let stub, port, tmp_xdg
|
|
429
|
+
|
|
430
|
+
before(async () => {
|
|
431
|
+
stub = make_stub()
|
|
432
|
+
port = await stub.start()
|
|
433
|
+
tmp_xdg = make_tmp()
|
|
434
|
+
const creds_dir = path.join(tmp_xdg, 'happyskills')
|
|
435
|
+
fs.mkdirSync(creds_dir, { recursive: true })
|
|
436
|
+
const payload = { email: 'test@example.com', 'cognito:username': 'testuser', sub: 'test-sub-123' }
|
|
437
|
+
const fake_jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fakesig`
|
|
438
|
+
fs.writeFileSync(path.join(creds_dir, 'credentials.json'), JSON.stringify({
|
|
439
|
+
id_token: fake_jwt,
|
|
440
|
+
access_token: 'fake-access',
|
|
441
|
+
refresh_token: 'fake-refresh',
|
|
442
|
+
expires_in: 3600,
|
|
443
|
+
stored_at: new Date().toISOString(),
|
|
444
|
+
}, null, '\t'))
|
|
445
|
+
})
|
|
446
|
+
after(async () => {
|
|
447
|
+
await stub.close()
|
|
448
|
+
fs.rmSync(tmp_xdg, { recursive: true, force: true })
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('iterates `/users/search` rows that arrive wrapped as data.results', async () => {
|
|
452
|
+
stub.route('GET /users/search', () => ({
|
|
453
|
+
status: 200,
|
|
454
|
+
body: success_envelope({
|
|
455
|
+
results: [
|
|
456
|
+
{ username: 'alice', email: 'a@x.com', display_name: 'Alice' },
|
|
457
|
+
{ username: 'bob', email: 'b@x.com', display_name: 'Bob' },
|
|
458
|
+
],
|
|
459
|
+
}, { command: 'GET /users/search' }),
|
|
460
|
+
}))
|
|
461
|
+
const { code, stdout } = await run_cli(
|
|
462
|
+
['people', 'search', 'al', '--json'],
|
|
463
|
+
{ HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}`, XDG_CONFIG_HOME: tmp_xdg }
|
|
464
|
+
)
|
|
465
|
+
assert.strictEqual(code, 0, `expected exit 0, got ${code}; stdout:\n${stdout}`)
|
|
466
|
+
const env = parse_envelope(stdout, 'people search')
|
|
467
|
+
assert_success_envelope(env)
|
|
468
|
+
// The CLI re-emits the rows under data.results (canonical array key).
|
|
469
|
+
const rows = Array.isArray(env.data) ? env.data : env.data.results
|
|
470
|
+
assert.ok(Array.isArray(rows), `expected an array of users; got ${JSON.stringify(env.data)}`)
|
|
471
|
+
assert.strictEqual(rows.length, 2)
|
|
472
|
+
assert.strictEqual(rows[0].username, 'alice')
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('text mode renders the rows without crashing on .map/.length', async () => {
|
|
476
|
+
// Pre-fix: client returned `{ results: [...] }`, so `users.map` was
|
|
477
|
+
// undefined and the text path threw TypeError. This test pins down
|
|
478
|
+
// the regression by spawning the command WITHOUT --json (so the
|
|
479
|
+
// run_cli helper appends --text per its default), and asserting
|
|
480
|
+
// the process exits 0 with both usernames visible on stdout.
|
|
481
|
+
stub.route('GET /users/search', () => ({
|
|
482
|
+
status: 200,
|
|
483
|
+
body: success_envelope({
|
|
484
|
+
results: [
|
|
485
|
+
{ username: 'alice', email: 'a@x.com', display_name: 'Alice' },
|
|
486
|
+
{ username: 'bob', email: 'b@x.com', display_name: 'Bob' },
|
|
487
|
+
],
|
|
488
|
+
}, { command: 'GET /users/search' }),
|
|
489
|
+
}))
|
|
490
|
+
const { code, stdout, stderr } = await run_cli(
|
|
491
|
+
['people', 'search', 'al'],
|
|
492
|
+
{ HAPPYSKILLS_API_URL: `http://127.0.0.1:${port}`, XDG_CONFIG_HOME: tmp_xdg }
|
|
493
|
+
)
|
|
494
|
+
assert.strictEqual(code, 0, `expected exit 0; stderr:\n${stderr}\nstdout:\n${stdout}`)
|
|
495
|
+
assert.doesNotMatch(stderr, /TypeError|is not a function|Cannot read/i, `text mode threw:\n${stderr}`)
|
|
496
|
+
assert.match(stdout, /alice/)
|
|
497
|
+
assert.match(stdout, /bob/)
|
|
498
|
+
})
|
|
499
|
+
})
|
|
@@ -15,17 +15,24 @@ const { spawnSync } = require('child_process')
|
|
|
15
15
|
const fs = require('fs')
|
|
16
16
|
const os = require('os')
|
|
17
17
|
const path = require('path')
|
|
18
|
+
const {
|
|
19
|
+
parse_envelope,
|
|
20
|
+
assert_success_envelope,
|
|
21
|
+
} = require('../schema/envelope_test_helpers')
|
|
18
22
|
|
|
19
23
|
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
20
24
|
const NODE = process.execPath
|
|
21
25
|
|
|
22
26
|
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-bump-test-'))
|
|
23
27
|
const run = (args, opts) => {
|
|
24
|
-
const
|
|
28
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
29
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
30
|
+
const result = spawnSync(NODE, [CLI, ...final_args], {
|
|
25
31
|
env: {
|
|
26
32
|
...process.env,
|
|
27
|
-
NO_COLOR: '1',
|
|
33
|
+
NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
|
|
28
34
|
HAPPYSKILLS_API_URL: 'http://localhost:0',
|
|
35
|
+
CI: '',
|
|
29
36
|
...(opts?.env || {})
|
|
30
37
|
},
|
|
31
38
|
encoding: 'utf-8',
|
|
@@ -34,9 +41,11 @@ const run = (args, opts) => {
|
|
|
34
41
|
})
|
|
35
42
|
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
36
43
|
}
|
|
44
|
+
// Parse + envelope-validate. Bump/list emit success envelopes here.
|
|
37
45
|
const parse_json = (stdout, label) => {
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
const env = parse_envelope(stdout, label)
|
|
47
|
+
assert_success_envelope(env, label)
|
|
48
|
+
return env
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
// Scaffold a project with one locked-and-installed skill.
|