@principles/pd-cli 1.75.0 → 1.76.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.
@@ -1,114 +1,249 @@
1
- import { describe, it, expect } from 'vitest';
1
+ /**
2
+ * runtime-features tests — PRI-305
3
+ *
4
+ * Covers:
5
+ * - JSON purity (single parseable JSON object)
6
+ * - Missing config → defaults with nextAction
7
+ * - Malformed config → fail loud with reason and nextAction
8
+ * - Effective flags from .pd/config.yaml
9
+ * - No secret output
10
+ * - CLI handler: --json stdout purity and exit code
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
14
  import * as fs from 'node:fs';
3
15
  import * as path from 'node:path';
4
16
  import * as os from 'node:os';
5
- import { buildFeatureFlagsStatus } from '../../src/commands/runtime-features.js';
6
- import { loadEffectiveFeatureFlags } from '../../src/services/feature-flag-loader.js';
17
+ import * as yaml from 'js-yaml';
18
+ import { buildRuntimeFeaturesStatus, handleRuntimeFeaturesStatus, type RuntimeFeaturesOutput } from '../../src/commands/runtime-features.js';
19
+
20
+ // ── Helpers ─────────────────────────────────────────────────────────────────
21
+
22
+ function mkTmpDir(): string {
23
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-runtime-features-test-'));
24
+ }
25
+
26
+ function rmTmpDir(dir: string): void {
27
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
28
+ }
29
+
30
+ function writeConfig(workspaceDir: string, content: string): void {
31
+ const configDir = path.join(workspaceDir, '.pd');
32
+ fs.mkdirSync(configDir, { recursive: true });
33
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
34
+ }
35
+
36
+ function makeValidConfigYaml(): string {
37
+ return yaml.dump({
38
+ version: 1,
39
+ features: {
40
+ prompt: { category: 'core', enabled: true },
41
+ code_tool_hook: { category: 'core', enabled: true },
42
+ defer_archive: { category: 'core', enabled: true },
43
+ correction_observer: { category: 'quiet', enabled: false },
44
+ empathy_observer: { category: 'quiet', enabled: false },
45
+ },
46
+ runtimeProfiles: {
47
+ 'openclaw.default': { type: 'openclaw', source: 'default' },
48
+ },
49
+ internalAgents: {
50
+ defaultRuntime: 'openclaw.default',
51
+ agents: {
52
+ diagnostician: { enabled: true },
53
+ dreamer: { enabled: true },
54
+ scribe: { enabled: true },
55
+ artificer: { enabled: true },
56
+ philosopher: { enabled: false },
57
+ evaluator: { enabled: false },
58
+ rolloutReviewer: { enabled: false },
59
+ trainer: { enabled: false },
60
+ correctionObserver: { enabled: false },
61
+ empathyObserver: { enabled: false },
62
+ },
63
+ },
64
+ ui: { diagnostics: { mode: 'simple' } },
65
+ });
66
+ }
67
+
68
+ // ── JSON purity ──────────────────────────────────────────────────────────────
69
+
70
+ describe('JSON purity', () => {
71
+ it('output is a single parseable JSON object', () => {
72
+ const tmp = mkTmpDir();
73
+ try {
74
+ const output = buildRuntimeFeaturesStatus(tmp);
75
+ const json = JSON.stringify(output, null, 2);
76
+ const parsed = JSON.parse(json);
77
+ expect(typeof parsed).toBe('object');
78
+ expect(parsed).not.toBeNull();
79
+ expect(Array.isArray(parsed)).toBe(false);
80
+ } finally { rmTmpDir(tmp); }
81
+ });
82
+ });
7
83
 
8
- describe('buildFeatureFlagsStatus', () => {
9
- it('returns status ok with clean config (no config file)', () => {
10
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
84
+ // ── Missing config → defaults ────────────────────────────────────────────────
85
+
86
+ describe('Missing config defaults', () => {
87
+ it('returns status=ok with source=defaults when config file is absent', () => {
88
+ const tmp = mkTmpDir();
11
89
  try {
12
- const output = buildFeatureFlagsStatus(tmpDir);
90
+ const output = buildRuntimeFeaturesStatus(tmp);
13
91
  expect(output.status).toBe('ok');
14
- expect(output.reason).toBeUndefined();
15
- expect(output.nextAction).toBeUndefined();
16
- expect(output.warnings).toEqual([]);
17
- } finally {
18
- fs.rmSync(tmpDir, { recursive: true, force: true });
19
- }
92
+ expect(output.source).toBe('defaults');
93
+ expect(output.enabledMvpChannels).toContain('prompt');
94
+ expect(output.enabledMvpChannels).toContain('code_tool_hook');
95
+ expect(output.enabledMvpChannels).toContain('defer_archive');
96
+ } finally { rmTmpDir(tmp); }
20
97
  });
98
+ });
21
99
 
22
- it('returns status degraded with reason/nextAction when warnings present', () => {
23
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
24
- const configDir = path.join(tmpDir, '.pd');
25
- fs.mkdirSync(configDir, { recursive: true });
26
- fs.writeFileSync(path.join(configDir, 'feature-flags.yaml'), 'gfi:\n enabled: "yes"\n', 'utf8');
100
+ // ── Malformed config fail loud ────────────────────────────────────────────
27
101
 
102
+ describe('Malformed config → fail loud', () => {
103
+ it('returns status=failed with reason and nextAction for YAML parse error', () => {
104
+ const tmp = mkTmpDir();
105
+ writeConfig(tmp, 'version: [unterminated');
28
106
  try {
29
- const output = buildFeatureFlagsStatus(tmpDir);
30
- expect(output.status).toBe('degraded');
31
- expect(output.reason).toBeDefined();
32
- expect(output.nextAction).toBeDefined();
33
- expect(output.warnings.length).toBeGreaterThan(0);
34
- } finally {
35
- fs.rmSync(tmpDir, { recursive: true, force: true });
36
- }
107
+ const output = buildRuntimeFeaturesStatus(tmp);
108
+ expect(output.status).toBe('failed');
109
+ expect(output.source).toBe('malformed');
110
+ expect(output.reason).toBeTruthy();
111
+ expect(output.nextAction).toBeTruthy();
112
+ expect(output.errors).toBeDefined();
113
+ expect(output.errors!.length).toBeGreaterThan(0);
114
+ } finally { rmTmpDir(tmp); }
37
115
  });
38
116
 
39
- it('JSON output contains all required fields', () => {
40
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
117
+ it('returns status=failed for invalid version', () => {
118
+ const tmp = mkTmpDir();
119
+ writeConfig(tmp, yaml.dump({ version: 99, features: {}, runtimeProfiles: {}, internalAgents: { defaultRuntime: 'x', agents: {} } }));
41
120
  try {
42
- const output = buildFeatureFlagsStatus(tmpDir);
43
- const json = JSON.stringify(output);
44
- const parsed = JSON.parse(json);
121
+ const output = buildRuntimeFeaturesStatus(tmp);
122
+ expect(output.status).toBe('failed');
123
+ expect(output.source).toBe('malformed');
124
+ } finally { rmTmpDir(tmp); }
125
+ });
126
+ });
127
+
128
+ // ── Valid config → effective flags ───────────────────────────────────────────
45
129
 
46
- expect(parsed.status).toBe('ok');
47
- expect(parsed.source).toBe('defaults');
48
- expect(parsed.configPath).toBeTruthy();
49
- expect(parsed.flags).toBeInstanceOf(Array);
50
- expect(parsed.warnings).toBeInstanceOf(Array);
51
- expect(typeof parsed.totalFlags).toBe('number');
52
- expect(typeof parsed.enabledCount).toBe('number');
53
- expect(typeof parsed.disabledCount).toBe('number');
54
- } finally {
55
- fs.rmSync(tmpDir, { recursive: true, force: true });
56
- }
130
+ describe('Valid config → effective flags', () => {
131
+ it('returns status=ok with source=user_config for valid config', () => {
132
+ const tmp = mkTmpDir();
133
+ writeConfig(tmp, makeValidConfigYaml());
134
+ try {
135
+ const output = buildRuntimeFeaturesStatus(tmp);
136
+ expect(output.status).toBe('ok');
137
+ expect(output.source).toBe('user_config');
138
+ expect(output.enabledMvpChannels).toContain('prompt');
139
+ } finally { rmTmpDir(tmp); }
140
+ });
141
+
142
+ it('shows all feature flags with category and enabled status', () => {
143
+ const tmp = mkTmpDir();
144
+ writeConfig(tmp, makeValidConfigYaml());
145
+ try {
146
+ const output = buildRuntimeFeaturesStatus(tmp);
147
+ expect(output.features.length).toBeGreaterThan(0);
148
+ const promptFlag = output.features.find(f => f.id === 'prompt');
149
+ expect(promptFlag).toBeDefined();
150
+ expect(promptFlag!.category).toBe('core');
151
+ expect(promptFlag!.enabled).toBe(true);
152
+ } finally { rmTmpDir(tmp); }
153
+ });
154
+
155
+ it('counts enabled and disabled correctly', () => {
156
+ const tmp = mkTmpDir();
157
+ writeConfig(tmp, makeValidConfigYaml());
158
+ try {
159
+ const output = buildRuntimeFeaturesStatus(tmp);
160
+ expect(output.totalFlags).toBeGreaterThan(0);
161
+ expect(output.enabledCount + output.disabledCount).toBe(output.totalFlags);
162
+ } finally { rmTmpDir(tmp); }
57
163
  });
58
164
  });
59
165
 
60
- describe('YAML loader integration', () => {
61
- it('rejects __proto__ key in YAML', () => {
62
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
63
- const configDir = path.join(tmpDir, '.pd');
64
- fs.mkdirSync(configDir, { recursive: true });
65
- fs.writeFileSync(
66
- path.join(configDir, 'feature-flags.yaml'),
67
- '"__proto__":\n enabled: true\n',
68
- 'utf8',
69
- );
166
+ // ── No secret output ─────────────────────────────────────────────────────────
70
167
 
168
+ describe('No secret output', () => {
169
+ it('output never contains raw API key values', () => {
170
+ const tmp = mkTmpDir();
171
+ writeConfig(tmp, makeValidConfigYaml());
71
172
  try {
72
- const result = loadEffectiveFeatureFlags(tmpDir);
73
- expect(result.warnings.some(w => w.includes('__proto__'))).toBe(true);
74
- } finally {
75
- fs.rmSync(tmpDir, { recursive: true, force: true });
76
- }
173
+ const output = buildRuntimeFeaturesStatus(tmp);
174
+ const json = JSON.stringify(output);
175
+ expect(json).not.toContain('sk-ant-');
176
+ expect(json).not.toContain('"apiKey"');
177
+ expect(json).not.toContain('"gatewayToken"');
178
+ } finally { rmTmpDir(tmp); }
179
+ });
180
+ });
181
+
182
+ // ── CLI handler: --json stdout purity and exit code ──────────────────────────
183
+
184
+ describe('CLI handler: --json stdout purity and exit code', () => {
185
+ let stdoutSpy: ReturnType<typeof vi.spyOn>;
186
+ let originalExitCode: number | undefined;
187
+
188
+ beforeEach(() => {
189
+ stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
190
+ originalExitCode = process.exitCode;
191
+ process.exitCode = undefined;
192
+ });
193
+
194
+ afterEach(() => {
195
+ stdoutSpy.mockRestore();
196
+ process.exitCode = originalExitCode;
77
197
  });
78
198
 
79
- it('handles malformed YAML gracefully', () => {
80
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
81
- const configDir = path.join(tmpDir, '.pd');
82
- fs.mkdirSync(configDir, { recursive: true });
83
- fs.writeFileSync(path.join(configDir, 'feature-flags.yaml'), 'gfi: [unterminated', 'utf8');
199
+ it('--json outputs exactly one parseable JSON object to stdout', async () => {
200
+ const tmp = mkTmpDir();
201
+ try {
202
+ await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
203
+ expect(stdoutSpy).toHaveBeenCalledTimes(1);
204
+ const output = stdoutSpy.mock.calls[0][0] as string;
205
+ const parsed = JSON.parse(output);
206
+ expect(typeof parsed).toBe('object');
207
+ expect(parsed).not.toBeNull();
208
+ expect(Array.isArray(parsed)).toBe(false);
209
+ // Verify key fields exist
210
+ expect(parsed).toHaveProperty('status');
211
+ expect(parsed).toHaveProperty('source');
212
+ expect(parsed).toHaveProperty('features');
213
+ } finally { rmTmpDir(tmp); }
214
+ });
84
215
 
216
+ it('--json sets process.exitCode=1 on failed status', async () => {
217
+ const tmp = mkTmpDir();
218
+ writeConfig(tmp, 'version: [unterminated');
85
219
  try {
86
- const result = loadEffectiveFeatureFlags(tmpDir);
87
- expect(result.warnings.some(w => w.includes('YAML'))).toBe(true);
88
- } finally {
89
- fs.rmSync(tmpDir, { recursive: true, force: true });
90
- }
220
+ await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
221
+ expect(process.exitCode).toBe(1);
222
+ // Verify the JSON output still parses
223
+ const output = stdoutSpy.mock.calls[0][0] as string;
224
+ const parsed = JSON.parse(output);
225
+ expect(parsed.status).toBe('failed');
226
+ expect(parsed.reason).toBeTruthy();
227
+ expect(parsed.nextAction).toBeTruthy();
228
+ } finally { rmTmpDir(tmp); }
91
229
  });
92
230
 
93
- it('enables GFI via valid YAML config', () => {
94
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
95
- const configDir = path.join(tmpDir, '.pd');
96
- fs.mkdirSync(configDir, { recursive: true });
97
- fs.writeFileSync(
98
- path.join(configDir, 'feature-flags.yaml'),
99
- 'gfi:\n enabled: true\n',
100
- 'utf8',
101
- );
231
+ it('--json does not set exitCode=1 on ok status', async () => {
232
+ const tmp = mkTmpDir();
233
+ try {
234
+ await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
235
+ expect(process.exitCode).toBeUndefined();
236
+ } finally { rmTmpDir(tmp); }
237
+ });
102
238
 
239
+ it('--json output contains no extra stdout lines (no banners, headers)', async () => {
240
+ const tmp = mkTmpDir();
103
241
  try {
104
- const result = loadEffectiveFeatureFlags(tmpDir);
105
- expect(result.flags.gfi).toBeDefined();
106
- if (result.flags.gfi) {
107
- expect(result.flags.gfi.enabled).toBe(true);
108
- }
109
- expect(result.source).toBe('workspace_file');
110
- } finally {
111
- fs.rmSync(tmpDir, { recursive: true, force: true });
112
- }
242
+ await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
243
+ expect(stdoutSpy).toHaveBeenCalledTimes(1);
244
+ const output = stdoutSpy.mock.calls[0][0] as string;
245
+ // Must parse as single JSON object — no leading/trailing non-JSON
246
+ expect(() => JSON.parse(output)).not.toThrow();
247
+ } finally { rmTmpDir(tmp); }
113
248
  });
114
249
  });