ccqa-tools 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -0
- package/dist/coverage/collector.cjs +229 -0
- package/dist/coverage/collector.d.cts +183 -0
- package/dist/coverage/collector.d.ts +183 -0
- package/dist/coverage/collector.js +225 -0
- package/dist/coverage/core.cjs +176 -0
- package/dist/coverage/core.d.cts +157 -0
- package/dist/coverage/core.d.ts +157 -0
- package/dist/coverage/core.js +163 -0
- package/dist/coverage/middleware.cjs +161 -0
- package/dist/coverage/middleware.d.cts +23 -0
- package/dist/coverage/middleware.d.ts +23 -0
- package/dist/coverage/middleware.js +158 -0
- package/dist/coverage/next-loader.cjs +158 -0
- package/dist/coverage/next-loader.d.cts +18 -0
- package/dist/coverage/next-loader.d.ts +19 -0
- package/dist/coverage/next-loader.js +158 -0
- package/dist/coverage/next.cjs +101 -0
- package/dist/coverage/next.d.cts +33 -0
- package/dist/coverage/next.d.ts +33 -0
- package/dist/coverage/next.js +100 -0
- package/dist/coverage/register.cjs +741 -0
- package/dist/coverage/register.d.cts +1 -0
- package/dist/coverage/register.d.ts +1 -0
- package/dist/coverage/register.js +716 -0
- package/dist/coverage/slack.cjs +228 -0
- package/dist/coverage/slack.d.cts +47 -0
- package/dist/coverage/slack.d.ts +47 -0
- package/dist/coverage/slack.js +225 -0
- package/dist/coverage/temporal-workflow.cjs +154 -0
- package/dist/coverage/temporal-workflow.d.cts +28 -0
- package/dist/coverage/temporal-workflow.d.ts +28 -0
- package/dist/coverage/temporal-workflow.js +153 -0
- package/dist/coverage/temporal.cjs +253 -0
- package/dist/coverage/temporal.d.cts +40 -0
- package/dist/coverage/temporal.d.ts +40 -0
- package/dist/coverage/temporal.js +250 -0
- package/package.json +95 -0
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# ccqa-tools
|
|
2
|
+
|
|
3
|
+
What `ccqa` needs installed **inside the application under test**, as one
|
|
4
|
+
dependency with a directory per feature. Today that is coverage; the shape is
|
|
5
|
+
here so the next one does not cost the consuming repository another dependency
|
|
6
|
+
review.
|
|
7
|
+
|
|
8
|
+
Everything in here runs in somebody else's application, which sets the rule for
|
|
9
|
+
what may be added: **near-zero dependencies**. Coverage's only runtime
|
|
10
|
+
dependency is `acorn`, and the Temporal integration is an optional peer. A
|
|
11
|
+
feature that needs more than that belongs in its own package, or every consumer
|
|
12
|
+
of one feature carries the others'.
|
|
13
|
+
|
|
14
|
+
## Coverage
|
|
15
|
+
|
|
16
|
+
`ccqa run --coverage` says which files each spec actually reached — including
|
|
17
|
+
the ones a Temporal activity ran in another process. The browser half needs
|
|
18
|
+
nothing installed: `ccqa` reads V8's own counters. This is the server half.
|
|
19
|
+
|
|
20
|
+
### What it does
|
|
21
|
+
|
|
22
|
+
`ccqa` sets a cookie on the browser at the start of each spec. Every request
|
|
23
|
+
that browser makes carries it — and nothing else does, which is what lets an
|
|
24
|
+
instrumented server tell one spec's traffic from the rest of a shared
|
|
25
|
+
environment's. A load hook rewrites the project's own modules so that entering
|
|
26
|
+
one records the file into whichever spec's context the current request belongs
|
|
27
|
+
to, and a collector pushes what it has once a second.
|
|
28
|
+
|
|
29
|
+
While no spec is running, an instrumented call is one global read and one
|
|
30
|
+
truthiness test. That is the whole reason this needs no sampling — and the
|
|
31
|
+
reason to leave the switch off in production rather than "just leaving it on".
|
|
32
|
+
|
|
33
|
+
### Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pnpm add -D ccqa-tools
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Turn it on
|
|
40
|
+
|
|
41
|
+
Three things, and the process is measured:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
CCQA_COVERAGE=1 \
|
|
45
|
+
CCQA_COVERAGE_ENDPOINT=http://<host running ccqa run>:4757 \
|
|
46
|
+
node --import ccqa-tools/coverage/register server.js
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
With `CCQA_COVERAGE` unset the register hook is never loaded and the
|
|
50
|
+
application pays nothing at all.
|
|
51
|
+
|
|
52
|
+
| Variable | Meaning |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `CCQA_COVERAGE` | `1` to enable. Any other value is a `<runId>.<specId>` and becomes the ambient spec for a process with no inbound request to read — a worker started per spec. |
|
|
55
|
+
| `CCQA_COVERAGE_ENDPOINT` | Where to push. Unset collects in memory and reports nothing. |
|
|
56
|
+
| `CCQA_COVERAGE_TOKEN` | Sent as a bearer token, but the current `ccqa` sink does not check it. For a relay in front of it, or a future endpoint that does. |
|
|
57
|
+
| `CCQA_COVERAGE_ROOT` | Root that file ids are relative to. Defaults to `process.cwd()`. In a workspace, point it at a directory containing the sibling packages too, and give `ccqa` the same one through `coverage.projectRoot`. |
|
|
58
|
+
| `CCQA_COVERAGE_INCLUDE` | Comma-separated directories to instrument, relative to the root. Defaults to `src`. |
|
|
59
|
+
| `CCQA_COVERAGE_DEBUG` | `1` for diagnostics on stderr. |
|
|
60
|
+
|
|
61
|
+
### If the server code is bundled
|
|
62
|
+
|
|
63
|
+
A load hook only ever sees what the runtime loads, so a bundled server is
|
|
64
|
+
invisible to it — the bundler already swallowed the sources. Instrument at
|
|
65
|
+
build time instead, and keep the register hook: it is what opens the per-request
|
|
66
|
+
context the instrumentation records into.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// next.config.ts
|
|
70
|
+
import { withCoverage } from "ccqa-tools/coverage/next";
|
|
71
|
+
|
|
72
|
+
export default withCoverage(nextConfig, { root: import.meta.dirname });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Only server bundles are touched. The browser is measured through V8's counters
|
|
76
|
+
and needs nothing injected.
|
|
77
|
+
|
|
78
|
+
### If a chat platform drives the flow
|
|
79
|
+
|
|
80
|
+
A Slack webhook is sent by Slack, not by the browser under test, so none of the
|
|
81
|
+
carriers above is present and everything the flow runs would be unattributed.
|
|
82
|
+
What the payload does say is which user acted, and recording that is enough for
|
|
83
|
+
`ccqa` to work out the rest at its end.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { slackActor } from "ccqa-tools/coverage/slack";
|
|
87
|
+
|
|
88
|
+
app.use(slackActor()); // after whatever parses the body
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
It takes no arguments on purpose. It does not know which users are being
|
|
92
|
+
measured, when a test is running, or what a spec is — the run holds all of
|
|
93
|
+
that and nothing is ever sent back here. Which users matter is declared in the
|
|
94
|
+
consuming project's `.ccqa/config.yaml`, under `coverage.actors.slack`.
|
|
95
|
+
|
|
96
|
+
Extraction is a fixed list of the places Slack puts a user id (Events API,
|
|
97
|
+
interactivity payloads, slash commands). A shape not on the list records
|
|
98
|
+
nothing rather than guessing: a wrong identity is a wrong answer, a missing one
|
|
99
|
+
is a counted gap.
|
|
100
|
+
|
|
101
|
+
### If work continues in Temporal
|
|
102
|
+
|
|
103
|
+
Three interceptors carry the spec across the three hops. The workflow one lives
|
|
104
|
+
at its own specifier because Temporal evaluates it inside a deterministic
|
|
105
|
+
sandbox with no Node built-ins. The Temporal SDK is not declared as a
|
|
106
|
+
dependency here — these subpaths resolve it from the application's own tree,
|
|
107
|
+
and declaring it would put a protobuf runtime in the lockfile of every consumer
|
|
108
|
+
that has no Temporal at all.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import {
|
|
112
|
+
createClientInterceptor,
|
|
113
|
+
createActivityInterceptor,
|
|
114
|
+
} from "ccqa-tools/coverage/temporal";
|
|
115
|
+
|
|
116
|
+
new Client({ interceptors: { workflow: [createClientInterceptor()] } });
|
|
117
|
+
|
|
118
|
+
Worker.create({
|
|
119
|
+
interceptors: {
|
|
120
|
+
activity: [() => ({ inbound: createActivityInterceptor() })],
|
|
121
|
+
workflowModules: ["ccqa-tools/coverage/temporal/workflow"],
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A pre-built workflow bundle takes the same specifier through
|
|
127
|
+
`bundleWorkflowCode({ workflowInterceptorModules })`.
|
|
128
|
+
|
|
129
|
+
The workflow body itself is not measured. It runs in the sandbox, which is one
|
|
130
|
+
of the places nothing can reach; the activities it schedules are ordinary Node
|
|
131
|
+
and are measured normally.
|
|
132
|
+
|
|
133
|
+
### If the server is not Node's `http`
|
|
134
|
+
|
|
135
|
+
`ccqa-tools/coverage/register` wraps `node:http`, which covers every framework that
|
|
136
|
+
receives its requests from it. Anything else — an edge runtime, a fetch-style
|
|
137
|
+
handler — opens the context itself:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { coverageMiddleware, withCoverage } from "ccqa-tools/coverage/middleware";
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Things that will waste your afternoon
|
|
144
|
+
|
|
145
|
+
- **A task runner that filters the environment turns this off silently.** If
|
|
146
|
+
the process gets `NODE_OPTIONS` but not `CCQA_COVERAGE` — which is exactly
|
|
147
|
+
what a runner with a declared-variable allowlist does — the preload loads
|
|
148
|
+
and then does nothing. Check that `CCQA_COVERAGE_DEBUG=1` prints `armed in
|
|
149
|
+
pid …` from the process actually serving requests, not just its launcher.
|
|
150
|
+
- **Prefer an absolute path in `--import`.** `NODE_OPTIONS` is inherited by
|
|
151
|
+
every child process, and in a monorepo those include packages that cannot
|
|
152
|
+
resolve `ccqa-tools` at all. `--import file:///abs/path/to/register.js`
|
|
153
|
+
has no such failure mode.
|
|
154
|
+
- **Clear the build cache after adding the plugin.** A bundler that cached its
|
|
155
|
+
modules will not re-run a loader you just added.
|
|
156
|
+
- **Never write to stdout from a preload.** It is shared with whatever the host
|
|
157
|
+
process was parsing there. This package writes to stderr only. Most of that
|
|
158
|
+
is behind `CCQA_COVERAGE_DEBUG`, but a handful of warnings that mean nothing
|
|
159
|
+
is being reported at all — no endpoint set, a push failing, the load hooks
|
|
160
|
+
never installing — print regardless, since those are not debugging detail.
|
|
161
|
+
|
|
162
|
+
### What it cannot see
|
|
163
|
+
|
|
164
|
+
Declared up front, because a silent gap reads as "never reached" and that is
|
|
165
|
+
the answer this exists to produce:
|
|
166
|
+
|
|
167
|
+
- code that runs outside any request — schedulers, queue consumers, timers
|
|
168
|
+
started before the spec;
|
|
169
|
+
- worker threads and other separate isolates, including a Temporal workflow
|
|
170
|
+
body;
|
|
171
|
+
- module top level, which runs once and would otherwise belong to whichever
|
|
172
|
+
spec happened to import it first. Recorded separately;
|
|
173
|
+
- a process killed outright (`SIGKILL`, an OOM kill) rather than exited — the
|
|
174
|
+
collector flushes on `beforeExit`, which a hard kill never fires.
|
|
175
|
+
|
|
176
|
+
Executions that run during a spec but outside its context are counted, and
|
|
177
|
+
`ccqa` reports the count next to the result.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/coverage/core.ts
|
|
3
|
+
const RUNTIME_KEY = Symbol.for("ccqa.coverage.runtime");
|
|
4
|
+
function globals() {
|
|
5
|
+
return globalThis;
|
|
6
|
+
}
|
|
7
|
+
function getRuntime() {
|
|
8
|
+
return globals()[RUNTIME_KEY];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The key both halves of the collector agree on. A space separates them safely:
|
|
12
|
+
* a spec id can never contain one, so identity keys and spec ids stay disjoint
|
|
13
|
+
* where the collector tracks both in one map.
|
|
14
|
+
*/
|
|
15
|
+
function actorBucketKey(tag, at) {
|
|
16
|
+
return `${tag} ${at}`;
|
|
17
|
+
}
|
|
18
|
+
/** Drops a spec's bucket once it has been handed to the collector. */
|
|
19
|
+
function closeBucket(runtime, specId) {
|
|
20
|
+
if (runtime.buckets.delete(specId)) armGate(runtime);
|
|
21
|
+
}
|
|
22
|
+
/** Drops an identity's bucket once it has been handed to the collector. */
|
|
23
|
+
function closeActorBucket(runtime, key) {
|
|
24
|
+
if (runtime.actors.delete(key)) armGate(runtime);
|
|
25
|
+
}
|
|
26
|
+
function armGate(runtime) {
|
|
27
|
+
runtime.active = runtime.buckets.size + runtime.actors.size;
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/coverage/runtime-env.ts
|
|
31
|
+
/**
|
|
32
|
+
* Diagnostics go to stderr and nowhere else. A `--import` preload is inherited
|
|
33
|
+
* by every child node process, and writing to stdout corrupts whatever the host
|
|
34
|
+
* was parsing there — enough to make a framework's own toolchain fail to start.
|
|
35
|
+
*/
|
|
36
|
+
function debugLog(config, message) {
|
|
37
|
+
if (!config.debug) return;
|
|
38
|
+
process.stderr.write(`[ccqa-tools] ${message}\n`);
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/coverage/collector.ts
|
|
42
|
+
/**
|
|
43
|
+
* Ships what this process reached to a sink, on a timer.
|
|
44
|
+
*
|
|
45
|
+
* Push, not pull: behind a load balancer nothing can address one replica of N,
|
|
46
|
+
* so an endpoint the runner scrapes would silently report a fraction of the
|
|
47
|
+
* truth. Every replica pushes instead, and the sink unions — file sets make
|
|
48
|
+
* that commutative, associative and idempotent, so the sink never has to know
|
|
49
|
+
* how many replicas there were.
|
|
50
|
+
*/
|
|
51
|
+
const DEFAULT_INTERVAL_MS = 1e3;
|
|
52
|
+
const DEFAULT_IDLE_TTL_MS = 12e4;
|
|
53
|
+
/**
|
|
54
|
+
* How many times the exit-time flush may run. More than one because a push the
|
|
55
|
+
* timer started may still be in flight when the first one fires, and that one
|
|
56
|
+
* returns without sending anything.
|
|
57
|
+
*/
|
|
58
|
+
const MAX_EXIT_FLUSHES = 3;
|
|
59
|
+
/**
|
|
60
|
+
* How often a process that instrumented nothing re-announces itself when it has
|
|
61
|
+
* nothing else to send.
|
|
62
|
+
*
|
|
63
|
+
* It has no files and no attributions to report, so the delta it pushes is
|
|
64
|
+
* empty forever after the first success — and the run that heard it is over.
|
|
65
|
+
* Without this, every later run against the same process is told nothing
|
|
66
|
+
* reported at all, rather than that one process is blind.
|
|
67
|
+
*/
|
|
68
|
+
const BLIND_HEARTBEAT_MS = 3e4;
|
|
69
|
+
function createCollectorState() {
|
|
70
|
+
return {
|
|
71
|
+
sent: /* @__PURE__ */ new Map(),
|
|
72
|
+
sentBoot: /* @__PURE__ */ new Set(),
|
|
73
|
+
sentActors: /* @__PURE__ */ new Map(),
|
|
74
|
+
lastChange: /* @__PURE__ */ new Map(),
|
|
75
|
+
lastSentUnattributed: 0,
|
|
76
|
+
lastSentUninstrumentedFiles: 0,
|
|
77
|
+
lastSentUninstrumentedProcess: false,
|
|
78
|
+
lastSentAt: 0,
|
|
79
|
+
droppedPushes: 0
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function startCollector(options, config) {
|
|
83
|
+
const runtime = getRuntime();
|
|
84
|
+
if (runtime === void 0) return () => {};
|
|
85
|
+
const idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
86
|
+
const state = createCollectorState();
|
|
87
|
+
let inFlight = false;
|
|
88
|
+
let consecutiveFailures = 0;
|
|
89
|
+
const flush = () => {
|
|
90
|
+
if (inFlight) return Promise.resolve();
|
|
91
|
+
evict(runtime, state, idleTtlMs);
|
|
92
|
+
const payload = diff(runtime, state);
|
|
93
|
+
if (payload === void 0) return Promise.resolve();
|
|
94
|
+
inFlight = true;
|
|
95
|
+
return post(options, payload).then(() => {
|
|
96
|
+
for (const [specId, files] of Object.entries(payload.specs)) {
|
|
97
|
+
const acked = state.sent.get(specId) ?? /* @__PURE__ */ new Set();
|
|
98
|
+
for (const file of files) acked.add(file);
|
|
99
|
+
state.sent.set(specId, acked);
|
|
100
|
+
}
|
|
101
|
+
for (const file of payload.boot) state.sentBoot.add(file);
|
|
102
|
+
for (const bucket of payload.actors) {
|
|
103
|
+
const key = actorBucketKey(bucket.tag, bucket.at);
|
|
104
|
+
const acked = state.sentActors.get(key) ?? /* @__PURE__ */ new Set();
|
|
105
|
+
for (const file of bucket.files) acked.add(file);
|
|
106
|
+
state.sentActors.set(key, acked);
|
|
107
|
+
}
|
|
108
|
+
state.lastSentUnattributed = payload.unattributed;
|
|
109
|
+
state.lastSentUninstrumentedFiles = payload.uninstrumentedFiles;
|
|
110
|
+
state.lastSentUninstrumentedProcess = payload.uninstrumentedProcess;
|
|
111
|
+
consecutiveFailures = 0;
|
|
112
|
+
}).catch((error) => {
|
|
113
|
+
state.droppedPushes++;
|
|
114
|
+
consecutiveFailures++;
|
|
115
|
+
if (config) debugLog(config, `push failed: ${String(error)}`);
|
|
116
|
+
if (consecutiveFailures === 1 || consecutiveFailures % 10 === 0) process.stderr.write(`[ccqa-tools] push to ${options.endpoint} failed ${consecutiveFailures} times in a row: ${String(error)}\n`);
|
|
117
|
+
}).finally(() => {
|
|
118
|
+
inFlight = false;
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
const timer = setInterval(() => void flush(), options.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
122
|
+
timer.unref?.();
|
|
123
|
+
let exitFlushes = 0;
|
|
124
|
+
const onBeforeExit = async () => {
|
|
125
|
+
if (consecutiveFailures > 0 || exitFlushes >= MAX_EXIT_FLUSHES) return;
|
|
126
|
+
exitFlushes++;
|
|
127
|
+
await flush();
|
|
128
|
+
};
|
|
129
|
+
process.on("beforeExit", onBeforeExit);
|
|
130
|
+
return () => {
|
|
131
|
+
clearInterval(timer);
|
|
132
|
+
process.off("beforeExit", onBeforeExit);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* `acked` is always a subset of `reached`, and neither ever shrinks, so equal
|
|
137
|
+
* sizes mean nothing new — skip the copy-then-filter and check that first.
|
|
138
|
+
*/
|
|
139
|
+
function freshOf(reached, acked) {
|
|
140
|
+
if (acked !== void 0 && acked.size === reached.size) return [];
|
|
141
|
+
const fresh = [];
|
|
142
|
+
for (const file of reached) if (acked === void 0 || !acked.has(file)) fresh.push(file);
|
|
143
|
+
return fresh;
|
|
144
|
+
}
|
|
145
|
+
/** Exported for tests: the sink's HTTP round trip is the only real-I/O part. */
|
|
146
|
+
function diff(runtime, state) {
|
|
147
|
+
const specs = {};
|
|
148
|
+
let any = false;
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
for (const [specId, files] of runtime.buckets) {
|
|
151
|
+
const fresh = freshOf(files, state.sent.get(specId));
|
|
152
|
+
if (fresh.length === 0) continue;
|
|
153
|
+
specs[specId] = fresh;
|
|
154
|
+
state.lastChange.set(specId, now);
|
|
155
|
+
any = true;
|
|
156
|
+
}
|
|
157
|
+
const actors = [];
|
|
158
|
+
for (const [key, bucket] of runtime.actors) {
|
|
159
|
+
const fresh = freshOf(bucket.files, state.sentActors.get(key));
|
|
160
|
+
if (fresh.length === 0) continue;
|
|
161
|
+
actors.push({
|
|
162
|
+
tag: bucket.tag,
|
|
163
|
+
at: bucket.at,
|
|
164
|
+
files: fresh
|
|
165
|
+
});
|
|
166
|
+
state.lastChange.set(key, now);
|
|
167
|
+
}
|
|
168
|
+
const boot = freshOf(runtime.boot, state.sentBoot);
|
|
169
|
+
const unattributedChanged = runtime.unattributed !== state.lastSentUnattributed;
|
|
170
|
+
const healthChanged = runtime.uninstrumentedFiles !== state.lastSentUninstrumentedFiles || runtime.uninstrumentedProcess !== state.lastSentUninstrumentedProcess;
|
|
171
|
+
const reannounce = runtime.uninstrumentedProcess && now - state.lastSentAt >= BLIND_HEARTBEAT_MS;
|
|
172
|
+
if (!any && boot.length === 0 && actors.length === 0 && !unattributedChanged && !healthChanged && !reannounce) return;
|
|
173
|
+
state.lastSentAt = now;
|
|
174
|
+
return {
|
|
175
|
+
protocol: 1,
|
|
176
|
+
pid: runtime.pid,
|
|
177
|
+
startedAt: runtime.startedAt,
|
|
178
|
+
unattributed: runtime.unattributed,
|
|
179
|
+
uninstrumentedFiles: runtime.uninstrumentedFiles,
|
|
180
|
+
uninstrumentedProcess: runtime.uninstrumentedProcess,
|
|
181
|
+
specs,
|
|
182
|
+
boot,
|
|
183
|
+
actors,
|
|
184
|
+
droppedPushes: state.droppedPushes
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Forgets specs that stopped changing and have nothing outstanding, so a long
|
|
189
|
+
* lived server does not accumulate every spec it has ever served — and so the
|
|
190
|
+
* hot-path gate falls back to zero between runs.
|
|
191
|
+
*/
|
|
192
|
+
function evict(runtime, state, idleTtlMs) {
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const cutoff = now - idleTtlMs;
|
|
195
|
+
const actors = [...runtime.actors].map(([key, bucket]) => [key, bucket.files]);
|
|
196
|
+
dropQuiet([...runtime.buckets], state.sent, state, cutoff, now, (key) => closeBucket(runtime, key));
|
|
197
|
+
dropQuiet(actors, state.sentActors, state, cutoff, now, (key) => closeActorBucket(runtime, key));
|
|
198
|
+
}
|
|
199
|
+
function dropQuiet(entries, sent, state, cutoff, now, close) {
|
|
200
|
+
for (const [key, files] of entries) {
|
|
201
|
+
const seen = state.lastChange.get(key);
|
|
202
|
+
if (seen === void 0) {
|
|
203
|
+
state.lastChange.set(key, now);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (seen > cutoff) continue;
|
|
207
|
+
if (freshOf(files, sent.get(key)).length > 0) continue;
|
|
208
|
+
close(key);
|
|
209
|
+
sent.delete(key);
|
|
210
|
+
state.lastChange.delete(key);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function post(options, payload) {
|
|
214
|
+
const headers = { "content-type": "application/json" };
|
|
215
|
+
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
216
|
+
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
217
|
+
const response = await fetch(options.endpoint, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers,
|
|
220
|
+
body: JSON.stringify(payload),
|
|
221
|
+
signal: AbortSignal.timeout(intervalMs * 5)
|
|
222
|
+
});
|
|
223
|
+
if (!response.ok) throw new Error(`sink returned ${response.status}`);
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
exports.createCollectorState = createCollectorState;
|
|
227
|
+
exports.diff = diff;
|
|
228
|
+
exports.evict = evict;
|
|
229
|
+
exports.startCollector = startCollector;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
//#region src/coverage/core.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The hot path, and the only module instrumented application code imports.
|
|
4
|
+
*
|
|
5
|
+
* It has **no imports at all**, on purpose. Bundlers pull this file into every
|
|
6
|
+
* layer they build — a Next.js RSC bundle, an SSR bundle, a route-handler
|
|
7
|
+
* bundle, a Temporal workflow sandbox — and each of those rejects a different
|
|
8
|
+
* subset of Node built-ins. `node:http` alone is enough to fail a Next.js
|
|
9
|
+
* webpack build outright. Everything platform-specific lives behind
|
|
10
|
+
* `ccqa-tools/coverage/register`, which only ever loads in real Node.
|
|
11
|
+
*
|
|
12
|
+
* For the same reason the process-wide state hangs off `globalThis` rather than
|
|
13
|
+
* module scope: a bundler produces several copies of this file per process, and
|
|
14
|
+
* a per-copy AsyncLocalStorage would shard attribution across them.
|
|
15
|
+
*/
|
|
16
|
+
/** The slice of AsyncLocalStorage this package needs, minus the import. */
|
|
17
|
+
interface ContextStorage<T> {
|
|
18
|
+
getStore(): T | undefined;
|
|
19
|
+
run<R>(store: T, fn: () => R): R;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What one in-flight execution carries.
|
|
23
|
+
*
|
|
24
|
+
* Exactly one of the two says where its reach goes. `specId` is set when the
|
|
25
|
+
* request itself named a spec. `actor` is set when nothing did, and all that is
|
|
26
|
+
* known is who caused the work and when it was first asked for — enough for the
|
|
27
|
+
* run to decide later, and nothing this process could decide on its own.
|
|
28
|
+
*/
|
|
29
|
+
interface CoverageStore {
|
|
30
|
+
specId?: string;
|
|
31
|
+
actor?: ActorMark;
|
|
32
|
+
files: Set<string>;
|
|
33
|
+
}
|
|
34
|
+
/** Who caused a request, and the instant it arrived. Never interpreted here. */
|
|
35
|
+
interface ActorMark {
|
|
36
|
+
tag: string;
|
|
37
|
+
at: number;
|
|
38
|
+
}
|
|
39
|
+
/** One identity's reach from one instant, as the collector ships it. */
|
|
40
|
+
interface ActorBucket {
|
|
41
|
+
tag: string;
|
|
42
|
+
at: number;
|
|
43
|
+
files: Set<string>;
|
|
44
|
+
}
|
|
45
|
+
interface CoverageRuntime {
|
|
46
|
+
/**
|
|
47
|
+
* Shape version of this object. Two builds of `ccqa-tools` can end up in
|
|
48
|
+
* one process (an app dependency and a hoisted transitive one); the first to
|
|
49
|
+
* install wins and the rest defer to it, so the shape has to be recognisable.
|
|
50
|
+
*/
|
|
51
|
+
readonly protocol: 1;
|
|
52
|
+
readonly als: ContextStorage<CoverageStore>;
|
|
53
|
+
/** specId -> reached file ids. Union is idempotent, so merging is free. */
|
|
54
|
+
readonly buckets: Map<string, Set<string>>;
|
|
55
|
+
/**
|
|
56
|
+
* `<tag> <at>` -> what that identity reached from that instant.
|
|
57
|
+
*
|
|
58
|
+
* Kept apart from `buckets` because nothing here knows which spec they belong
|
|
59
|
+
* to — or whether they belong to one at all. Most of them are other people
|
|
60
|
+
* using the same environment, and the run discards those.
|
|
61
|
+
*/
|
|
62
|
+
readonly actors: Map<string, ActorBucket>;
|
|
63
|
+
/**
|
|
64
|
+
* Files reached at module top level. Kept out of the spec buckets because the
|
|
65
|
+
* first spec to import a module would otherwise own it, making the result
|
|
66
|
+
* depend on spec execution order.
|
|
67
|
+
*/
|
|
68
|
+
readonly boot: Set<string>;
|
|
69
|
+
/**
|
|
70
|
+
* Number of open buckets, spec and identity alike. `record()` returns on zero
|
|
71
|
+
* before touching anything else — the reason instrumentation costs nothing
|
|
72
|
+
* while nothing is being measured, and the reason this package does not need
|
|
73
|
+
* sampling. A field rather than a `.size` sum because this check is the hot
|
|
74
|
+
* path itself.
|
|
75
|
+
*
|
|
76
|
+
* An identity bucket arms it the same way a spec does, so an application with
|
|
77
|
+
* the actor preset installed pays while any identity has acted recently, not
|
|
78
|
+
* only while a spec is running. That is the price of the application not
|
|
79
|
+
* being told which identities matter.
|
|
80
|
+
*/
|
|
81
|
+
active: number;
|
|
82
|
+
/**
|
|
83
|
+
* Executions that ran while a spec was open but outside its async context.
|
|
84
|
+
* A silent gap here would read as "never reached", so it is always counted.
|
|
85
|
+
*/
|
|
86
|
+
unattributed: number;
|
|
87
|
+
/**
|
|
88
|
+
* Files a load hook saw but could not turn into recorded coverage — an
|
|
89
|
+
* undecodable source, or a parse error. Uncounted, this would render
|
|
90
|
+
* identically to "reached by no spec", which is a lie.
|
|
91
|
+
*/
|
|
92
|
+
uninstrumentedFiles: number;
|
|
93
|
+
/**
|
|
94
|
+
* Set when nothing in this process can be instrumented at all, so every file
|
|
95
|
+
* it runs is missing rather than some of them.
|
|
96
|
+
*
|
|
97
|
+
* A flag and not a count, because the failure is the process: counted as one
|
|
98
|
+
* file it reads as a rounding error next to the thousands it actually hides.
|
|
99
|
+
*/
|
|
100
|
+
uninstrumentedProcess: boolean;
|
|
101
|
+
readonly pid: number;
|
|
102
|
+
readonly startedAt: number;
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/coverage/runtime-env.d.ts
|
|
106
|
+
interface CoverageConfig {
|
|
107
|
+
enabled: boolean;
|
|
108
|
+
/** Set when the process is dedicated to one spec and has no request to read. */
|
|
109
|
+
ambientSpecId: string | undefined;
|
|
110
|
+
root: string;
|
|
111
|
+
/** Path prefixes, relative to `root`, whose files get instrumented. */
|
|
112
|
+
include: string[];
|
|
113
|
+
debug: boolean;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/coverage/collector.d.ts
|
|
117
|
+
interface CollectorOptions {
|
|
118
|
+
endpoint: string;
|
|
119
|
+
token?: string | undefined;
|
|
120
|
+
intervalMs?: number;
|
|
121
|
+
/** Drops a spec's bucket this long after its last change and last flush. */
|
|
122
|
+
idleTtlMs?: number;
|
|
123
|
+
}
|
|
124
|
+
interface CoveragePush {
|
|
125
|
+
protocol: 1;
|
|
126
|
+
pid: number;
|
|
127
|
+
startedAt: number;
|
|
128
|
+
unattributed: number;
|
|
129
|
+
uninstrumentedFiles: number;
|
|
130
|
+
uninstrumentedProcess: boolean;
|
|
131
|
+
/** Only ids not yet accepted by the sink, per spec. */
|
|
132
|
+
specs: Record<string, string[]>;
|
|
133
|
+
boot: string[];
|
|
134
|
+
/**
|
|
135
|
+
* What each identity reached, and when the work was first asked for. Which
|
|
136
|
+
* spec that is — if any — is the run's to decide; this side only reports.
|
|
137
|
+
*/
|
|
138
|
+
actors: Array<{
|
|
139
|
+
tag: string;
|
|
140
|
+
at: number;
|
|
141
|
+
files: string[];
|
|
142
|
+
}>;
|
|
143
|
+
/**
|
|
144
|
+
* Failed push attempts over this process's whole life, not since the last
|
|
145
|
+
* ack. An application outlives the runs measuring it, and every second
|
|
146
|
+
* before the first of them stood up a sink is a failure — reported as a
|
|
147
|
+
* delta since the last ack, all of that arrives inside the first run and
|
|
148
|
+
* reads as that run having lost 25 minutes of reports. The sink subtracts
|
|
149
|
+
* what a process had already dropped when it first heard from it.
|
|
150
|
+
*/
|
|
151
|
+
droppedPushes: number;
|
|
152
|
+
}
|
|
153
|
+
/** What survives between ticks: ack state plus the counters it gates on. */
|
|
154
|
+
interface CollectorState {
|
|
155
|
+
/** Per spec, ids the sink has acknowledged. */
|
|
156
|
+
sent: Map<string, Set<string>>;
|
|
157
|
+
sentBoot: Set<string>;
|
|
158
|
+
/** Per identity bucket, ids the sink has acknowledged. */
|
|
159
|
+
sentActors: Map<string, Set<string>>;
|
|
160
|
+
/** Per bucket key (spec id or identity bucket), when it last gained a fresh id. */
|
|
161
|
+
lastChange: Map<string, number>;
|
|
162
|
+
/** `unattributed` as of the last push the sink acknowledged. */
|
|
163
|
+
lastSentUnattributed: number;
|
|
164
|
+
/** The two instrumentation-health figures as of that same push. */
|
|
165
|
+
lastSentUninstrumentedFiles: number;
|
|
166
|
+
lastSentUninstrumentedProcess: boolean;
|
|
167
|
+
/** When the last payload was built, for the blind-process heartbeat. */
|
|
168
|
+
lastSentAt: number;
|
|
169
|
+
/** Lifetime failed attempts. Never reset; the sink baselines it instead. */
|
|
170
|
+
droppedPushes: number;
|
|
171
|
+
}
|
|
172
|
+
declare function createCollectorState(): CollectorState;
|
|
173
|
+
declare function startCollector(options: CollectorOptions, config?: CoverageConfig): () => void;
|
|
174
|
+
/** Exported for tests: the sink's HTTP round trip is the only real-I/O part. */
|
|
175
|
+
declare function diff(runtime: CoverageRuntime, state: CollectorState): CoveragePush | undefined;
|
|
176
|
+
/**
|
|
177
|
+
* Forgets specs that stopped changing and have nothing outstanding, so a long
|
|
178
|
+
* lived server does not accumulate every spec it has ever served — and so the
|
|
179
|
+
* hot-path gate falls back to zero between runs.
|
|
180
|
+
*/
|
|
181
|
+
declare function evict(runtime: CoverageRuntime, state: CollectorState, idleTtlMs: number): void;
|
|
182
|
+
//#endregion
|
|
183
|
+
export { CollectorOptions, CollectorState, CoveragePush, createCollectorState, diff, evict, startCollector };
|