opencode-pilot 0.1.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/.devcontainer/devcontainer.json +16 -0
- package/.github/workflows/ci.yml +67 -0
- package/.releaserc.cjs +28 -0
- package/AGENTS.md +71 -0
- package/CONTRIBUTING.md +102 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/opencode-pilot +809 -0
- package/dist/opencode-ntfy.tar.gz +0 -0
- package/examples/config.yaml +73 -0
- package/examples/templates/default.md +7 -0
- package/examples/templates/devcontainer.md +7 -0
- package/examples/templates/review-feedback.md +7 -0
- package/examples/templates/review.md +15 -0
- package/install.sh +246 -0
- package/package.json +40 -0
- package/plugin/config.js +76 -0
- package/plugin/index.js +260 -0
- package/plugin/logger.js +125 -0
- package/plugin/notifier.js +110 -0
- package/service/actions.js +334 -0
- package/service/io.opencode.ntfy.plist +29 -0
- package/service/logger.js +82 -0
- package/service/poll-service.js +246 -0
- package/service/poller.js +339 -0
- package/service/readiness.js +234 -0
- package/service/repo-config.js +222 -0
- package/service/server.js +1523 -0
- package/service/utils.js +21 -0
- package/test/run_tests.bash +34 -0
- package/test/test_actions.bash +263 -0
- package/test/test_cli.bash +161 -0
- package/test/test_config.bash +438 -0
- package/test/test_helper.bash +140 -0
- package/test/test_logger.bash +401 -0
- package/test/test_notifier.bash +310 -0
- package/test/test_plist.bash +125 -0
- package/test/test_plugin.bash +952 -0
- package/test/test_poll_service.bash +179 -0
- package/test/test_poller.bash +120 -0
- package/test/test_readiness.bash +313 -0
- package/test/test_repo_config.bash +406 -0
- package/test/test_service.bash +1342 -0
- package/test/unit/actions.test.js +235 -0
- package/test/unit/config.test.js +86 -0
- package/test/unit/paths.test.js +77 -0
- package/test/unit/poll-service.test.js +142 -0
- package/test/unit/poller.test.js +347 -0
- package/test/unit/repo-config.test.js +441 -0
- package/test/unit/utils.test.js +53 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for repo-config.js - configuration for repos, sources, tools, templates
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe, beforeEach, afterEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
10
|
+
|
|
11
|
+
describe('repo-config.js', () => {
|
|
12
|
+
let tempDir;
|
|
13
|
+
let configPath;
|
|
14
|
+
let templatesDir;
|
|
15
|
+
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
tempDir = mkdtempSync(join(tmpdir(), 'opencode-pilot-test-'));
|
|
18
|
+
configPath = join(tempDir, 'config.yaml');
|
|
19
|
+
templatesDir = join(tempDir, 'templates');
|
|
20
|
+
mkdirSync(templatesDir);
|
|
21
|
+
|
|
22
|
+
// Clear module cache to get fresh config each test
|
|
23
|
+
const { clearConfigCache } = await import('../../service/repo-config.js');
|
|
24
|
+
clearConfigCache();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('error handling', () => {
|
|
32
|
+
test('handles malformed YAML gracefully', async () => {
|
|
33
|
+
// Write invalid YAML (unbalanced quotes)
|
|
34
|
+
writeFileSync(configPath, `
|
|
35
|
+
repos:
|
|
36
|
+
myorg/backend:
|
|
37
|
+
path: "~/code/backend
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
41
|
+
|
|
42
|
+
// Should not throw, should return empty
|
|
43
|
+
loadRepoConfig(configPath);
|
|
44
|
+
const sources = getSources();
|
|
45
|
+
|
|
46
|
+
assert.deepStrictEqual(sources, []);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('handles empty file gracefully', async () => {
|
|
50
|
+
writeFileSync(configPath, '');
|
|
51
|
+
|
|
52
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
53
|
+
loadRepoConfig(configPath);
|
|
54
|
+
const sources = getSources();
|
|
55
|
+
|
|
56
|
+
assert.deepStrictEqual(sources, []);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('repos', () => {
|
|
61
|
+
test('gets repo config with path', async () => {
|
|
62
|
+
writeFileSync(configPath, `
|
|
63
|
+
repos:
|
|
64
|
+
myorg/backend:
|
|
65
|
+
path: ~/code/backend
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
|
|
69
|
+
loadRepoConfig(configPath);
|
|
70
|
+
const config = getRepoConfig('myorg/backend');
|
|
71
|
+
|
|
72
|
+
assert.strictEqual(config.path, '~/code/backend');
|
|
73
|
+
assert.strictEqual(config.repo_path, '~/code/backend');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('returns empty object for unknown repo', async () => {
|
|
77
|
+
writeFileSync(configPath, `
|
|
78
|
+
repos:
|
|
79
|
+
myorg/backend:
|
|
80
|
+
path: ~/code/backend
|
|
81
|
+
`);
|
|
82
|
+
|
|
83
|
+
const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
|
|
84
|
+
loadRepoConfig(configPath);
|
|
85
|
+
const config = getRepoConfig('unknown/repo');
|
|
86
|
+
|
|
87
|
+
assert.deepStrictEqual(config, {});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('supports YAML anchors for shared config', async () => {
|
|
91
|
+
writeFileSync(configPath, `
|
|
92
|
+
repos:
|
|
93
|
+
myorg/backend: &default-repo
|
|
94
|
+
path: ~/code/backend
|
|
95
|
+
prompt: devcontainer
|
|
96
|
+
session:
|
|
97
|
+
name: "issue-{number}"
|
|
98
|
+
|
|
99
|
+
myorg/frontend:
|
|
100
|
+
<<: *default-repo
|
|
101
|
+
path: ~/code/frontend
|
|
102
|
+
`);
|
|
103
|
+
|
|
104
|
+
const { loadRepoConfig, getRepoConfig } = await import('../../service/repo-config.js');
|
|
105
|
+
loadRepoConfig(configPath);
|
|
106
|
+
|
|
107
|
+
const backend = getRepoConfig('myorg/backend');
|
|
108
|
+
const frontend = getRepoConfig('myorg/frontend');
|
|
109
|
+
|
|
110
|
+
// Backend has its values
|
|
111
|
+
assert.strictEqual(backend.path, '~/code/backend');
|
|
112
|
+
assert.strictEqual(backend.prompt, 'devcontainer');
|
|
113
|
+
assert.deepStrictEqual(backend.session, { name: 'issue-{number}' });
|
|
114
|
+
|
|
115
|
+
// Frontend inherits from anchor, overrides path
|
|
116
|
+
assert.strictEqual(frontend.path, '~/code/frontend');
|
|
117
|
+
assert.strictEqual(frontend.prompt, 'devcontainer');
|
|
118
|
+
assert.deepStrictEqual(frontend.session, { name: 'issue-{number}' });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('sources', () => {
|
|
123
|
+
test('returns sources array from top-level', async () => {
|
|
124
|
+
writeFileSync(configPath, `
|
|
125
|
+
sources:
|
|
126
|
+
- name: my-issues
|
|
127
|
+
tool:
|
|
128
|
+
mcp: github
|
|
129
|
+
name: github_search_issues
|
|
130
|
+
args:
|
|
131
|
+
q: "is:issue assignee:@me"
|
|
132
|
+
item:
|
|
133
|
+
id: "github:{repository.full_name}#{number}"
|
|
134
|
+
repo: "{repository.full_name}"
|
|
135
|
+
`);
|
|
136
|
+
|
|
137
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
138
|
+
loadRepoConfig(configPath);
|
|
139
|
+
const sources = getSources();
|
|
140
|
+
|
|
141
|
+
assert.strictEqual(sources.length, 1);
|
|
142
|
+
assert.strictEqual(sources[0].name, 'my-issues');
|
|
143
|
+
assert.deepStrictEqual(sources[0].tool, { mcp: 'github', name: 'github_search_issues' });
|
|
144
|
+
assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('returns empty array when no sources', async () => {
|
|
148
|
+
writeFileSync(configPath, `
|
|
149
|
+
repos:
|
|
150
|
+
myorg/backend:
|
|
151
|
+
path: ~/code/backend
|
|
152
|
+
`);
|
|
153
|
+
|
|
154
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
155
|
+
loadRepoConfig(configPath);
|
|
156
|
+
const sources = getSources();
|
|
157
|
+
|
|
158
|
+
assert.deepStrictEqual(sources, []);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('source can reference workflow settings', async () => {
|
|
162
|
+
writeFileSync(configPath, `
|
|
163
|
+
sources:
|
|
164
|
+
- name: reviews
|
|
165
|
+
tool:
|
|
166
|
+
mcp: github
|
|
167
|
+
name: github_search_issues
|
|
168
|
+
args:
|
|
169
|
+
q: "is:pr review-requested:@me"
|
|
170
|
+
item:
|
|
171
|
+
id: "github:{repository.full_name}#{number}"
|
|
172
|
+
repo: "{repository.full_name}"
|
|
173
|
+
prompt: review
|
|
174
|
+
agent: reviewer
|
|
175
|
+
`);
|
|
176
|
+
|
|
177
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
178
|
+
loadRepoConfig(configPath);
|
|
179
|
+
const sources = getSources();
|
|
180
|
+
|
|
181
|
+
assert.strictEqual(sources[0].prompt, 'review');
|
|
182
|
+
assert.strictEqual(sources[0].agent, 'reviewer');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('source can specify multiple repos with working_dir', async () => {
|
|
186
|
+
writeFileSync(configPath, `
|
|
187
|
+
sources:
|
|
188
|
+
- name: cross-repo
|
|
189
|
+
tool:
|
|
190
|
+
mcp: github
|
|
191
|
+
name: github_search_issues
|
|
192
|
+
args:
|
|
193
|
+
q: "is:issue label:multi-repo"
|
|
194
|
+
item:
|
|
195
|
+
id: "github:{number}"
|
|
196
|
+
repos:
|
|
197
|
+
- myorg/backend
|
|
198
|
+
- myorg/frontend
|
|
199
|
+
working_dir: ~/code
|
|
200
|
+
`);
|
|
201
|
+
|
|
202
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
203
|
+
loadRepoConfig(configPath);
|
|
204
|
+
const sources = getSources();
|
|
205
|
+
|
|
206
|
+
assert.deepStrictEqual(sources[0].repos, ['myorg/backend', 'myorg/frontend']);
|
|
207
|
+
assert.strictEqual(sources[0].working_dir, '~/code');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('supports YAML anchors for shared source config', async () => {
|
|
211
|
+
writeFileSync(configPath, `
|
|
212
|
+
sources:
|
|
213
|
+
- name: my-issues
|
|
214
|
+
tool: &github-tool
|
|
215
|
+
mcp: github
|
|
216
|
+
name: github_search_issues
|
|
217
|
+
args:
|
|
218
|
+
q: "is:issue assignee:@me"
|
|
219
|
+
item: &github-item
|
|
220
|
+
id: "github:{repository.full_name}#{number}"
|
|
221
|
+
repo: "{repository.full_name}"
|
|
222
|
+
|
|
223
|
+
- name: review-requests
|
|
224
|
+
tool: *github-tool
|
|
225
|
+
args:
|
|
226
|
+
q: "is:pr review-requested:@me"
|
|
227
|
+
item: *github-item
|
|
228
|
+
repo: "{repository.full_name}"
|
|
229
|
+
prompt: review
|
|
230
|
+
`);
|
|
231
|
+
|
|
232
|
+
const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
|
|
233
|
+
loadRepoConfig(configPath);
|
|
234
|
+
const sources = getSources();
|
|
235
|
+
|
|
236
|
+
assert.strictEqual(sources.length, 2);
|
|
237
|
+
// Both use same tool config via anchor
|
|
238
|
+
assert.deepStrictEqual(sources[0].tool, sources[1].tool);
|
|
239
|
+
assert.deepStrictEqual(sources[0].item, sources[1].item);
|
|
240
|
+
// But have different args and prompts
|
|
241
|
+
assert.notStrictEqual(sources[0].args.q, sources[1].args.q);
|
|
242
|
+
assert.strictEqual(sources[1].prompt, 'review');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('templates', () => {
|
|
247
|
+
test('loads template from templates directory', async () => {
|
|
248
|
+
writeFileSync(join(templatesDir, 'default.md'), '{title}\n\n{body}');
|
|
249
|
+
|
|
250
|
+
const { getTemplate } = await import('../../service/repo-config.js');
|
|
251
|
+
const template = getTemplate('default', templatesDir);
|
|
252
|
+
|
|
253
|
+
assert.strictEqual(template, '{title}\n\n{body}');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('returns null for missing template', async () => {
|
|
257
|
+
const { getTemplate } = await import('../../service/repo-config.js');
|
|
258
|
+
const template = getTemplate('nonexistent', templatesDir);
|
|
259
|
+
|
|
260
|
+
assert.strictEqual(template, null);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('tools mappings', () => {
|
|
265
|
+
test('returns mappings for a tool provider', async () => {
|
|
266
|
+
writeFileSync(configPath, `
|
|
267
|
+
tools:
|
|
268
|
+
linear:
|
|
269
|
+
mappings:
|
|
270
|
+
number: identifier
|
|
271
|
+
body: description
|
|
272
|
+
|
|
273
|
+
sources: []
|
|
274
|
+
`);
|
|
275
|
+
|
|
276
|
+
const { loadRepoConfig, getToolMappings } = await import('../../service/repo-config.js');
|
|
277
|
+
loadRepoConfig(configPath);
|
|
278
|
+
|
|
279
|
+
const mappings = getToolMappings('linear');
|
|
280
|
+
|
|
281
|
+
assert.deepStrictEqual(mappings, {
|
|
282
|
+
number: 'identifier',
|
|
283
|
+
body: 'description'
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('returns null for unknown provider', async () => {
|
|
288
|
+
writeFileSync(configPath, `
|
|
289
|
+
tools:
|
|
290
|
+
github:
|
|
291
|
+
mappings:
|
|
292
|
+
url: html_url
|
|
293
|
+
|
|
294
|
+
sources: []
|
|
295
|
+
`);
|
|
296
|
+
|
|
297
|
+
const { loadRepoConfig, getToolMappings } = await import('../../service/repo-config.js');
|
|
298
|
+
loadRepoConfig(configPath);
|
|
299
|
+
|
|
300
|
+
const mappings = getToolMappings('unknown');
|
|
301
|
+
|
|
302
|
+
assert.strictEqual(mappings, null);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('returns null when no tools section', async () => {
|
|
306
|
+
writeFileSync(configPath, `
|
|
307
|
+
sources: []
|
|
308
|
+
`);
|
|
309
|
+
|
|
310
|
+
const { loadRepoConfig, getToolMappings } = await import('../../service/repo-config.js');
|
|
311
|
+
loadRepoConfig(configPath);
|
|
312
|
+
|
|
313
|
+
const mappings = getToolMappings('github');
|
|
314
|
+
|
|
315
|
+
assert.strictEqual(mappings, null);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('repo resolution for sources', () => {
|
|
320
|
+
test('resolves repo from simple field reference', async () => {
|
|
321
|
+
writeFileSync(configPath, `
|
|
322
|
+
repos:
|
|
323
|
+
myorg/backend:
|
|
324
|
+
path: ~/code/backend
|
|
325
|
+
|
|
326
|
+
sources:
|
|
327
|
+
- name: my-issues
|
|
328
|
+
tool:
|
|
329
|
+
mcp: github
|
|
330
|
+
name: github_search_issues
|
|
331
|
+
args:
|
|
332
|
+
q: "is:issue"
|
|
333
|
+
item:
|
|
334
|
+
id: "github:{number}"
|
|
335
|
+
repo: "{repository.full_name}"
|
|
336
|
+
`);
|
|
337
|
+
|
|
338
|
+
const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
|
|
339
|
+
loadRepoConfig(configPath);
|
|
340
|
+
|
|
341
|
+
const source = getSources()[0];
|
|
342
|
+
const item = { repository: { full_name: 'myorg/backend' }, number: 123 };
|
|
343
|
+
const repos = resolveRepoForItem(source, item);
|
|
344
|
+
|
|
345
|
+
assert.deepStrictEqual(repos, ['myorg/backend']);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('resolves multiple repos from repos array', async () => {
|
|
349
|
+
writeFileSync(configPath, `
|
|
350
|
+
sources:
|
|
351
|
+
- name: cross-repo
|
|
352
|
+
tool:
|
|
353
|
+
mcp: github
|
|
354
|
+
name: github_search_issues
|
|
355
|
+
item:
|
|
356
|
+
id: "github:{number}"
|
|
357
|
+
repos:
|
|
358
|
+
- myorg/backend
|
|
359
|
+
- myorg/frontend
|
|
360
|
+
`);
|
|
361
|
+
|
|
362
|
+
const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
|
|
363
|
+
loadRepoConfig(configPath);
|
|
364
|
+
|
|
365
|
+
const source = getSources()[0];
|
|
366
|
+
const item = { number: 123 };
|
|
367
|
+
const repos = resolveRepoForItem(source, item);
|
|
368
|
+
|
|
369
|
+
assert.deepStrictEqual(repos, ['myorg/backend', 'myorg/frontend']);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('returns empty array when no repo config', async () => {
|
|
373
|
+
writeFileSync(configPath, `
|
|
374
|
+
sources:
|
|
375
|
+
- name: personal-todos
|
|
376
|
+
tool:
|
|
377
|
+
mcp: reminders
|
|
378
|
+
name: list_reminders
|
|
379
|
+
item:
|
|
380
|
+
id: "reminder:{id}"
|
|
381
|
+
`);
|
|
382
|
+
|
|
383
|
+
const { loadRepoConfig, getSources, resolveRepoForItem } = await import('../../service/repo-config.js');
|
|
384
|
+
loadRepoConfig(configPath);
|
|
385
|
+
|
|
386
|
+
const source = getSources()[0];
|
|
387
|
+
const item = { id: 'abc123' };
|
|
388
|
+
const repos = resolveRepoForItem(source, item);
|
|
389
|
+
|
|
390
|
+
assert.deepStrictEqual(repos, []);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('listRepos', () => {
|
|
395
|
+
test('lists all configured repos', async () => {
|
|
396
|
+
writeFileSync(configPath, `
|
|
397
|
+
repos:
|
|
398
|
+
myorg/backend:
|
|
399
|
+
path: ~/code/backend
|
|
400
|
+
myorg/frontend:
|
|
401
|
+
path: ~/code/frontend
|
|
402
|
+
`);
|
|
403
|
+
|
|
404
|
+
const { loadRepoConfig, listRepos } = await import('../../service/repo-config.js');
|
|
405
|
+
loadRepoConfig(configPath);
|
|
406
|
+
const repos = listRepos();
|
|
407
|
+
|
|
408
|
+
assert.deepStrictEqual(repos, ['myorg/backend', 'myorg/frontend']);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe('findRepoByPath', () => {
|
|
413
|
+
test('finds repo by path', async () => {
|
|
414
|
+
writeFileSync(configPath, `
|
|
415
|
+
repos:
|
|
416
|
+
myorg/backend:
|
|
417
|
+
path: ~/code/backend
|
|
418
|
+
`);
|
|
419
|
+
|
|
420
|
+
const { loadRepoConfig, findRepoByPath } = await import('../../service/repo-config.js');
|
|
421
|
+
loadRepoConfig(configPath);
|
|
422
|
+
const repoKey = findRepoByPath('~/code/backend');
|
|
423
|
+
|
|
424
|
+
assert.strictEqual(repoKey, 'myorg/backend');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('returns null for unknown path', async () => {
|
|
428
|
+
writeFileSync(configPath, `
|
|
429
|
+
repos:
|
|
430
|
+
myorg/backend:
|
|
431
|
+
path: ~/code/backend
|
|
432
|
+
`);
|
|
433
|
+
|
|
434
|
+
const { loadRepoConfig, findRepoByPath } = await import('../../service/repo-config.js');
|
|
435
|
+
loadRepoConfig(configPath);
|
|
436
|
+
const repoKey = findRepoByPath('~/code/unknown');
|
|
437
|
+
|
|
438
|
+
assert.strictEqual(repoKey, null);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for utils.js - Shared utility functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
|
|
8
|
+
describe('utils.js', () => {
|
|
9
|
+
describe('getNestedValue', () => {
|
|
10
|
+
test('gets top-level value', async () => {
|
|
11
|
+
const { getNestedValue } = await import('../../service/utils.js');
|
|
12
|
+
|
|
13
|
+
const obj = { name: 'Test', count: 42 };
|
|
14
|
+
|
|
15
|
+
assert.strictEqual(getNestedValue(obj, 'name'), 'Test');
|
|
16
|
+
assert.strictEqual(getNestedValue(obj, 'count'), 42);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('gets nested value with dot notation', async () => {
|
|
20
|
+
const { getNestedValue } = await import('../../service/utils.js');
|
|
21
|
+
|
|
22
|
+
const obj = {
|
|
23
|
+
repository: {
|
|
24
|
+
full_name: 'myorg/backend',
|
|
25
|
+
owner: { login: 'myorg' }
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
assert.strictEqual(getNestedValue(obj, 'repository.full_name'), 'myorg/backend');
|
|
30
|
+
assert.strictEqual(getNestedValue(obj, 'repository.owner.login'), 'myorg');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('returns undefined for missing path', async () => {
|
|
34
|
+
const { getNestedValue } = await import('../../service/utils.js');
|
|
35
|
+
|
|
36
|
+
const obj = { name: 'Test' };
|
|
37
|
+
|
|
38
|
+
assert.strictEqual(getNestedValue(obj, 'missing'), undefined);
|
|
39
|
+
assert.strictEqual(getNestedValue(obj, 'deep.missing.path'), undefined);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('handles null/undefined in path', async () => {
|
|
43
|
+
const { getNestedValue } = await import('../../service/utils.js');
|
|
44
|
+
|
|
45
|
+
const obj = { name: null, empty: { inner: undefined } };
|
|
46
|
+
|
|
47
|
+
assert.strictEqual(getNestedValue(obj, 'name'), null);
|
|
48
|
+
assert.strictEqual(getNestedValue(obj, 'name.anything'), undefined);
|
|
49
|
+
assert.strictEqual(getNestedValue(obj, 'empty.inner'), undefined);
|
|
50
|
+
assert.strictEqual(getNestedValue(obj, 'empty.inner.deep'), undefined);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|