@forgeax/engine-profiler 0.0.0-dev.8d955ade1c79

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 (103) 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__/cli-consumer.integration.test.d.ts +2 -0
  6. package/dist/__tests__/cli-consumer.integration.test.d.ts.map +1 -0
  7. package/dist/__tests__/cli-contract.test.d.ts +2 -0
  8. package/dist/__tests__/cli-contract.test.d.ts.map +1 -0
  9. package/dist/__tests__/cli-input-errors.test.d.ts +2 -0
  10. package/dist/__tests__/cli-input-errors.test.d.ts.map +1 -0
  11. package/dist/__tests__/cli-process.integration.test.d.ts +2 -0
  12. package/dist/__tests__/cli-process.integration.test.d.ts.map +1 -0
  13. package/dist/__tests__/compare.test.d.ts +2 -0
  14. package/dist/__tests__/compare.test.d.ts.map +1 -0
  15. package/dist/__tests__/consumer-smoke.integration.test.d.ts +2 -0
  16. package/dist/__tests__/consumer-smoke.integration.test.d.ts.map +1 -0
  17. package/dist/__tests__/generated-freshness.test.d.ts +2 -0
  18. package/dist/__tests__/generated-freshness.test.d.ts.map +1 -0
  19. package/dist/__tests__/model-determinism.test.d.ts +2 -0
  20. package/dist/__tests__/model-determinism.test.d.ts.map +1 -0
  21. package/dist/__tests__/model-query.test.d.ts +2 -0
  22. package/dist/__tests__/model-query.test.d.ts.map +1 -0
  23. package/dist/__tests__/overflow-long-tail.test.d.ts +2 -0
  24. package/dist/__tests__/overflow-long-tail.test.d.ts.map +1 -0
  25. package/dist/__tests__/profile-source-owner.test-d.d.ts +2 -0
  26. package/dist/__tests__/profile-source-owner.test-d.d.ts.map +1 -0
  27. package/dist/__tests__/profiler-error-owner.test-d.d.ts +2 -0
  28. package/dist/__tests__/profiler-error-owner.test-d.d.ts.map +1 -0
  29. package/dist/__tests__/published-entry.browser.test.d.ts +2 -0
  30. package/dist/__tests__/published-entry.browser.test.d.ts.map +1 -0
  31. package/dist/__tests__/recorder-contract.test.d.ts +2 -0
  32. package/dist/__tests__/recorder-contract.test.d.ts.map +1 -0
  33. package/dist/__tests__/schema-contract.test.d.ts +2 -0
  34. package/dist/__tests__/schema-contract.test.d.ts.map +1 -0
  35. package/dist/browser-user-timing.d.ts +24 -0
  36. package/dist/browser-user-timing.d.ts.map +1 -0
  37. package/dist/browser-user-timing.mjs +135 -0
  38. package/dist/browser-user-timing.mjs.map +1 -0
  39. package/dist/cli.d.ts +9 -0
  40. package/dist/cli.d.ts.map +1 -0
  41. package/dist/cli.mjs +7770 -0
  42. package/dist/cli.mjs.map +1 -0
  43. package/dist/clock.d.ts +5 -0
  44. package/dist/clock.d.ts.map +1 -0
  45. package/dist/compare.d.ts +42 -0
  46. package/dist/compare.d.ts.map +1 -0
  47. package/dist/errors.d.ts +64 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/generated/profile-capture.d.ts +44 -0
  50. package/dist/generated/profile-capture.d.ts.map +1 -0
  51. package/dist/index.d.ts +21 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.mjs +8074 -0
  54. package/dist/index.mjs.map +1 -0
  55. package/dist/model.d.ts +44 -0
  56. package/dist/model.d.ts.map +1 -0
  57. package/dist/profiler.d.ts +24 -0
  58. package/dist/profiler.d.ts.map +1 -0
  59. package/dist/recorder.d.ts +26 -0
  60. package/dist/recorder.d.ts.map +1 -0
  61. package/dist/schema.d.ts +13 -0
  62. package/dist/schema.d.ts.map +1 -0
  63. package/dist/types.d.ts +44 -0
  64. package/dist/types.d.ts.map +1 -0
  65. package/package.json +76 -0
  66. package/schema/profile-capture.schema.json +71 -0
  67. package/scripts/consume-smoke.mjs +17 -0
  68. package/scripts/generate-profile-types.mjs +66 -0
  69. package/src/__tests__/cli-consumer.integration.test.ts +113 -0
  70. package/src/__tests__/cli-contract.test.ts +115 -0
  71. package/src/__tests__/cli-input-errors.test.ts +171 -0
  72. package/src/__tests__/cli-process.integration.test.ts +127 -0
  73. package/src/__tests__/compare.test.ts +223 -0
  74. package/src/__tests__/consumer-smoke.integration.test.ts +37 -0
  75. package/src/__tests__/fixtures/cli/invalid-version.json +18 -0
  76. package/src/__tests__/fixtures/cli/partial-capture.json +28 -0
  77. package/src/__tests__/fixtures/cli/valid-capture.json +43 -0
  78. package/src/__tests__/fixtures/profile-capture/invalid-boundary.json +144 -0
  79. package/src/__tests__/fixtures/profile-capture/model-input.json +54 -0
  80. package/src/__tests__/fixtures/profile-capture/sink-failure.json +30 -0
  81. package/src/__tests__/fixtures/profile-capture/valid-complete.json +43 -0
  82. package/src/__tests__/fixtures/profile-capture/valid-overflow.json +45 -0
  83. package/src/__tests__/generated-freshness.test.ts +14 -0
  84. package/src/__tests__/model-determinism.test.ts +70 -0
  85. package/src/__tests__/model-query.test.ts +128 -0
  86. package/src/__tests__/overflow-long-tail.test.ts +131 -0
  87. package/src/__tests__/profile-source-owner.test-d.ts +45 -0
  88. package/src/__tests__/profiler-error-owner.test-d.ts +64 -0
  89. package/src/__tests__/published-entry.browser.test.ts +27 -0
  90. package/src/__tests__/recorder-contract.test.ts +163 -0
  91. package/src/__tests__/schema-contract.test.ts +42 -0
  92. package/src/browser-user-timing.ts +186 -0
  93. package/src/cli.ts +410 -0
  94. package/src/clock.ts +18 -0
  95. package/src/compare.ts +156 -0
  96. package/src/errors.ts +78 -0
  97. package/src/generated/profile-capture.ts +48 -0
  98. package/src/index.ts +48 -0
  99. package/src/model.ts +161 -0
  100. package/src/profiler.ts +201 -0
  101. package/src/recorder.ts +416 -0
  102. package/src/schema.ts +168 -0
  103. package/src/types.ts +56 -0
@@ -0,0 +1,171 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { readCliInput, runProfilerCli } from '../cli.js';
4
+
5
+ function expectStructuredError(
6
+ result: { stdout: string; stderr: string; exitCode: number },
7
+ code: string,
8
+ ): void {
9
+ expect(result.exitCode).not.toBe(0);
10
+ expect(result.stdout).toBe('');
11
+ const parsed = JSON.parse(result.stderr) as {
12
+ error?: { code?: string; expected?: string; hint?: string; detail?: unknown };
13
+ };
14
+ expect(parsed).toEqual({
15
+ error: {
16
+ code,
17
+ expected: expect.any(String),
18
+ hint: expect.any(String),
19
+ detail: expect.anything(),
20
+ },
21
+ });
22
+ }
23
+
24
+ function expectStructuredErrorWithSide(
25
+ result: { stdout: string; stderr: string; exitCode: number },
26
+ code: string,
27
+ side: 'left' | 'right',
28
+ ): void {
29
+ expect(result.exitCode).not.toBe(0);
30
+ expect(result.stdout).toBe('');
31
+ expect(JSON.parse(result.stderr)).toEqual({
32
+ error: {
33
+ code,
34
+ expected: expect.any(String),
35
+ hint: expect.any(String),
36
+ detail: {
37
+ side,
38
+ ...(code === 'cli-input-file-read-failed' ? { path: expect.any(String) } : {}),
39
+ message: expect.any(String),
40
+ },
41
+ },
42
+ });
43
+ }
44
+
45
+ describe('profiler CLI input error contract', () => {
46
+ it('does not read stdin when a file input is selected', () => {
47
+ expect(
48
+ readCliInput(['summary', '--file', 'capture.json'], () => {
49
+ throw new Error('stdin read');
50
+ }),
51
+ ).toBe('');
52
+ });
53
+
54
+ it('reads stdin when no file input is selected', () => {
55
+ expect(readCliInput(['summary'], () => 'capture')).toBe('capture');
56
+ });
57
+
58
+ it('rejects empty stdin without producing a summary', () => {
59
+ expectStructuredError(runProfilerCli(['summary'], ''), 'cli-input-empty');
60
+ });
61
+
62
+ it('rejects malformed JSON from stdin', () => {
63
+ expectStructuredError(
64
+ runProfilerCli(['summary'], '{"schemaVersion":'),
65
+ 'cli-input-invalid-json',
66
+ );
67
+ });
68
+
69
+ it('rejects a missing file before model analysis', () => {
70
+ expectStructuredError(
71
+ runProfilerCli(['summary', '--file', '/tmp/forgeax-profiler-missing-capture.json'], ''),
72
+ 'cli-input-file-read-failed',
73
+ );
74
+ });
75
+
76
+ it('rejects schema-invalid JSON as a structured artifact error', () => {
77
+ expectStructuredError(
78
+ runProfilerCli(['summary'], '{"schemaVersion":"1.0"}'),
79
+ 'profile-artifact-invalid',
80
+ );
81
+ });
82
+
83
+ it('rejects unsupported artifact versions without guessing fields', () => {
84
+ expectStructuredError(
85
+ runProfilerCli(
86
+ ['summary'],
87
+ JSON.stringify({
88
+ schemaVersion: '9.0',
89
+ captureId: 'capture-0001',
90
+ timeUnit: 'microseconds',
91
+ frameLimit: 1,
92
+ eventLimit: 1,
93
+ phaseCatalog: { app: [], render: [] },
94
+ records: [],
95
+ completeness: { status: 'partial', retainedEventCount: 0, droppedEventCount: 0 },
96
+ }),
97
+ ),
98
+ 'profile-artifact-incompatible',
99
+ );
100
+ });
101
+
102
+ it('rejects non-positive and unsafe frame identifiers', () => {
103
+ const input = JSON.stringify({
104
+ schemaVersion: '1.0',
105
+ captureId: 'capture-0001',
106
+ timeUnit: 'microseconds',
107
+ frameLimit: 1,
108
+ eventLimit: 1,
109
+ phaseCatalog: { app: ['input'], render: [] },
110
+ records: [],
111
+ completeness: { status: 'partial', retainedEventCount: 0, droppedEventCount: 0 },
112
+ });
113
+
114
+ expectStructuredError(runProfilerCli(['frame', '--frame-id', '0'], input), 'cli-query-invalid');
115
+ expectStructuredError(
116
+ runProfilerCli(['frame', '--frame-id', '9007199254740992'], input),
117
+ 'cli-query-invalid',
118
+ );
119
+ });
120
+
121
+ it('rejects unknown flags and missing phase selectors', () => {
122
+ const input = JSON.stringify({
123
+ schemaVersion: '1.0',
124
+ captureId: 'capture-0001',
125
+ timeUnit: 'microseconds',
126
+ frameLimit: 1,
127
+ eventLimit: 1,
128
+ phaseCatalog: { app: ['input'], render: [] },
129
+ records: [],
130
+ completeness: { status: 'partial', retainedEventCount: 0, droppedEventCount: 0 },
131
+ });
132
+
133
+ expectStructuredError(runProfilerCli(['summary', '--unknown'], input), 'cli-arguments-invalid');
134
+ expectStructuredError(
135
+ runProfilerCli(['phase', '--source', 'app'], input),
136
+ 'cli-arguments-invalid',
137
+ );
138
+ });
139
+
140
+ it('requires two file inputs for compare and does not consume stdin', () => {
141
+ expect(
142
+ readCliInput(['compare', '--left-file', 'left.json', '--right-file', 'right.json'], () => {
143
+ throw new Error('stdin read');
144
+ }),
145
+ ).toBe('');
146
+ expectStructuredError(
147
+ runProfilerCli(['compare', '--left-file', 'left.json'], ''),
148
+ 'cli-arguments-invalid',
149
+ );
150
+ expectStructuredError(
151
+ runProfilerCli(['summary', '--left-file', 'left.json', '--right-file', 'right.json'], '{}'),
152
+ 'cli-arguments-invalid',
153
+ );
154
+ });
155
+
156
+ it('attributes unreadable compare files independently', () => {
157
+ const validPath = fileURLToPath(new URL('./fixtures/cli/valid-capture.json', import.meta.url));
158
+ const missingLeftPath = '/tmp/forgeax-profiler-missing-left-capture.json';
159
+ const missingRightPath = '/tmp/forgeax-profiler-missing-right-capture.json';
160
+ expectStructuredErrorWithSide(
161
+ runProfilerCli(['compare', '--left-file', missingLeftPath, '--right-file', validPath], ''),
162
+ 'cli-input-file-read-failed',
163
+ 'left',
164
+ );
165
+ expectStructuredErrorWithSide(
166
+ runProfilerCli(['compare', '--left-file', validPath, '--right-file', missingRightPath], ''),
167
+ 'cli-input-file-read-failed',
168
+ 'right',
169
+ );
170
+ });
171
+ });
@@ -0,0 +1,127 @@
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 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 parseObject(output: string): Record<string, unknown> {
27
+ return JSON.parse(output) as Record<string, unknown>;
28
+ }
29
+
30
+ describe('profiler CLI public process contract', () => {
31
+ it('prints deterministic summary, frame, and phase JSON through the built entry', () => {
32
+ const input = readFixture();
33
+ const summary = runCli(['summary'], input);
34
+ const summaryRepeat = runCli(['summary'], input);
35
+ const frame = runCli(['frame', '--frame-id', '2'], input);
36
+ const phase = runCli(['phase', '--source', 'render', '--phase', 'submit'], input);
37
+
38
+ expect(summary.status).toBe(0);
39
+ expect(summary.stderr).toBe('');
40
+ expect(parseObject(summary.stdout)).toEqual(parseObject(summaryRepeat.stdout));
41
+ expect(parseObject(summary.stdout)).toMatchObject({
42
+ query: 'summary',
43
+ captureId: 'capture-0042',
44
+ completeness: { status: 'complete' },
45
+ });
46
+ expect(frame.status).toBe(0);
47
+ expect(parseObject(frame.stdout)).toMatchObject({ query: 'frame', frameId: 2 });
48
+ expect(phase.status).toBe(0);
49
+ expect(parseObject(phase.stdout)).toMatchObject({
50
+ query: 'phase',
51
+ source: 'render',
52
+ phase: 'submit',
53
+ });
54
+ });
55
+
56
+ it('accepts file input and keeps invalid input on stderr as one error object', () => {
57
+ const fromFile = runCli(['summary', '--file', fixturePath], '');
58
+ const invalid = runCli(['summary'], '{"schemaVersion":');
59
+
60
+ expect(fromFile.status).toBe(0);
61
+ expect(fromFile.stderr).toBe('');
62
+ expect(parseObject(fromFile.stdout)).toMatchObject({ captureId: 'capture-0042' });
63
+ expect(invalid.status).not.toBe(0);
64
+ expect(invalid.stdout).toBe('');
65
+ expect(parseObject(invalid.stderr)).toEqual({
66
+ error: {
67
+ code: 'cli-input-invalid-json',
68
+ expected: expect.any(String),
69
+ hint: expect.any(String),
70
+ detail: expect.any(Object),
71
+ },
72
+ });
73
+ });
74
+
75
+ it('compares two file artifacts deterministically through the built entry', () => {
76
+ const args = ['compare', '--left-file', fixturePath, '--right-file', partialFixturePath];
77
+ const first = runCli(args, 'stdin must not be read');
78
+ const second = runCli(args, 'different stdin must not be read');
79
+ const output = parseObject(first.stdout);
80
+
81
+ expect(first.status).toBe(0);
82
+ expect(first.stderr).toBe('');
83
+ expect(first.stdout).toBe(second.stdout);
84
+ expect(output).toMatchObject({
85
+ query: 'compare',
86
+ left: {
87
+ summary: { captureId: 'capture-0042' },
88
+ completeness: { status: 'complete' },
89
+ },
90
+ right: {
91
+ summary: { captureId: 'capture-0043' },
92
+ completeness: { status: 'partial' },
93
+ },
94
+ phases: expect.any(Array),
95
+ });
96
+ });
97
+
98
+ it('attributes an invalid compare artifact to its input side', () => {
99
+ const leftInvalid = runCli(
100
+ ['compare', '--left-file', invalidFixturePath, '--right-file', fixturePath],
101
+ '',
102
+ );
103
+ const rightInvalid = runCli(
104
+ ['compare', '--left-file', fixturePath, '--right-file', invalidFixturePath],
105
+ '',
106
+ );
107
+
108
+ expect(leftInvalid.status).not.toBe(0);
109
+ expect(parseObject(leftInvalid.stderr)).toEqual({
110
+ error: {
111
+ code: 'profile-artifact-incompatible',
112
+ expected: expect.any(String),
113
+ hint: expect.any(String),
114
+ detail: { path: '/schemaVersion', message: expect.any(String), side: 'left' },
115
+ },
116
+ });
117
+ expect(rightInvalid.status).not.toBe(0);
118
+ expect(parseObject(rightInvalid.stderr)).toEqual({
119
+ error: {
120
+ code: 'profile-artifact-incompatible',
121
+ expected: expect.any(String),
122
+ hint: expect.any(String),
123
+ detail: { path: '/schemaVersion', message: expect.any(String), side: 'right' },
124
+ },
125
+ });
126
+ });
127
+ });
@@ -0,0 +1,223 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { compareProfileCaptures } from '../compare.js';
4
+
5
+ function readFixture(name: string): unknown {
6
+ return JSON.parse(
7
+ readFileSync(new URL(`./fixtures/cli/${name}`, import.meta.url), 'utf8'),
8
+ ) as unknown;
9
+ }
10
+
11
+ function capture(variant: 'left' | 'right'): Record<string, unknown> {
12
+ const right = variant === 'right';
13
+ return {
14
+ schemaVersion: '1.0',
15
+ captureId: right ? 'capture-0002' : 'capture-0001',
16
+ timeUnit: 'microseconds',
17
+ frameLimit: 4,
18
+ eventLimit: 16,
19
+ phaseCatalog: {
20
+ app: ['input', 'update'],
21
+ render: right ? ['submit', 'gpu'] : ['submit'],
22
+ },
23
+ records: right
24
+ ? [
25
+ {
26
+ kind: 'phase',
27
+ source: 'app',
28
+ frameId: 1,
29
+ phase: 'input',
30
+ startMicros: 1,
31
+ endMicros: 16,
32
+ durationMicros: 15,
33
+ },
34
+ {
35
+ kind: 'phase',
36
+ source: 'app',
37
+ frameId: 1,
38
+ phase: 'update',
39
+ parentSource: 'app',
40
+ parentPhase: 'input',
41
+ startMicros: 16,
42
+ endMicros: 41,
43
+ durationMicros: 25,
44
+ },
45
+ {
46
+ kind: 'phase',
47
+ source: 'render',
48
+ frameId: 1,
49
+ phase: 'submit',
50
+ startMicros: 41,
51
+ endMicros: 46,
52
+ durationMicros: 5,
53
+ },
54
+ {
55
+ kind: 'phase',
56
+ source: 'render',
57
+ frameId: 2,
58
+ phase: 'gpu',
59
+ startMicros: 50,
60
+ endMicros: 90,
61
+ durationMicros: 40,
62
+ },
63
+ ]
64
+ : [
65
+ {
66
+ kind: 'phase',
67
+ source: 'app',
68
+ frameId: 1,
69
+ phase: 'input',
70
+ startMicros: 1,
71
+ endMicros: 11,
72
+ durationMicros: 10,
73
+ },
74
+ {
75
+ kind: 'phase',
76
+ source: 'app',
77
+ frameId: 1,
78
+ phase: 'update',
79
+ parentSource: 'app',
80
+ parentPhase: 'input',
81
+ startMicros: 11,
82
+ endMicros: 31,
83
+ durationMicros: 20,
84
+ },
85
+ { kind: 'skip', source: 'render', frameId: 1, phase: 'submit', reason: 'not recorded' },
86
+ ],
87
+ completeness: right
88
+ ? {
89
+ status: 'overflow',
90
+ retainedEventCount: 4,
91
+ droppedEventCount: 2,
92
+ firstAffectedFrameId: 2,
93
+ lastAffectedFrameId: 2,
94
+ }
95
+ : { status: 'complete', retainedEventCount: 3, droppedEventCount: 0 },
96
+ };
97
+ }
98
+
99
+ describe('ProfileCapture comparison', () => {
100
+ it('preserves both summaries and compares a union of full phase identities', () => {
101
+ const result = compareProfileCaptures(capture('left'), capture('right'));
102
+
103
+ expect(result.ok).toBe(true);
104
+ if (!result.ok) throw new Error(result.error.code);
105
+ expect(result.value.left).toMatchObject({
106
+ summary: {
107
+ captureId: 'capture-0001',
108
+ frameCount: 1,
109
+ recordCount: 3,
110
+ phaseCount: 2,
111
+ skipCount: 1,
112
+ },
113
+ completeness: { status: 'complete', retainedEventCount: 3, droppedEventCount: 0 },
114
+ });
115
+ expect(result.value.right).toMatchObject({
116
+ summary: { captureId: 'capture-0002' },
117
+ completeness: {
118
+ status: 'overflow',
119
+ retainedEventCount: 4,
120
+ droppedEventCount: 2,
121
+ firstAffectedFrameId: 2,
122
+ lastAffectedFrameId: 2,
123
+ },
124
+ });
125
+
126
+ const update = result.value.phases.find((phase) => phase.identity.phase === 'update');
127
+ expect(update).toEqual({
128
+ identity: { source: 'app', phase: 'update', parentSource: 'app', parentPhase: 'input' },
129
+ left: { count: 1, skipCount: 0, p95DurationMicros: 20 },
130
+ right: { count: 1, skipCount: 0, p95DurationMicros: 25 },
131
+ delta: { count: 0, skipCount: 0, p95DurationMicros: 5 },
132
+ });
133
+
134
+ const submit = result.value.phases.find(
135
+ (phase) => phase.identity.source === 'render' && phase.identity.phase === 'submit',
136
+ );
137
+ expect(submit).toEqual({
138
+ identity: { source: 'render', phase: 'submit' },
139
+ left: { count: 0, skipCount: 1, p95DurationMicros: null },
140
+ right: { count: 1, skipCount: 0, p95DurationMicros: 5 },
141
+ delta: { count: 1, skipCount: -1 },
142
+ });
143
+
144
+ const gpu = result.value.phases.find((phase) => phase.identity.phase === 'gpu');
145
+ expect(gpu).toEqual({
146
+ identity: { source: 'render', phase: 'gpu' },
147
+ right: { count: 1, skipCount: 0, p95DurationMicros: 40 },
148
+ });
149
+ });
150
+
151
+ it('keeps invalid input attributable to its side', () => {
152
+ const left = compareProfileCaptures({ schemaVersion: '9.0' }, capture('right'));
153
+ expect(left).toEqual({
154
+ ok: false,
155
+ error: {
156
+ code: 'profile-artifact-incompatible',
157
+ expected: expect.any(String),
158
+ hint: expect.any(String),
159
+ detail: {
160
+ path: '/schemaVersion',
161
+ message: expect.any(String),
162
+ side: 'left',
163
+ },
164
+ },
165
+ });
166
+
167
+ const right = compareProfileCaptures(capture('left'), { schemaVersion: '9.0' });
168
+ expect(right).toMatchObject({ ok: false, error: { detail: { side: 'right' } } });
169
+ });
170
+
171
+ it('does not mutate either artifact and produces stable ordering', () => {
172
+ const left = capture('left');
173
+ const right = capture('right');
174
+ const beforeLeft = JSON.stringify(left);
175
+ const beforeRight = JSON.stringify(right);
176
+ const first = compareProfileCaptures(left, right);
177
+ const second = compareProfileCaptures(left, right);
178
+
179
+ expect(first).toEqual(second);
180
+ expect(JSON.stringify(left)).toBe(beforeLeft);
181
+ expect(JSON.stringify(right)).toBe(beforeRight);
182
+ });
183
+
184
+ it('keeps an unavailable p95 unavailable instead of manufacturing a delta', () => {
185
+ const result = compareProfileCaptures(
186
+ readFixture('valid-capture.json'),
187
+ readFixture('partial-capture.json'),
188
+ );
189
+
190
+ expect(result.ok).toBe(true);
191
+ if (!result.ok) throw new Error(result.error.code);
192
+ const input = result.value.phases.find(
193
+ (phase) => phase.identity.source === 'app' && phase.identity.phase === 'input',
194
+ );
195
+ expect(input?.left?.p95DurationMicros).toBe(10);
196
+ expect(input?.right?.p95DurationMicros).toBe(15);
197
+ expect(input?.delta?.p95DurationMicros).toBe(5);
198
+ const update = result.value.phases.find(
199
+ (phase) => phase.identity.source === 'app' && phase.identity.phase === 'update',
200
+ );
201
+ expect(update?.left?.p95DurationMicros).toBe(30);
202
+ expect(update?.right).toBeUndefined();
203
+ expect(update?.delta).toBeUndefined();
204
+ });
205
+
206
+ it('does not merge equal phase names that have different parent identities', () => {
207
+ const right = capture('right');
208
+ const rootUpdate = (right.records as Array<Record<string, unknown>>).map((record) => {
209
+ if (record.phase !== 'update') return record;
210
+ const { parentSource: _parentSource, parentPhase: _parentPhase, ...withoutParent } = record;
211
+ return withoutParent;
212
+ });
213
+ const result = compareProfileCaptures(capture('left'), { ...right, records: rootUpdate });
214
+
215
+ expect(result.ok).toBe(true);
216
+ if (!result.ok) throw new Error(result.error.code);
217
+ const updates = result.value.phases.filter((phase) => phase.identity.phase === 'update');
218
+ expect(updates).toHaveLength(2);
219
+ expect(updates.map((phase) => phase.identity.parentPhase)).toEqual(['input', undefined]);
220
+ expect(updates[0]?.delta).toBeUndefined();
221
+ expect(updates[1]?.delta).toBeUndefined();
222
+ });
223
+ });
@@ -0,0 +1,37 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { buildProfileModel } from '@forgeax/engine-profiler';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ // @perf-budget-skip: intentional public-entry subprocess smoke gate.
9
+ const packageRoot = fileURLToPath(new URL('../../', import.meta.url));
10
+ const scriptPath = fileURLToPath(new URL('../../scripts/consume-smoke.mjs', import.meta.url));
11
+ const fixturePath = fileURLToPath(
12
+ new URL('./fixtures/profile-capture/model-input.json', import.meta.url),
13
+ );
14
+
15
+ describe('consumer smoke', () => {
16
+ it('consumes a fixture through the public package entry', () => {
17
+ const result = spawnSync(process.execPath, [scriptPath], {
18
+ cwd: packageRoot,
19
+ encoding: 'utf8',
20
+ });
21
+
22
+ expect(result.status).toBe(0);
23
+ expect(result.stderr).toBe('');
24
+ expect(result.stdout.trim().split('\n')).toHaveLength(1);
25
+
26
+ const output = JSON.parse(result.stdout) as Record<string, unknown>;
27
+ const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as unknown;
28
+ const model = buildProfileModel(fixture);
29
+ expect(model.ok).toBe(true);
30
+ if (!model.ok) throw new Error(model.error.code);
31
+ expect(output).toEqual({
32
+ query: 'summary',
33
+ ...model.value.summary,
34
+ phases: model.value.phases,
35
+ });
36
+ });
37
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "schemaVersion": "2.0",
3
+ "captureId": "capture-0001",
4
+ "timeUnit": "microseconds",
5
+ "frameLimit": 2,
6
+ "eventLimit": 8,
7
+ "phaseCatalog": {
8
+ "app": ["input", "update"],
9
+ "render": ["prepare", "submit"]
10
+ },
11
+ "records": [],
12
+ "completeness": {
13
+ "status": "partial",
14
+ "retainedEventCount": 0,
15
+ "droppedEventCount": 0,
16
+ "incompleteReason": "unsupported-version"
17
+ }
18
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "captureId": "capture-0043",
4
+ "timeUnit": "microseconds",
5
+ "frameLimit": 4,
6
+ "eventLimit": 8,
7
+ "phaseCatalog": {
8
+ "app": ["input", "update"],
9
+ "render": ["prepare", "submit"]
10
+ },
11
+ "records": [
12
+ {
13
+ "kind": "phase",
14
+ "source": "app",
15
+ "frameId": 1,
16
+ "phase": "input",
17
+ "startMicros": 1000,
18
+ "endMicros": 1015,
19
+ "durationMicros": 15
20
+ }
21
+ ],
22
+ "completeness": {
23
+ "status": "partial",
24
+ "retainedEventCount": 1,
25
+ "droppedEventCount": 0,
26
+ "incompleteReason": "stopped-during-frame"
27
+ }
28
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "captureId": "capture-0042",
4
+ "timeUnit": "microseconds",
5
+ "frameLimit": 2,
6
+ "eventLimit": 8,
7
+ "phaseCatalog": {
8
+ "app": ["input", "update"],
9
+ "render": ["prepare", "submit"]
10
+ },
11
+ "records": [
12
+ {
13
+ "kind": "phase",
14
+ "source": "app",
15
+ "frameId": 1,
16
+ "phase": "input",
17
+ "startMicros": 1000,
18
+ "endMicros": 1010,
19
+ "durationMicros": 10
20
+ },
21
+ {
22
+ "kind": "phase",
23
+ "source": "app",
24
+ "frameId": 2,
25
+ "phase": "update",
26
+ "startMicros": 2000,
27
+ "endMicros": 2030,
28
+ "durationMicros": 30
29
+ },
30
+ {
31
+ "kind": "skip",
32
+ "source": "render",
33
+ "frameId": 2,
34
+ "phase": "submit",
35
+ "reason": "no-render"
36
+ }
37
+ ],
38
+ "completeness": {
39
+ "status": "complete",
40
+ "retainedEventCount": 3,
41
+ "droppedEventCount": 0
42
+ }
43
+ }