@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,45 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "captureId": "capture-0002",
4
+ "timeUnit": "microseconds",
5
+ "frameLimit": 4,
6
+ "eventLimit": 3,
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": 1100,
19
+ "durationMicros": 100
20
+ },
21
+ {
22
+ "kind": "phase",
23
+ "source": "app",
24
+ "frameId": 1,
25
+ "phase": "update",
26
+ "startMicros": 1200,
27
+ "endMicros": 1500,
28
+ "durationMicros": 300
29
+ },
30
+ {
31
+ "kind": "skip",
32
+ "source": "render",
33
+ "frameId": 1,
34
+ "phase": "submit",
35
+ "reason": "no-render"
36
+ }
37
+ ],
38
+ "completeness": {
39
+ "status": "overflow",
40
+ "retainedEventCount": 3,
41
+ "droppedEventCount": 7,
42
+ "firstAffectedFrameId": 2,
43
+ "lastAffectedFrameId": 4
44
+ }
45
+ }
@@ -0,0 +1,14 @@
1
+ // @perf-budget-skip: intentional generated-artifact freshness subprocess gate.
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ describe('generated profile types freshness', () => {
7
+ it('matches the current profile capture schema', () => {
8
+ const result = spawnSync(process.execPath, ['scripts/generate-profile-types.mjs', '--check'], {
9
+ cwd: new URL('../..', import.meta.url),
10
+ encoding: 'utf8',
11
+ });
12
+ expect(result.status, result.stderr).toBe(0);
13
+ });
14
+ });
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildProfileModel } from '../model.js';
4
+
5
+ type CaptureFixture = Record<string, unknown>;
6
+
7
+ function readFixture(): CaptureFixture {
8
+ return JSON.parse(
9
+ readFileSync(new URL('./fixtures/profile-capture/model-input.json', import.meta.url), 'utf8'),
10
+ ) as CaptureFixture;
11
+ }
12
+
13
+ function expectOk<T>(result: { ok: boolean; value?: T; error?: { code: string } }): T {
14
+ expect(result.ok).toBe(true);
15
+ if (!result.ok) throw new Error(result.error?.code ?? 'unexpected profiler error');
16
+ return result.value as T;
17
+ }
18
+
19
+ describe('buildProfileModel determinism contract', () => {
20
+ it('does not mutate input and returns deeply equal output on repeated calls', () => {
21
+ const capture = readFixture();
22
+ const before = JSON.stringify(capture);
23
+ const first = expectOk(buildProfileModel(capture));
24
+ const second = expectOk(buildProfileModel(capture));
25
+
26
+ expect(second).toEqual(first);
27
+ expect(JSON.stringify(capture)).toBe(before);
28
+ });
29
+
30
+ it('derives summary, frame, phase and nearest-rank p95 data', () => {
31
+ const model = expectOk(buildProfileModel(readFixture()));
32
+
33
+ expect(model.summary.captureId).toBe('capture-0010');
34
+ expect(model.summary.completeness.status).toBe('overflow');
35
+ expect(model.summary.frameRange).toEqual({ first: 1, last: 2 });
36
+ expect(model.summary.frameCount).toBe(2);
37
+ expect(model.summary.p95DurationMicros).toBe(30);
38
+ expect(model.frames).toHaveLength(2);
39
+ expect(model.frames[0]?.frameId).toBe(1);
40
+ expect(model.frames[0]?.phaseCount).toBe(2);
41
+ expect(model.phases).toEqual([
42
+ { source: 'app', phase: 'input', count: 2, skipCount: 0, p95DurationMicros: 30 },
43
+ { source: 'app', phase: 'update', count: 1, skipCount: 0, p95DurationMicros: 20 },
44
+ { source: 'render', phase: 'submit', count: 0, skipCount: 1, p95DurationMicros: null },
45
+ ]);
46
+ });
47
+
48
+ it('preserves partial and overflow completeness for empty and truncated captures', () => {
49
+ const fixture = readFixture();
50
+ const partial = {
51
+ ...fixture,
52
+ captureId: 'capture-0011',
53
+ records: [],
54
+ completeness: {
55
+ status: 'partial',
56
+ retainedEventCount: 0,
57
+ droppedEventCount: 0,
58
+ incompleteReason: 'stopped-before-frame',
59
+ },
60
+ };
61
+ const model = expectOk(buildProfileModel(partial));
62
+
63
+ expect(model.summary.completeness.status).toBe('partial');
64
+ expect(model.summary.frameCount).toBe(0);
65
+ expect(model.summary.frameRange).toBeNull();
66
+ expect(model.summary.p95DurationMicros).toBeNull();
67
+ expect(model.completeness.incompleteReason).toBe('stopped-before-frame');
68
+ expect(model.completeness.droppedEventCount).toBe(0);
69
+ });
70
+ });
@@ -0,0 +1,128 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildProfileModel } from '../model.js';
4
+
5
+ type CaptureFixture = Record<string, unknown>;
6
+
7
+ function readFixture(): CaptureFixture {
8
+ return JSON.parse(
9
+ readFileSync(new URL('./fixtures/profile-capture/model-input.json', import.meta.url), 'utf8'),
10
+ ) as CaptureFixture;
11
+ }
12
+
13
+ function expectOk<T>(result: { ok: boolean; value?: T; error?: { code: string } }): T {
14
+ expect(result.ok).toBe(true);
15
+ if (!result.ok) throw new Error(result.error?.code ?? 'unexpected profiler error');
16
+ return result.value as T;
17
+ }
18
+
19
+ describe('buildProfileModel query projection', () => {
20
+ it('preserves capture identity, limits, completeness, and frame detail', () => {
21
+ const model = expectOk(buildProfileModel(readFixture()));
22
+
23
+ expect(model.summary).toMatchObject({
24
+ schemaVersion: '1.0',
25
+ captureId: 'capture-0010',
26
+ timeUnit: 'microseconds',
27
+ frameLimit: 2,
28
+ eventLimit: 8,
29
+ frameRange: { first: 1, last: 2 },
30
+ completeness: {
31
+ status: 'overflow',
32
+ retainedEventCount: 4,
33
+ droppedEventCount: 2,
34
+ firstAffectedFrameId: 2,
35
+ lastAffectedFrameId: 2,
36
+ },
37
+ });
38
+
39
+ expect(model.frames).toHaveLength(2);
40
+ expect(model.frames.find((frame) => frame.frameId === 2)).toMatchObject({
41
+ frameId: 2,
42
+ recordCount: 2,
43
+ phaseCount: 1,
44
+ skipCount: 1,
45
+ durationMicros: 10,
46
+ });
47
+ });
48
+
49
+ it('supports exact source and phase lookup without losing skip records', () => {
50
+ const fixture = readFixture();
51
+ const phaseCatalog = fixture.phaseCatalog as { app: string[]; render: string[] };
52
+ const records = fixture.records as Array<Record<string, unknown>>;
53
+ const model = expectOk(
54
+ buildProfileModel({
55
+ ...fixture,
56
+ phaseCatalog: { ...phaseCatalog, render: [...phaseCatalog.render, 'input'] },
57
+ records: [
58
+ ...records,
59
+ {
60
+ kind: 'phase',
61
+ source: 'render',
62
+ frameId: 2,
63
+ phase: 'input',
64
+ startMicros: 2030,
65
+ endMicros: 2045,
66
+ durationMicros: 15,
67
+ },
68
+ ],
69
+ completeness: {
70
+ ...(fixture.completeness as Record<string, unknown>),
71
+ retainedEventCount: 5,
72
+ },
73
+ }),
74
+ );
75
+
76
+ expect(
77
+ model.phases.find((phase) => phase.source === 'render' && phase.phase === 'input'),
78
+ ).toEqual({
79
+ source: 'render',
80
+ phase: 'input',
81
+ count: 1,
82
+ skipCount: 0,
83
+ p95DurationMicros: 15,
84
+ });
85
+ expect(
86
+ model.phases.find((phase) => phase.source === 'render' && phase.phase === 'submit'),
87
+ ).toEqual({
88
+ source: 'render',
89
+ phase: 'submit',
90
+ count: 0,
91
+ skipCount: 1,
92
+ p95DurationMicros: null,
93
+ });
94
+ });
95
+
96
+ it('keeps empty partial captures queryable without inventing a frame', () => {
97
+ const fixture = readFixture();
98
+ const model = expectOk(
99
+ buildProfileModel({
100
+ ...fixture,
101
+ captureId: 'capture-0011',
102
+ records: [],
103
+ completeness: {
104
+ status: 'partial',
105
+ retainedEventCount: 0,
106
+ droppedEventCount: 0,
107
+ incompleteReason: 'stopped-before-frame',
108
+ },
109
+ }),
110
+ );
111
+
112
+ expect(model.summary).toMatchObject({
113
+ captureId: 'capture-0011',
114
+ completeness: {
115
+ status: 'partial',
116
+ retainedEventCount: 0,
117
+ droppedEventCount: 0,
118
+ incompleteReason: 'stopped-before-frame',
119
+ },
120
+ frameRange: null,
121
+ frameCount: 0,
122
+ recordCount: 0,
123
+ p95DurationMicros: null,
124
+ });
125
+ expect(model.frames).toEqual([]);
126
+ expect(model.phases).toEqual([]);
127
+ });
128
+ });
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { runProfilerCli } from '../cli.js';
3
+ import { buildProfileModel } from '../model.js';
4
+ import { createProfiler } from '../profiler.js';
5
+ import type { ProfileCapture } from '../types.js';
6
+
7
+ const TEST_PHASE_CATALOG = { app: ['frame-total'], render: ['extract'] } as const;
8
+
9
+ function createClock() {
10
+ let micros = 0;
11
+ return {
12
+ nowMicros() {
13
+ micros += 10;
14
+ return micros;
15
+ },
16
+ };
17
+ }
18
+
19
+ function captureAfterFrames(frameCount: number): ProfileCapture {
20
+ const profiler = createProfiler({ clock: createClock(), phaseCatalog: TEST_PHASE_CATALOG });
21
+ const started = profiler.startCapture({ frameLimit: frameCount + 1, eventLimit: 4 });
22
+ expect(started.ok).toBe(true);
23
+ if (!started.ok) throw new Error(started.error.code);
24
+
25
+ for (let frameId = 1; frameId <= frameCount; frameId += 1) {
26
+ expect(started.value.beginFrame(frameId).ok).toBe(true);
27
+ expect(started.value.beginPhase({ source: 'app', phase: 'frame-total' }).ok).toBe(true);
28
+ expect(started.value.endPhase().ok).toBe(true);
29
+ expect(started.value.beginPhase({ source: 'render', phase: 'extract' }).ok).toBe(true);
30
+ expect(started.value.endPhase().ok).toBe(true);
31
+ expect(started.value.endFrame().ok).toBe(true);
32
+ }
33
+
34
+ const finished = started.value.finish();
35
+ expect(finished.ok).toBe(true);
36
+ if (!finished.ok) throw new Error(finished.error.code);
37
+ return finished.value;
38
+ }
39
+
40
+ describe('profiler overflow long-tail bounded evidence', () => {
41
+ it('keeps nested ownership explicit without double-counting frame duration', () => {
42
+ const profiler = createProfiler({
43
+ clock: createClock(),
44
+ phaseCatalog: { app: ['frame-total', 'frame-child'], render: [] },
45
+ });
46
+ const started = profiler.startCapture({ frameLimit: 1, eventLimit: 8 });
47
+ expect(started.ok).toBe(true);
48
+ if (!started.ok) return;
49
+
50
+ expect(started.value.beginFrame(1).ok).toBe(true);
51
+ expect(started.value.beginPhase({ source: 'app', phase: 'frame-total' }).ok).toBe(true);
52
+ expect(started.value.beginPhase({ source: 'app', phase: 'frame-child' }).ok).toBe(true);
53
+ expect(started.value.endPhase().ok).toBe(true);
54
+ expect(started.value.endPhase().ok).toBe(true);
55
+ expect(started.value.endFrame().ok).toBe(true);
56
+
57
+ const finished = started.value.finish();
58
+ expect(finished.ok).toBe(true);
59
+ if (!finished.ok) return;
60
+ const child = finished.value.records.find(
61
+ (record) => record.kind === 'phase' && record.phase === 'frame-child',
62
+ );
63
+ expect(child).toMatchObject({ parentSource: 'app', parentPhase: 'frame-total' });
64
+
65
+ const model = buildProfileModel(finished.value);
66
+ expect(model.ok).toBe(true);
67
+ if (!model.ok) return;
68
+ expect(model.value.frames[0]?.durationMicros).toBe(30);
69
+ expect(model.value.phases).toContainEqual(
70
+ expect.objectContaining({
71
+ phase: 'frame-child',
72
+ parentPhase: 'frame-total',
73
+ p95DurationMicros: 10,
74
+ }),
75
+ );
76
+
77
+ const cli = runProfilerCli(
78
+ ['phase', '--source', 'app', '--phase', 'frame-total'],
79
+ JSON.stringify(finished.value),
80
+ );
81
+ expect(cli.exitCode).toBe(0);
82
+ expect(JSON.parse(cli.stdout)).toMatchObject({
83
+ children: [
84
+ expect.objectContaining({
85
+ source: 'app',
86
+ phase: 'frame-child',
87
+ parentSource: 'app',
88
+ parentPhase: 'frame-total',
89
+ }),
90
+ ],
91
+ });
92
+ });
93
+
94
+ it('keeps record, model, and CLI storage bounded after a ten-times tail', () => {
95
+ const overflowTriggerFrame = 3;
96
+ const postOverflowFrames = 40;
97
+ const capture = captureAfterFrames(overflowTriggerFrame + postOverflowFrames);
98
+
99
+ expect(capture.completeness).toMatchObject({
100
+ status: 'overflow',
101
+ retainedEventCount: 4,
102
+ firstAffectedFrameId: overflowTriggerFrame,
103
+ lastAffectedFrameId: overflowTriggerFrame + postOverflowFrames,
104
+ });
105
+ expect(capture.records).toHaveLength(4);
106
+ expect(JSON.stringify(capture).length).toBeLessThan(1600);
107
+
108
+ const model = buildProfileModel(capture);
109
+ expect(model.ok).toBe(true);
110
+ if (!model.ok) return;
111
+ expect(model.value.summary.completeness).toEqual(capture.completeness);
112
+ expect(model.value.completeness.status).toBe('overflow');
113
+
114
+ const cli = runProfilerCli(['summary'], JSON.stringify(capture));
115
+ expect(cli.exitCode).toBe(0);
116
+ expect(cli.stderr).toBe('');
117
+ expect(JSON.parse(cli.stdout)).toMatchObject({
118
+ completeness: capture.completeness,
119
+ frameCount: 2,
120
+ });
121
+ });
122
+
123
+ it('does not turn an overflow prefix into a complete artifact', () => {
124
+ const capture = captureAfterFrames(44);
125
+ expect(capture.completeness.status).not.toBe('complete');
126
+ expect(buildProfileModel(capture)).toMatchObject({
127
+ ok: true,
128
+ value: { summary: { completeness: { status: 'overflow' } } },
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, expectTypeOf, it } from 'vitest';
3
+ import type { ProfilerError, ProfileSource } from '../index.js';
4
+ import type { RecorderPhaseCatalog } from '../recorder.js';
5
+
6
+ type ExpectedProfileSource = 'app' | 'render';
7
+ type PhaseCatalogConflict = Extract<ProfilerError, { readonly code: 'phase-catalog-conflict' }>;
8
+
9
+ const errorsSource = readFileSync(new URL('../errors.ts', import.meta.url), 'utf8');
10
+ const recorderSource = readFileSync(new URL('../recorder.ts', import.meta.url), 'utf8');
11
+ const cliSource = readFileSync(new URL('../cli.ts', import.meta.url), 'utf8');
12
+
13
+ describe('profiler ProfileSource owner', () => {
14
+ it('preserves the exact generated vocabulary through the public projection', () => {
15
+ expectTypeOf<ProfileSource>().toEqualTypeOf<ExpectedProfileSource>();
16
+
17
+ const acceptProfileSource = (source: ProfileSource): ProfileSource => source;
18
+ expect(acceptProfileSource('app')).toBe('app');
19
+ expect(acceptProfileSource('render')).toBe('render');
20
+ // @ts-expect-error ProfileSource intentionally excludes additional sources.
21
+ acceptProfileSource('ecs');
22
+ });
23
+
24
+ it('derives the public error and recorder views from ProfileSource', () => {
25
+ expectTypeOf<PhaseCatalogConflict['detail']['source']>().toEqualTypeOf<ProfileSource>();
26
+ expectTypeOf<keyof RecorderPhaseCatalog>().toEqualTypeOf<ProfileSource>();
27
+ expectTypeOf<ProfileSource>().toEqualTypeOf<keyof RecorderPhaseCatalog>();
28
+ });
29
+
30
+ it('keeps private recorder and CLI projections on the same owner', () => {
31
+ expect(errorsSource).toContain('readonly source: ProfileSource;');
32
+ expect(recorderSource).toContain(
33
+ 'export type RecorderPhaseCatalog = Readonly<Record<ProfileSource, readonly string[]>>;',
34
+ );
35
+ expect(recorderSource).toContain(
36
+ 'readonly phaseArrays: Record<ProfileSource, readonly string[]>;',
37
+ );
38
+ expect(recorderSource).toContain(
39
+ 'readonly phaseSets: Record<ProfileSource, ReadonlySet<string>>;',
40
+ );
41
+ expect(cliSource).toContain('readonly source: ProfileSource;');
42
+ expect(cliSource).toContain('source: ProfileSource | undefined;');
43
+ expect(cliSource).toContain('source: state.source as ProfileSource');
44
+ });
45
+ });
@@ -0,0 +1,64 @@
1
+ import { expectTypeOf, test } from 'vitest';
2
+ import type { ProfilerError, ProfilerErrorCode } from '../index.js';
3
+
4
+ type ExpectedProfilerErrorCode =
5
+ | 'capture-boundary-invalid'
6
+ | 'capture-already-active'
7
+ | 'profiler-not-enabled'
8
+ | 'phase-catalog-conflict'
9
+ | 'profile-source-failed'
10
+ | 'profile-sink-failed'
11
+ | 'capture-state-invalid';
12
+
13
+ expectTypeOf<ProfilerErrorCode>().toEqualTypeOf<ExpectedProfilerErrorCode>();
14
+ expectTypeOf<ProfilerErrorCode>().toEqualTypeOf<ProfilerError['code']>();
15
+
16
+ test('rejects a code outside the exact public vocabulary', () => {
17
+ // @ts-expect-error Unknown profiler error codes are not assignable.
18
+ const invalidCode: ProfilerErrorCode = 'profiler-code-does-not-exist';
19
+ void invalidCode;
20
+ });
21
+
22
+ test('narrows every detail payload by exhaustive code switch', () => {
23
+ const readDetail = (error: ProfilerError): string => {
24
+ switch (error.code) {
25
+ case 'capture-boundary-invalid':
26
+ expectTypeOf(error.detail).toEqualTypeOf<{
27
+ readonly frameLimit: unknown;
28
+ readonly eventLimit: unknown;
29
+ }>();
30
+ return `${String(error.detail.frameLimit)}:${String(error.detail.eventLimit)}`;
31
+ case 'capture-already-active':
32
+ expectTypeOf(error.detail).toEqualTypeOf<{ readonly captureId: string }>();
33
+ return error.detail.captureId;
34
+ case 'profiler-not-enabled':
35
+ expectTypeOf(error.detail).toEqualTypeOf<{ readonly enabled: false }>();
36
+ return String(error.detail.enabled);
37
+ case 'phase-catalog-conflict':
38
+ expectTypeOf(error.detail).toEqualTypeOf<{
39
+ readonly source: 'app' | 'render';
40
+ readonly expected: readonly string[];
41
+ readonly actual: readonly string[];
42
+ }>();
43
+ return `${error.detail.source}:${error.detail.expected.join(',')}:${error.detail.actual.join(',')}`;
44
+ case 'profile-source-failed':
45
+ expectTypeOf(error.detail).toEqualTypeOf<{
46
+ readonly source: string;
47
+ readonly phase: string;
48
+ readonly frameId: number;
49
+ }>();
50
+ return `${error.detail.source}:${error.detail.phase}:${error.detail.frameId}`;
51
+ case 'profile-sink-failed':
52
+ expectTypeOf(error.detail).toEqualTypeOf<{ readonly message: string }>();
53
+ return error.detail.message;
54
+ case 'capture-state-invalid':
55
+ expectTypeOf(error.detail).toEqualTypeOf<{ readonly operation: string }>();
56
+ return error.detail.operation;
57
+ }
58
+
59
+ const exhaustive: never = error;
60
+ return exhaustive;
61
+ };
62
+
63
+ void readDetail;
64
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ // @ts-expect-error The regression intentionally imports the built .mjs artifact;
4
+ // TypeScript does not map an explicit .mjs path to the sibling generated .d.ts.
5
+ import { createProfiler, validateProfileCapture } from '../../dist/index.mjs';
6
+
7
+ describe('published profiler browser entry', () => {
8
+ it('loads the built ESM entry and validates a real capture', () => {
9
+ const profiler = createProfiler({
10
+ phaseCatalog: { app: ['frame-total'], render: [] },
11
+ });
12
+ const started = profiler.startCapture({ frameLimit: 1, eventLimit: 8 });
13
+ expect(started.ok).toBe(true);
14
+ if (!started.ok) return;
15
+
16
+ const session = started.value;
17
+ expect(session.beginFrame(1).ok).toBe(true);
18
+ expect(session.beginPhase({ source: 'app', phase: 'frame-total' }).ok).toBe(true);
19
+ expect(session.endPhase().ok).toBe(true);
20
+ expect(session.endFrame().ok).toBe(true);
21
+
22
+ const capture = profiler.latestCapture();
23
+ expect(capture?.records.length).toBeGreaterThan(0);
24
+ const validated = validateProfileCapture(capture);
25
+ expect(validated.ok).toBe(true);
26
+ });
27
+ });