@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.
- package/LICENSE +202 -0
- package/README.md +164 -0
- package/bin/forgeax-engine-profiler.mjs +5 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/cli-consumer.integration.test.d.ts +2 -0
- package/dist/__tests__/cli-consumer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/cli-contract.test.d.ts +2 -0
- package/dist/__tests__/cli-contract.test.d.ts.map +1 -0
- package/dist/__tests__/cli-input-errors.test.d.ts +2 -0
- package/dist/__tests__/cli-input-errors.test.d.ts.map +1 -0
- package/dist/__tests__/cli-process.integration.test.d.ts +2 -0
- package/dist/__tests__/cli-process.integration.test.d.ts.map +1 -0
- package/dist/__tests__/compare.test.d.ts +2 -0
- package/dist/__tests__/compare.test.d.ts.map +1 -0
- package/dist/__tests__/consumer-smoke.integration.test.d.ts +2 -0
- package/dist/__tests__/consumer-smoke.integration.test.d.ts.map +1 -0
- package/dist/__tests__/generated-freshness.test.d.ts +2 -0
- package/dist/__tests__/generated-freshness.test.d.ts.map +1 -0
- package/dist/__tests__/model-determinism.test.d.ts +2 -0
- package/dist/__tests__/model-determinism.test.d.ts.map +1 -0
- package/dist/__tests__/model-query.test.d.ts +2 -0
- package/dist/__tests__/model-query.test.d.ts.map +1 -0
- package/dist/__tests__/overflow-long-tail.test.d.ts +2 -0
- package/dist/__tests__/overflow-long-tail.test.d.ts.map +1 -0
- package/dist/__tests__/profile-source-owner.test-d.d.ts +2 -0
- package/dist/__tests__/profile-source-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/profiler-error-owner.test-d.d.ts +2 -0
- package/dist/__tests__/profiler-error-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/published-entry.browser.test.d.ts +2 -0
- package/dist/__tests__/published-entry.browser.test.d.ts.map +1 -0
- package/dist/__tests__/recorder-contract.test.d.ts +2 -0
- package/dist/__tests__/recorder-contract.test.d.ts.map +1 -0
- package/dist/__tests__/schema-contract.test.d.ts +2 -0
- package/dist/__tests__/schema-contract.test.d.ts.map +1 -0
- package/dist/browser-user-timing.d.ts +24 -0
- package/dist/browser-user-timing.d.ts.map +1 -0
- package/dist/browser-user-timing.mjs +135 -0
- package/dist/browser-user-timing.mjs.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.mjs +7770 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/clock.d.ts +5 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/compare.d.ts +42 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/errors.d.ts +64 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/generated/profile-capture.d.ts +44 -0
- package/dist/generated/profile-capture.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +8074 -0
- package/dist/index.mjs.map +1 -0
- package/dist/model.d.ts +44 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/profiler.d.ts +24 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/recorder.d.ts +26 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/schema.d.ts +13 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +76 -0
- package/schema/profile-capture.schema.json +71 -0
- package/scripts/consume-smoke.mjs +17 -0
- package/scripts/generate-profile-types.mjs +66 -0
- package/src/__tests__/cli-consumer.integration.test.ts +113 -0
- package/src/__tests__/cli-contract.test.ts +115 -0
- package/src/__tests__/cli-input-errors.test.ts +171 -0
- package/src/__tests__/cli-process.integration.test.ts +127 -0
- package/src/__tests__/compare.test.ts +223 -0
- package/src/__tests__/consumer-smoke.integration.test.ts +37 -0
- package/src/__tests__/fixtures/cli/invalid-version.json +18 -0
- package/src/__tests__/fixtures/cli/partial-capture.json +28 -0
- package/src/__tests__/fixtures/cli/valid-capture.json +43 -0
- package/src/__tests__/fixtures/profile-capture/invalid-boundary.json +144 -0
- package/src/__tests__/fixtures/profile-capture/model-input.json +54 -0
- package/src/__tests__/fixtures/profile-capture/sink-failure.json +30 -0
- package/src/__tests__/fixtures/profile-capture/valid-complete.json +43 -0
- package/src/__tests__/fixtures/profile-capture/valid-overflow.json +45 -0
- package/src/__tests__/generated-freshness.test.ts +14 -0
- package/src/__tests__/model-determinism.test.ts +70 -0
- package/src/__tests__/model-query.test.ts +128 -0
- package/src/__tests__/overflow-long-tail.test.ts +131 -0
- package/src/__tests__/profile-source-owner.test-d.ts +45 -0
- package/src/__tests__/profiler-error-owner.test-d.ts +64 -0
- package/src/__tests__/published-entry.browser.test.ts +27 -0
- package/src/__tests__/recorder-contract.test.ts +163 -0
- package/src/__tests__/schema-contract.test.ts +42 -0
- package/src/browser-user-timing.ts +186 -0
- package/src/cli.ts +410 -0
- package/src/clock.ts +18 -0
- package/src/compare.ts +156 -0
- package/src/errors.ts +78 -0
- package/src/generated/profile-capture.ts +48 -0
- package/src/index.ts +48 -0
- package/src/model.ts +161 -0
- package/src/profiler.ts +201 -0
- package/src/recorder.ts +416 -0
- package/src/schema.ts +168 -0
- package/src/types.ts +56 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cases": [
|
|
3
|
+
{
|
|
4
|
+
"name": "unsupported schema version",
|
|
5
|
+
"capture": { "schemaVersion": "2.0" }
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"name": "zero frame limit",
|
|
9
|
+
"capture": {
|
|
10
|
+
"schemaVersion": "1.0",
|
|
11
|
+
"captureId": "capture-0003",
|
|
12
|
+
"timeUnit": "microseconds",
|
|
13
|
+
"frameLimit": 0,
|
|
14
|
+
"eventLimit": 1,
|
|
15
|
+
"phaseCatalog": { "app": [], "render": [] },
|
|
16
|
+
"records": [],
|
|
17
|
+
"completeness": {
|
|
18
|
+
"status": "partial",
|
|
19
|
+
"retainedEventCount": 0,
|
|
20
|
+
"droppedEventCount": 0,
|
|
21
|
+
"incompleteReason": "stopped-before-frame"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "fractional event limit",
|
|
27
|
+
"capture": {
|
|
28
|
+
"schemaVersion": "1.0",
|
|
29
|
+
"captureId": "capture-0004",
|
|
30
|
+
"timeUnit": "microseconds",
|
|
31
|
+
"frameLimit": 1,
|
|
32
|
+
"eventLimit": 1.5,
|
|
33
|
+
"phaseCatalog": { "app": [], "render": [] },
|
|
34
|
+
"records": [],
|
|
35
|
+
"completeness": {
|
|
36
|
+
"status": "partial",
|
|
37
|
+
"retainedEventCount": 0,
|
|
38
|
+
"droppedEventCount": 0,
|
|
39
|
+
"incompleteReason": "stopped-before-frame"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "negative duration",
|
|
45
|
+
"capture": {
|
|
46
|
+
"schemaVersion": "1.0",
|
|
47
|
+
"captureId": "capture-0005",
|
|
48
|
+
"timeUnit": "microseconds",
|
|
49
|
+
"frameLimit": 1,
|
|
50
|
+
"eventLimit": 1,
|
|
51
|
+
"phaseCatalog": { "app": ["input"], "render": [] },
|
|
52
|
+
"records": [
|
|
53
|
+
{
|
|
54
|
+
"kind": "phase",
|
|
55
|
+
"source": "app",
|
|
56
|
+
"frameId": 1,
|
|
57
|
+
"phase": "input",
|
|
58
|
+
"startMicros": 20,
|
|
59
|
+
"endMicros": 10,
|
|
60
|
+
"durationMicros": -10
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"completeness": { "status": "complete", "retainedEventCount": 1, "droppedEventCount": 0 }
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "unknown phase",
|
|
68
|
+
"capture": {
|
|
69
|
+
"schemaVersion": "1.0",
|
|
70
|
+
"captureId": "capture-0006",
|
|
71
|
+
"timeUnit": "microseconds",
|
|
72
|
+
"frameLimit": 1,
|
|
73
|
+
"eventLimit": 1,
|
|
74
|
+
"phaseCatalog": { "app": ["input"], "render": [] },
|
|
75
|
+
"records": [
|
|
76
|
+
{
|
|
77
|
+
"kind": "skip",
|
|
78
|
+
"source": "app",
|
|
79
|
+
"frameId": 1,
|
|
80
|
+
"phase": "unknown",
|
|
81
|
+
"reason": "not-declared"
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"completeness": {
|
|
85
|
+
"status": "partial",
|
|
86
|
+
"retainedEventCount": 1,
|
|
87
|
+
"droppedEventCount": 0,
|
|
88
|
+
"incompleteReason": "phase-not-declared"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "complete with incomplete reason",
|
|
94
|
+
"capture": {
|
|
95
|
+
"schemaVersion": "1.0",
|
|
96
|
+
"captureId": "capture-0007",
|
|
97
|
+
"timeUnit": "microseconds",
|
|
98
|
+
"frameLimit": 1,
|
|
99
|
+
"eventLimit": 1,
|
|
100
|
+
"phaseCatalog": { "app": [], "render": [] },
|
|
101
|
+
"records": [],
|
|
102
|
+
"completeness": {
|
|
103
|
+
"status": "complete",
|
|
104
|
+
"retainedEventCount": 0,
|
|
105
|
+
"droppedEventCount": 0,
|
|
106
|
+
"incompleteReason": "source-failed"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "frame id is not positive safe integer",
|
|
112
|
+
"capture": {
|
|
113
|
+
"schemaVersion": "1.0",
|
|
114
|
+
"captureId": "capture-0008",
|
|
115
|
+
"timeUnit": "microseconds",
|
|
116
|
+
"frameLimit": 1,
|
|
117
|
+
"eventLimit": 1,
|
|
118
|
+
"phaseCatalog": { "app": ["input"], "render": [] },
|
|
119
|
+
"records": [
|
|
120
|
+
{ "kind": "skip", "source": "app", "frameId": -1, "phase": "input", "reason": "not-run" }
|
|
121
|
+
],
|
|
122
|
+
"completeness": {
|
|
123
|
+
"status": "partial",
|
|
124
|
+
"retainedEventCount": 1,
|
|
125
|
+
"droppedEventCount": 0,
|
|
126
|
+
"incompleteReason": "source-failed"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"name": "overflow without affected frame range",
|
|
132
|
+
"capture": {
|
|
133
|
+
"schemaVersion": "1.0",
|
|
134
|
+
"captureId": "capture-0009",
|
|
135
|
+
"timeUnit": "microseconds",
|
|
136
|
+
"frameLimit": 1,
|
|
137
|
+
"eventLimit": 1,
|
|
138
|
+
"phaseCatalog": { "app": [], "render": [] },
|
|
139
|
+
"records": [],
|
|
140
|
+
"completeness": { "status": "overflow", "retainedEventCount": 0, "droppedEventCount": 1 }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"captureId": "capture-0010",
|
|
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": 1030,
|
|
19
|
+
"durationMicros": 30
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"kind": "phase",
|
|
23
|
+
"source": "app",
|
|
24
|
+
"frameId": 1,
|
|
25
|
+
"phase": "update",
|
|
26
|
+
"startMicros": 1100,
|
|
27
|
+
"endMicros": 1120,
|
|
28
|
+
"durationMicros": 20
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"kind": "phase",
|
|
32
|
+
"source": "app",
|
|
33
|
+
"frameId": 2,
|
|
34
|
+
"phase": "input",
|
|
35
|
+
"startMicros": 2000,
|
|
36
|
+
"endMicros": 2010,
|
|
37
|
+
"durationMicros": 10
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"kind": "skip",
|
|
41
|
+
"source": "render",
|
|
42
|
+
"frameId": 2,
|
|
43
|
+
"phase": "submit",
|
|
44
|
+
"reason": "no-render"
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"completeness": {
|
|
48
|
+
"status": "overflow",
|
|
49
|
+
"retainedEventCount": 4,
|
|
50
|
+
"droppedEventCount": 2,
|
|
51
|
+
"firstAffectedFrameId": 2,
|
|
52
|
+
"lastAffectedFrameId": 2
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"captureId": "capture-0004",
|
|
4
|
+
"timeUnit": "microseconds",
|
|
5
|
+
"frameLimit": 2,
|
|
6
|
+
"eventLimit": 4,
|
|
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
|
+
"completeness": {
|
|
23
|
+
"status": "partial",
|
|
24
|
+
"retainedEventCount": 1,
|
|
25
|
+
"droppedEventCount": 0,
|
|
26
|
+
"incompleteReason": "profile-sink-failed",
|
|
27
|
+
"firstAffectedFrameId": 1,
|
|
28
|
+
"lastAffectedFrameId": 1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.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
|
+
{
|
|
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": "skip",
|
|
23
|
+
"source": "render",
|
|
24
|
+
"frameId": 1,
|
|
25
|
+
"phase": "submit",
|
|
26
|
+
"reason": "no-render"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"kind": "phase",
|
|
30
|
+
"source": "app",
|
|
31
|
+
"frameId": 2,
|
|
32
|
+
"phase": "update",
|
|
33
|
+
"startMicros": 2000,
|
|
34
|
+
"endMicros": 2200,
|
|
35
|
+
"durationMicros": 200
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"completeness": {
|
|
39
|
+
"status": "complete",
|
|
40
|
+
"retainedEventCount": 3,
|
|
41
|
+
"droppedEventCount": 0
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -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
|
+
});
|