@forgeax/engine-profiler 0.1.2

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.
Files changed (118) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +164 -0
  3. package/bin/forgeax-engine-profiler.mjs +5 -0
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/__tests__/browser-profile-detail-owner.test-d.d.ts +2 -0
  6. package/dist/__tests__/browser-profile-detail-owner.test-d.d.ts.map +1 -0
  7. package/dist/__tests__/browser-user-timing.test.d.ts +2 -0
  8. package/dist/__tests__/browser-user-timing.test.d.ts.map +1 -0
  9. package/dist/__tests__/cli-consumer.integration.test.d.ts +2 -0
  10. package/dist/__tests__/cli-consumer.integration.test.d.ts.map +1 -0
  11. package/dist/__tests__/cli-contract.test.d.ts +2 -0
  12. package/dist/__tests__/cli-contract.test.d.ts.map +1 -0
  13. package/dist/__tests__/cli-input-errors.test.d.ts +2 -0
  14. package/dist/__tests__/cli-input-errors.test.d.ts.map +1 -0
  15. package/dist/__tests__/cli-process.integration.test.d.ts +2 -0
  16. package/dist/__tests__/cli-process.integration.test.d.ts.map +1 -0
  17. package/dist/__tests__/compare.test.d.ts +2 -0
  18. package/dist/__tests__/compare.test.d.ts.map +1 -0
  19. package/dist/__tests__/comparison-side-owner.test-d.d.ts +2 -0
  20. package/dist/__tests__/comparison-side-owner.test-d.d.ts.map +1 -0
  21. package/dist/__tests__/consumer-smoke.integration.test.d.ts +2 -0
  22. package/dist/__tests__/consumer-smoke.integration.test.d.ts.map +1 -0
  23. package/dist/__tests__/generated-freshness.test.d.ts +2 -0
  24. package/dist/__tests__/generated-freshness.test.d.ts.map +1 -0
  25. package/dist/__tests__/model-determinism.test.d.ts +2 -0
  26. package/dist/__tests__/model-determinism.test.d.ts.map +1 -0
  27. package/dist/__tests__/model-query.test.d.ts +2 -0
  28. package/dist/__tests__/model-query.test.d.ts.map +1 -0
  29. package/dist/__tests__/overflow-long-tail.test.d.ts +2 -0
  30. package/dist/__tests__/overflow-long-tail.test.d.ts.map +1 -0
  31. package/dist/__tests__/profile-source-owner.test-d.d.ts +2 -0
  32. package/dist/__tests__/profile-source-owner.test-d.d.ts.map +1 -0
  33. package/dist/__tests__/profiler-error-owner.test-d.d.ts +2 -0
  34. package/dist/__tests__/profiler-error-owner.test-d.d.ts.map +1 -0
  35. package/dist/__tests__/published-entry.browser.test.d.ts +2 -0
  36. package/dist/__tests__/published-entry.browser.test.d.ts.map +1 -0
  37. package/dist/__tests__/recorder-contract.test.d.ts +2 -0
  38. package/dist/__tests__/recorder-contract.test.d.ts.map +1 -0
  39. package/dist/__tests__/schema-contract.test.d.ts +2 -0
  40. package/dist/__tests__/schema-contract.test.d.ts.map +1 -0
  41. package/dist/__tests__/schema-derived-union-owner.test.d.ts +2 -0
  42. package/dist/__tests__/schema-derived-union-owner.test.d.ts.map +1 -0
  43. package/dist/browser-user-timing.d.ts +24 -0
  44. package/dist/browser-user-timing.d.ts.map +1 -0
  45. package/dist/browser-user-timing.mjs +140 -0
  46. package/dist/browser-user-timing.mjs.map +1 -0
  47. package/dist/cli.d.ts +21 -0
  48. package/dist/cli.d.ts.map +1 -0
  49. package/dist/cli.mjs +7783 -0
  50. package/dist/cli.mjs.map +1 -0
  51. package/dist/clock.d.ts +5 -0
  52. package/dist/clock.d.ts.map +1 -0
  53. package/dist/compare.d.ts +42 -0
  54. package/dist/compare.d.ts.map +1 -0
  55. package/dist/errors.d.ts +64 -0
  56. package/dist/errors.d.ts.map +1 -0
  57. package/dist/generated/profile-capture.d.ts +44 -0
  58. package/dist/generated/profile-capture.d.ts.map +1 -0
  59. package/dist/index.d.ts +22 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.mjs +8111 -0
  62. package/dist/index.mjs.map +1 -0
  63. package/dist/model.d.ts +44 -0
  64. package/dist/model.d.ts.map +1 -0
  65. package/dist/profiler.d.ts +24 -0
  66. package/dist/profiler.d.ts.map +1 -0
  67. package/dist/recorder.d.ts +26 -0
  68. package/dist/recorder.d.ts.map +1 -0
  69. package/dist/schema.d.ts +13 -0
  70. package/dist/schema.d.ts.map +1 -0
  71. package/dist/tool-preview-profile.d.ts +33 -0
  72. package/dist/tool-preview-profile.d.ts.map +1 -0
  73. package/dist/types.d.ts +44 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/package.json +76 -0
  76. package/schema/profile-capture.schema.json +71 -0
  77. package/scripts/consume-smoke.mjs +17 -0
  78. package/scripts/generate-profile-types.mjs +75 -0
  79. package/src/__tests__/browser-profile-detail-owner.test-d.ts +26 -0
  80. package/src/__tests__/browser-user-timing.test.ts +127 -0
  81. package/src/__tests__/cli-consumer.integration.test.ts +113 -0
  82. package/src/__tests__/cli-contract.test.ts +115 -0
  83. package/src/__tests__/cli-input-errors.test.ts +210 -0
  84. package/src/__tests__/cli-process.integration.test.ts +154 -0
  85. package/src/__tests__/compare.test.ts +223 -0
  86. package/src/__tests__/comparison-side-owner.test-d.ts +28 -0
  87. package/src/__tests__/consumer-smoke.integration.test.ts +37 -0
  88. package/src/__tests__/fixtures/cli/invalid-version.json +18 -0
  89. package/src/__tests__/fixtures/cli/partial-capture.json +28 -0
  90. package/src/__tests__/fixtures/cli/valid-capture.json +43 -0
  91. package/src/__tests__/fixtures/profile-capture/invalid-boundary.json +144 -0
  92. package/src/__tests__/fixtures/profile-capture/model-input.json +54 -0
  93. package/src/__tests__/fixtures/profile-capture/sink-failure.json +30 -0
  94. package/src/__tests__/fixtures/profile-capture/valid-complete.json +43 -0
  95. package/src/__tests__/fixtures/profile-capture/valid-overflow.json +45 -0
  96. package/src/__tests__/generated-freshness.test.ts +14 -0
  97. package/src/__tests__/model-determinism.test.ts +70 -0
  98. package/src/__tests__/model-query.test.ts +128 -0
  99. package/src/__tests__/overflow-long-tail.test.ts +131 -0
  100. package/src/__tests__/profile-source-owner.test-d.ts +45 -0
  101. package/src/__tests__/profiler-error-owner.test-d.ts +64 -0
  102. package/src/__tests__/published-entry.browser.test.ts +27 -0
  103. package/src/__tests__/recorder-contract.test.ts +163 -0
  104. package/src/__tests__/schema-contract.test.ts +42 -0
  105. package/src/__tests__/schema-derived-union-owner.test.ts +57 -0
  106. package/src/browser-user-timing.ts +194 -0
  107. package/src/cli.ts +425 -0
  108. package/src/clock.ts +18 -0
  109. package/src/compare.ts +159 -0
  110. package/src/errors.ts +78 -0
  111. package/src/generated/profile-capture.ts +48 -0
  112. package/src/index.ts +54 -0
  113. package/src/model.ts +161 -0
  114. package/src/profiler.ts +201 -0
  115. package/src/recorder.ts +417 -0
  116. package/src/schema.ts +168 -0
  117. package/src/tool-preview-profile.ts +67 -0
  118. package/src/types.ts +56 -0
@@ -0,0 +1,113 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
7
+ const cliPath = fileURLToPath(new URL('../../dist/cli.mjs', import.meta.url));
8
+ const smokePath = fileURLToPath(new URL('../../scripts/consume-smoke.mjs', import.meta.url));
9
+ const fixtureRoot = new URL('./fixtures/cli/', import.meta.url);
10
+
11
+ function runCli(args: readonly string[], input = '') {
12
+ return spawnSync(process.execPath, [cliPath, ...args], {
13
+ cwd: packageRoot,
14
+ input,
15
+ encoding: 'utf8',
16
+ });
17
+ }
18
+
19
+ function readFixture(name: string): string {
20
+ return readFileSync(fileURLToPath(new URL(name, fixtureRoot)), 'utf8');
21
+ }
22
+
23
+ function runSummaryForFixture(name: string) {
24
+ const fixturePath = fileURLToPath(new URL(name, fixtureRoot));
25
+ return runCli(['summary', '--file', fixturePath]);
26
+ }
27
+
28
+ function parseObject(output: string): Record<string, unknown> {
29
+ return JSON.parse(output) as Record<string, unknown>;
30
+ }
31
+
32
+ describe('profiler CLI consumer smoke', () => {
33
+ it('preserves completeness states through the built public CLI', () => {
34
+ const complete = runSummaryForFixture('valid-capture.json');
35
+ const partial = runSummaryForFixture('partial-capture.json');
36
+ const overflow = runSummaryForFixture('../profile-capture/valid-overflow.json');
37
+ const empty = runCli(
38
+ ['summary'],
39
+ JSON.stringify({
40
+ schemaVersion: '1.0',
41
+ captureId: 'capture-0044',
42
+ timeUnit: 'microseconds',
43
+ frameLimit: 1,
44
+ eventLimit: 1,
45
+ phaseCatalog: { app: ['input'], render: ['submit'] },
46
+ records: [],
47
+ completeness: {
48
+ status: 'partial',
49
+ retainedEventCount: 0,
50
+ droppedEventCount: 0,
51
+ incompleteReason: 'stopped-before-frame',
52
+ },
53
+ }),
54
+ );
55
+
56
+ expect(complete.status).toBe(0);
57
+ expect(parseObject(complete.stdout).completeness).toMatchObject({ status: 'complete' });
58
+ expect(partial.status).toBe(0);
59
+ expect(parseObject(partial.stdout).completeness).toMatchObject({
60
+ status: 'partial',
61
+ incompleteReason: 'stopped-during-frame',
62
+ });
63
+ expect(overflow.status).toBe(0);
64
+ expect(parseObject(overflow.stdout).completeness).toMatchObject({
65
+ status: 'overflow',
66
+ droppedEventCount: 7,
67
+ });
68
+ expect(empty.status).toBe(0);
69
+ expect(parseObject(empty.stdout)).toMatchObject({
70
+ captureId: 'capture-0044',
71
+ frameCount: 0,
72
+ frameRange: null,
73
+ completeness: { status: 'partial' },
74
+ });
75
+ });
76
+
77
+ it('keeps incompatible artifacts on stderr and repeats deterministically', () => {
78
+ const input = readFixture('valid-capture.json');
79
+ const first = runCli(['summary'], input);
80
+ const second = runCli(['summary'], input);
81
+ const invalid = runSummaryForFixture('invalid-version.json');
82
+
83
+ expect(first.status).toBe(0);
84
+ expect(second.status).toBe(0);
85
+ expect(first.stdout).toBe(second.stdout);
86
+ expect(first.stderr).toBe('');
87
+ expect(invalid.status).not.toBe(0);
88
+ expect(invalid.stdout).toBe('');
89
+ expect(parseObject(invalid.stderr)).toEqual({
90
+ error: {
91
+ code: 'profile-artifact-incompatible',
92
+ expected: expect.any(String),
93
+ hint: expect.any(String),
94
+ detail: expect.any(Object),
95
+ },
96
+ });
97
+ });
98
+
99
+ it('runs the package consumer smoke through the public CLI entry', () => {
100
+ const result = spawnSync(process.execPath, [smokePath], {
101
+ cwd: packageRoot,
102
+ encoding: 'utf8',
103
+ });
104
+
105
+ expect(result.status).toBe(0);
106
+ expect(result.stderr).toBe('');
107
+ expect(parseObject(result.stdout)).toMatchObject({
108
+ query: 'summary',
109
+ captureId: 'capture-0010',
110
+ completeness: { status: 'overflow' },
111
+ });
112
+ });
113
+ });
@@ -0,0 +1,115 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { runProfilerCli } from '../cli.js';
4
+
5
+ type CliResponse = Record<string, unknown>;
6
+
7
+ function readFixture(name: string): string {
8
+ return readFileSync(new URL(`./fixtures/profile-capture/${name}`, import.meta.url), 'utf8');
9
+ }
10
+
11
+ function readCliFixture(name: string): string {
12
+ return readFileSync(new URL(`./fixtures/cli/${name}`, import.meta.url), 'utf8');
13
+ }
14
+
15
+ function parseOutput(output: string): CliResponse {
16
+ return JSON.parse(output) as CliResponse;
17
+ }
18
+
19
+ function expectJsonObject(output: string): CliResponse {
20
+ const parsed = parseOutput(output);
21
+ expect(parsed).toEqual(expect.any(Object));
22
+ return parsed;
23
+ }
24
+
25
+ describe('profiler CLI structured output contract', () => {
26
+ it('prints one machine-readable summary object by default', () => {
27
+ const result = runProfilerCli([], readFixture('valid-complete.json'));
28
+ const output = expectJsonObject(result.stdout);
29
+
30
+ expect(result.exitCode).toBe(0);
31
+ expect(result.stderr).toBe('');
32
+ expect(output).toMatchObject({
33
+ query: 'summary',
34
+ schemaVersion: '1.0',
35
+ captureId: 'capture-0001',
36
+ frameRange: { first: 1, last: 2 },
37
+ completeness: { status: 'complete' },
38
+ });
39
+ expect(output.phases).toEqual(expect.any(Array));
40
+ });
41
+
42
+ it('returns a frame projection selected by frameId', () => {
43
+ const result = runProfilerCli(['frame', '--frame-id', '2'], readFixture('valid-overflow.json'));
44
+ const output = expectJsonObject(result.stdout);
45
+
46
+ expect(result.exitCode).toBe(0);
47
+ expect(result.stderr).toBe('');
48
+ expect(output).toMatchObject({
49
+ query: 'frame',
50
+ captureId: 'capture-0002',
51
+ frameId: 2,
52
+ completeness: {
53
+ status: 'overflow',
54
+ droppedEventCount: 7,
55
+ firstAffectedFrameId: 2,
56
+ lastAffectedFrameId: 4,
57
+ },
58
+ frame: null,
59
+ });
60
+ });
61
+
62
+ it('returns a phase projection selected by source and phase', () => {
63
+ const result = runProfilerCli(
64
+ ['phase', '--source', 'render', '--phase', 'submit'],
65
+ readFixture('valid-complete.json'),
66
+ );
67
+ const output = expectJsonObject(result.stdout);
68
+
69
+ expect(result.exitCode).toBe(0);
70
+ expect(result.stderr).toBe('');
71
+ expect(output).toMatchObject({
72
+ query: 'phase',
73
+ captureId: 'capture-0001',
74
+ source: 'render',
75
+ phase: 'submit',
76
+ phaseSummary: {
77
+ source: 'render',
78
+ phase: 'submit',
79
+ skipCount: 1,
80
+ },
81
+ });
82
+ });
83
+
84
+ it('accepts file input and preserves an empty capture status', () => {
85
+ const result = runProfilerCli(
86
+ [
87
+ 'summary',
88
+ '--file',
89
+ new URL('./fixtures/profile-capture/valid-complete.json', import.meta.url).pathname,
90
+ ],
91
+ '',
92
+ );
93
+ const output = expectJsonObject(result.stdout);
94
+
95
+ expect(result.exitCode).toBe(0);
96
+ expect(result.stderr).toBe('');
97
+ expect(output.completeness).toMatchObject({ status: 'complete' });
98
+ });
99
+
100
+ it('writes one structured error object for an incompatible artifact', () => {
101
+ const result = runProfilerCli(['summary'], readCliFixture('invalid-version.json'));
102
+ const error = expectJsonObject(result.stderr);
103
+
104
+ expect(result.exitCode).not.toBe(0);
105
+ expect(result.stdout).toBe('');
106
+ expect(error).toMatchObject({
107
+ error: {
108
+ code: 'profile-artifact-incompatible',
109
+ expected: expect.any(String),
110
+ hint: expect.any(String),
111
+ detail: expect.any(Object),
112
+ },
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,210 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { readCliInput, runProfilerCli } from '../cli.js';
5
+
6
+ function expectStructuredError(
7
+ result: { stdout: string; stderr: string; exitCode: number },
8
+ code: string,
9
+ ): void {
10
+ expect(result.exitCode).not.toBe(0);
11
+ expect(result.stdout).toBe('');
12
+ const parsed = JSON.parse(result.stderr) as {
13
+ error?: { code?: string; expected?: string; hint?: string; detail?: unknown };
14
+ };
15
+ expect(parsed).toEqual({
16
+ error: {
17
+ code,
18
+ expected: expect.any(String),
19
+ hint: expect.any(String),
20
+ detail: expect.anything(),
21
+ },
22
+ });
23
+ }
24
+
25
+ function expectStructuredErrorWithSide(
26
+ result: { stdout: string; stderr: string; exitCode: number },
27
+ code: string,
28
+ side: 'left' | 'right',
29
+ ): void {
30
+ expect(result.exitCode).not.toBe(0);
31
+ expect(result.stdout).toBe('');
32
+ expect(JSON.parse(result.stderr)).toEqual({
33
+ error: {
34
+ code,
35
+ expected: expect.any(String),
36
+ hint: expect.any(String),
37
+ detail: {
38
+ side,
39
+ ...(code === 'cli-input-file-read-failed' ? { path: expect.any(String) } : {}),
40
+ message: expect.any(String),
41
+ },
42
+ },
43
+ });
44
+ }
45
+
46
+ describe('profiler CLI input error contract', () => {
47
+ it('does not read stdin when a file input is selected', () => {
48
+ expect(
49
+ readCliInput(['summary', '--file', 'capture.json'], () => {
50
+ throw new Error('stdin read');
51
+ }),
52
+ ).toBe('');
53
+ });
54
+
55
+ it('reads stdin when no file input is selected', () => {
56
+ expect(readCliInput(['summary'], () => 'capture')).toBe('capture');
57
+ });
58
+
59
+ it('returns a structured reader failure and retries the same reader deterministically', () => {
60
+ const input = readFileSync(
61
+ new URL('./fixtures/cli/valid-capture.json', import.meta.url),
62
+ 'utf8',
63
+ );
64
+ let refuse = true;
65
+ const readStdin = (): string => {
66
+ if (refuse) {
67
+ refuse = false;
68
+ throw new Error('EISDIR: illegal operation on a directory, read');
69
+ }
70
+ return input;
71
+ };
72
+
73
+ const first = runProfilerCli(['summary'], readCliInput(['summary'], readStdin));
74
+ expectStructuredError(first, 'cli-input-stdin-read-failed');
75
+ expect(JSON.parse(first.stderr)).toEqual({
76
+ error: {
77
+ code: 'cli-input-stdin-read-failed',
78
+ expected: expect.any(String),
79
+ hint: expect.any(String),
80
+ detail: {
81
+ message: 'EISDIR: illegal operation on a directory, read',
82
+ },
83
+ },
84
+ });
85
+
86
+ const second = runProfilerCli(['summary'], readCliInput(['summary'], readStdin));
87
+ const repeat = runProfilerCli(['summary'], readCliInput(['summary'], readStdin));
88
+ expect(second.exitCode).toBe(0);
89
+ expect(second.stderr).toBe('');
90
+ expect(second.stdout).toBe(repeat.stdout);
91
+ expect(JSON.parse(second.stdout)).toMatchObject({
92
+ query: 'summary',
93
+ captureId: 'capture-0042',
94
+ });
95
+ });
96
+
97
+ it('rejects empty stdin without producing a summary', () => {
98
+ expectStructuredError(runProfilerCli(['summary'], ''), 'cli-input-empty');
99
+ });
100
+
101
+ it('rejects malformed JSON from stdin', () => {
102
+ expectStructuredError(
103
+ runProfilerCli(['summary'], '{"schemaVersion":'),
104
+ 'cli-input-invalid-json',
105
+ );
106
+ });
107
+
108
+ it('rejects a missing file before model analysis', () => {
109
+ expectStructuredError(
110
+ runProfilerCli(['summary', '--file', '/tmp/forgeax-profiler-missing-capture.json'], ''),
111
+ 'cli-input-file-read-failed',
112
+ );
113
+ });
114
+
115
+ it('rejects schema-invalid JSON as a structured artifact error', () => {
116
+ expectStructuredError(
117
+ runProfilerCli(['summary'], '{"schemaVersion":"1.0"}'),
118
+ 'profile-artifact-invalid',
119
+ );
120
+ });
121
+
122
+ it('rejects unsupported artifact versions without guessing fields', () => {
123
+ expectStructuredError(
124
+ runProfilerCli(
125
+ ['summary'],
126
+ JSON.stringify({
127
+ schemaVersion: '9.0',
128
+ captureId: 'capture-0001',
129
+ timeUnit: 'microseconds',
130
+ frameLimit: 1,
131
+ eventLimit: 1,
132
+ phaseCatalog: { app: [], render: [] },
133
+ records: [],
134
+ completeness: { status: 'partial', retainedEventCount: 0, droppedEventCount: 0 },
135
+ }),
136
+ ),
137
+ 'profile-artifact-incompatible',
138
+ );
139
+ });
140
+
141
+ it('rejects non-positive and unsafe frame identifiers', () => {
142
+ const input = JSON.stringify({
143
+ schemaVersion: '1.0',
144
+ captureId: 'capture-0001',
145
+ timeUnit: 'microseconds',
146
+ frameLimit: 1,
147
+ eventLimit: 1,
148
+ phaseCatalog: { app: ['input'], render: [] },
149
+ records: [],
150
+ completeness: { status: 'partial', retainedEventCount: 0, droppedEventCount: 0 },
151
+ });
152
+
153
+ expectStructuredError(runProfilerCli(['frame', '--frame-id', '0'], input), 'cli-query-invalid');
154
+ expectStructuredError(
155
+ runProfilerCli(['frame', '--frame-id', '9007199254740992'], input),
156
+ 'cli-query-invalid',
157
+ );
158
+ });
159
+
160
+ it('rejects unknown flags and missing phase selectors', () => {
161
+ const input = JSON.stringify({
162
+ schemaVersion: '1.0',
163
+ captureId: 'capture-0001',
164
+ timeUnit: 'microseconds',
165
+ frameLimit: 1,
166
+ eventLimit: 1,
167
+ phaseCatalog: { app: ['input'], render: [] },
168
+ records: [],
169
+ completeness: { status: 'partial', retainedEventCount: 0, droppedEventCount: 0 },
170
+ });
171
+
172
+ expectStructuredError(runProfilerCli(['summary', '--unknown'], input), 'cli-arguments-invalid');
173
+ expectStructuredError(
174
+ runProfilerCli(['phase', '--source', 'app'], input),
175
+ 'cli-arguments-invalid',
176
+ );
177
+ });
178
+
179
+ it('requires two file inputs for compare and does not consume stdin', () => {
180
+ expect(
181
+ readCliInput(['compare', '--left-file', 'left.json', '--right-file', 'right.json'], () => {
182
+ throw new Error('stdin read');
183
+ }),
184
+ ).toBe('');
185
+ expectStructuredError(
186
+ runProfilerCli(['compare', '--left-file', 'left.json'], ''),
187
+ 'cli-arguments-invalid',
188
+ );
189
+ expectStructuredError(
190
+ runProfilerCli(['summary', '--left-file', 'left.json', '--right-file', 'right.json'], '{}'),
191
+ 'cli-arguments-invalid',
192
+ );
193
+ });
194
+
195
+ it('attributes unreadable compare files independently', () => {
196
+ const validPath = fileURLToPath(new URL('./fixtures/cli/valid-capture.json', import.meta.url));
197
+ const missingLeftPath = '/tmp/forgeax-profiler-missing-left-capture.json';
198
+ const missingRightPath = '/tmp/forgeax-profiler-missing-right-capture.json';
199
+ expectStructuredErrorWithSide(
200
+ runProfilerCli(['compare', '--left-file', missingLeftPath, '--right-file', validPath], ''),
201
+ 'cli-input-file-read-failed',
202
+ 'left',
203
+ );
204
+ expectStructuredErrorWithSide(
205
+ runProfilerCli(['compare', '--left-file', validPath, '--right-file', missingRightPath], ''),
206
+ 'cli-input-file-read-failed',
207
+ 'right',
208
+ );
209
+ });
210
+ });
@@ -0,0 +1,154 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { closeSync, openSync, readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ const cliPath = fileURLToPath(new URL('../../dist/cli.mjs', import.meta.url));
7
+ const fixturePath = fileURLToPath(new URL('./fixtures/cli/valid-capture.json', import.meta.url));
8
+ const partialFixturePath = fileURLToPath(
9
+ new URL('./fixtures/cli/partial-capture.json', import.meta.url),
10
+ );
11
+ const invalidFixturePath = fileURLToPath(
12
+ new URL('./fixtures/cli/invalid-version.json', import.meta.url),
13
+ );
14
+
15
+ function readFixture(): string {
16
+ return readFileSync(fixturePath, 'utf8');
17
+ }
18
+
19
+ function runCli(args: readonly string[], input: string) {
20
+ return spawnSync(process.execPath, [cliPath, ...args], {
21
+ input,
22
+ encoding: 'utf8',
23
+ });
24
+ }
25
+
26
+ function runCliWithDirectoryStdin(args: readonly string[]) {
27
+ const stdin = openSync(fileURLToPath(new URL('./', import.meta.url)), 'r');
28
+ try {
29
+ return spawnSync(process.execPath, [cliPath, ...args], {
30
+ stdio: [stdin, 'pipe', 'pipe'],
31
+ encoding: 'utf8',
32
+ });
33
+ } finally {
34
+ closeSync(stdin);
35
+ }
36
+ }
37
+
38
+ function parseObject(output: string): Record<string, unknown> {
39
+ return JSON.parse(output) as Record<string, unknown>;
40
+ }
41
+
42
+ describe('profiler CLI public process contract', () => {
43
+ it('prints deterministic summary, frame, and phase JSON through the built entry', () => {
44
+ const input = readFixture();
45
+ const summary = runCli(['summary'], input);
46
+ const summaryRepeat = runCli(['summary'], input);
47
+ const frame = runCli(['frame', '--frame-id', '2'], input);
48
+ const phase = runCli(['phase', '--source', 'render', '--phase', 'submit'], input);
49
+
50
+ expect(summary.status).toBe(0);
51
+ expect(summary.stderr).toBe('');
52
+ expect(parseObject(summary.stdout)).toEqual(parseObject(summaryRepeat.stdout));
53
+ expect(parseObject(summary.stdout)).toMatchObject({
54
+ query: 'summary',
55
+ captureId: 'capture-0042',
56
+ completeness: { status: 'complete' },
57
+ });
58
+ expect(frame.status).toBe(0);
59
+ expect(parseObject(frame.stdout)).toMatchObject({ query: 'frame', frameId: 2 });
60
+ expect(phase.status).toBe(0);
61
+ expect(parseObject(phase.stdout)).toMatchObject({
62
+ query: 'phase',
63
+ source: 'render',
64
+ phase: 'submit',
65
+ });
66
+ });
67
+
68
+ it('accepts file input and keeps invalid input on stderr as one error object', () => {
69
+ const fromFile = runCli(['summary', '--file', fixturePath], '');
70
+ const invalid = runCli(['summary'], '{"schemaVersion":');
71
+
72
+ expect(fromFile.status).toBe(0);
73
+ expect(fromFile.stderr).toBe('');
74
+ expect(parseObject(fromFile.stdout)).toMatchObject({ captureId: 'capture-0042' });
75
+ expect(invalid.status).not.toBe(0);
76
+ expect(invalid.stdout).toBe('');
77
+ expect(parseObject(invalid.stderr)).toEqual({
78
+ error: {
79
+ code: 'cli-input-invalid-json',
80
+ expected: expect.any(String),
81
+ hint: expect.any(String),
82
+ detail: expect.any(Object),
83
+ },
84
+ });
85
+ });
86
+
87
+ it('returns one structured stdin reader error through the built entry', () => {
88
+ const refusal = runCliWithDirectoryStdin(['summary']);
89
+
90
+ expect(refusal.status).toBe(2);
91
+ expect(refusal.stdout).toBe('');
92
+ expect(parseObject(refusal.stderr)).toEqual({
93
+ error: {
94
+ code: 'cli-input-stdin-read-failed',
95
+ expected: expect.any(String),
96
+ hint: expect.any(String),
97
+ detail: { message: expect.stringContaining('EISDIR') },
98
+ },
99
+ });
100
+ });
101
+
102
+ it('compares two file artifacts deterministically through the built entry', () => {
103
+ const args = ['compare', '--left-file', fixturePath, '--right-file', partialFixturePath];
104
+ const first = runCli(args, 'stdin must not be read');
105
+ const second = runCli(args, 'different stdin must not be read');
106
+ const output = parseObject(first.stdout);
107
+
108
+ expect(first.status).toBe(0);
109
+ expect(first.stderr).toBe('');
110
+ expect(first.stdout).toBe(second.stdout);
111
+ expect(output).toMatchObject({
112
+ query: 'compare',
113
+ left: {
114
+ summary: { captureId: 'capture-0042' },
115
+ completeness: { status: 'complete' },
116
+ },
117
+ right: {
118
+ summary: { captureId: 'capture-0043' },
119
+ completeness: { status: 'partial' },
120
+ },
121
+ phases: expect.any(Array),
122
+ });
123
+ });
124
+
125
+ it('attributes an invalid compare artifact to its input side', () => {
126
+ const leftInvalid = runCli(
127
+ ['compare', '--left-file', invalidFixturePath, '--right-file', fixturePath],
128
+ '',
129
+ );
130
+ const rightInvalid = runCli(
131
+ ['compare', '--left-file', fixturePath, '--right-file', invalidFixturePath],
132
+ '',
133
+ );
134
+
135
+ expect(leftInvalid.status).not.toBe(0);
136
+ expect(parseObject(leftInvalid.stderr)).toEqual({
137
+ error: {
138
+ code: 'profile-artifact-incompatible',
139
+ expected: expect.any(String),
140
+ hint: expect.any(String),
141
+ detail: { path: '/schemaVersion', message: expect.any(String), side: 'left' },
142
+ },
143
+ });
144
+ expect(rightInvalid.status).not.toBe(0);
145
+ expect(parseObject(rightInvalid.stderr)).toEqual({
146
+ error: {
147
+ code: 'profile-artifact-incompatible',
148
+ expected: expect.any(String),
149
+ hint: expect.any(String),
150
+ detail: { path: '/schemaVersion', message: expect.any(String), side: 'right' },
151
+ },
152
+ });
153
+ });
154
+ });