probatio 0.1.2 → 0.2.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.
@@ -1,5 +1,7 @@
1
1
  // Inspector line samples shared by the node:test collector, the Mocha collector,
2
2
  // and a plain-script child. This file has no test hooks and writes no shard.
3
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
4
+ import { Session as SyncSession } from "node:inspector"
3
5
  import { Session } from "node:inspector/promises"
4
6
  import { findSourceMap } from "node:module"
5
7
  import path from "node:path"
@@ -31,32 +33,14 @@ function decodeSegments(row) {
31
33
  }
32
34
 
33
35
  /**
36
+ * Offsets to source lines, shared by the async sampler (test processes) and the sync one (children).
37
+ * Paths are relative to `root`. node_modules and paths outside root are left out.
34
38
  * @param {(rel: string) => boolean} [skipRel]
35
- * @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
39
+ * @param {string} [root]
36
40
  */
37
- export async function openLineSampler(skipRel) {
38
- const session = new Session()
39
- session.connect()
40
- await session.post("Profiler.enable")
41
- await session.post("Debugger.enable")
42
- await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
43
-
44
- const textCache = new Map()
41
+ function lineConverter(skipRel, root = process.cwd()) {
45
42
  const indexCache = new Map()
46
43
 
47
- async function scriptText(script) {
48
- if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
49
- let text = ""
50
- try {
51
- const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
52
- text = got.scriptSource || ""
53
- } catch {
54
- text = ""
55
- }
56
- textCache.set(script.scriptId, text)
57
- return text
58
- }
59
-
60
44
  function relPath(url) {
61
45
  if (!url || url.startsWith("node:")) return null
62
46
  let abs
@@ -65,7 +49,7 @@ export async function openLineSampler(skipRel) {
65
49
  } catch {
66
50
  return null
67
51
  }
68
- const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
52
+ const rel = path.relative(root, abs).split(path.sep).join("/")
69
53
  if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
70
54
  if (rel.split("/").includes("node_modules")) return null
71
55
  if (skipRel && skipRel(rel)) return null
@@ -163,18 +147,55 @@ export async function openLineSampler(skipRel) {
163
147
  return counts
164
148
  }
165
149
 
150
+ /** Lines with a positive count in one script, or null when the script is not ours. */
151
+ function scriptLines(script, text) {
152
+ const file = relPath(script.url)
153
+ if (!file || !text) return null
154
+ const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
155
+ const found = []
156
+ for (const [line, count] of counts) {
157
+ if (count > 0) found.push({ file, line })
158
+ }
159
+ return found
160
+ }
161
+
162
+ return { relPath, scriptLines }
163
+ }
164
+
165
+ /**
166
+ * @param {(rel: string) => boolean} [skipRel]
167
+ * @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
168
+ */
169
+ export async function openLineSampler(skipRel) {
170
+ const session = new Session()
171
+ session.connect()
172
+ await session.post("Profiler.enable")
173
+ await session.post("Debugger.enable")
174
+ await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
175
+
176
+ const textCache = new Map()
177
+ const convert = lineConverter(skipRel)
178
+
179
+ async function scriptText(script) {
180
+ if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
181
+ let text = ""
182
+ try {
183
+ const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
184
+ text = got.scriptSource || ""
185
+ } catch {
186
+ text = ""
187
+ }
188
+ textCache.set(script.scriptId, text)
189
+ return text
190
+ }
191
+
166
192
  async function positiveLines() {
167
193
  const cov = await session.post("Profiler.takePreciseCoverage")
168
194
  const found = []
169
195
  for (const script of cov.result || []) {
170
- const file = relPath(script.url)
171
- if (!file) continue
172
- const text = await scriptText(script)
173
- if (!text) continue
174
- const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
175
- for (const [line, count] of counts) {
176
- if (count > 0) found.push({ file, line })
177
- }
196
+ if (!convert.relPath(script.url)) continue
197
+ const lines = convert.scriptLines(script, await scriptText(script))
198
+ if (lines) found.push(...lines)
178
199
  }
179
200
  return found
180
201
  }
@@ -186,3 +207,98 @@ export async function openLineSampler(skipRel) {
186
207
  },
187
208
  }
188
209
  }
210
+
211
+ /**
212
+ * The same sampler with synchronous calls, so an `exit` handler can read it.
213
+ * `exit` runs after process.exit(), where a promise never settles.
214
+ * @param {(rel: string) => boolean} [skipRel]
215
+ * @param {string} [root]
216
+ */
217
+ export function openSyncLineSampler(skipRel, root) {
218
+ const session = new SyncSession()
219
+ session.connect()
220
+ const post = (method, params) => {
221
+ let result
222
+ let failure = null
223
+ let done = false
224
+ session.post(method, params, (err, value) => {
225
+ failure = err
226
+ result = value
227
+ done = true
228
+ })
229
+ if (!done) throw new Error(`inspector ${method} did not answer synchronously`)
230
+ if (failure) throw failure
231
+ return result
232
+ }
233
+ post("Profiler.enable")
234
+ post("Debugger.enable")
235
+ post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
236
+ const convert = lineConverter(skipRel, root)
237
+ return {
238
+ positiveLines() {
239
+ const cov = post("Profiler.takePreciseCoverage")
240
+ const found = []
241
+ for (const script of cov.result || []) {
242
+ if (!convert.relPath(script.url)) continue
243
+ let text = ""
244
+ try {
245
+ text = post("Debugger.getScriptSource", { scriptId: script.scriptId }).scriptSource || ""
246
+ } catch {
247
+ text = ""
248
+ }
249
+ const lines = convert.scriptLines(script, text)
250
+ if (lines) found.push(...lines)
251
+ }
252
+ return found
253
+ },
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Called inside a test process that collects a line map. Every node child it starts
259
+ * loads child-lines.mjs through NODE_OPTIONS, with no import in the child's own code.
260
+ * The marker tells child-lines that this process already has test hooks.
261
+ */
262
+ export function exposeChildLines() {
263
+ globalThis.__probatioTestHooks = true
264
+ // One dump dir per test process. Test files can run in parallel, and a child's lines
265
+ // belong to the test in the process that started it, not to a test in another file.
266
+ const mapPath = process.env.PROBATIO_COVERAGE_MAP
267
+ if (mapPath) process.env.PROBATIO_CHILD_DIR = childDir(mapPath)
268
+ const childLines = new URL("./child-lines.mjs", import.meta.url).href
269
+ const flag = `--import=${childLines}`
270
+ const current = process.env.NODE_OPTIONS || ""
271
+ if (!current.includes(flag)) process.env.NODE_OPTIONS = current ? `${current} ${flag}` : flag
272
+ if (!process.env.PROBATIO_PACKAGE_ROOT) process.env.PROBATIO_PACKAGE_ROOT = process.cwd()
273
+ }
274
+
275
+ export function childDir(mapPath) {
276
+ return path.join(`${mapPath}.children`, String(process.pid))
277
+ }
278
+
279
+ /** Line dumps the children of the current test left. Each one is read once. */
280
+ export function takeChildHits(mapPath) {
281
+ const dir = childDir(mapPath)
282
+ if (!existsSync(dir)) return []
283
+ const hits = []
284
+ for (const name of readdirSync(dir)) {
285
+ if (!name.endsWith(".json")) continue
286
+ const full = path.join(dir, name)
287
+ try {
288
+ const parsed = JSON.parse(readFileSync(full, "utf8"))
289
+ const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
290
+ for (const hit of list) {
291
+ if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
292
+ hits.push({ file: hit.file, line: hit.line })
293
+ }
294
+ } catch {
295
+ // A torn dump is ignored. The parent does not invent a line.
296
+ }
297
+ try {
298
+ unlinkSync(full)
299
+ } catch {
300
+ // The next test must not inherit this dump.
301
+ }
302
+ }
303
+ return hits
304
+ }
@@ -85,8 +85,8 @@ public class ProbatioJacocoRun {
85
85
  System.err.println("java coverage: tests ran but JaCoCo recorded no classes");
86
86
  System.exit(1);
87
87
  }
88
- // jsoup's integration tests leave Netty loops running. A normal return would
89
- // wait on those threads until the parent clock kills the dump.
88
+ // Integration tests can leave non-daemon threads running (jsoup leaves Netty loops).
89
+ // A normal return would wait on those threads until the parent clock kills the dump.
90
90
  System.exit(0);
91
91
  }
92
92
 
@@ -0,0 +1,58 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * `.probatio.json` at the package root. How to run this project's suite, written by the
5
+ * project and visible in review. Probatio does not patch a project's files or environment
6
+ * to make one repository pass. A project that needs that says so here.
7
+ */
8
+ export const CONFIG_FILE = ".probatio.json";
9
+ const EMPTY = { env: {}, testLine: null, passLine: null };
10
+ export function readProjectConfig(pkg) {
11
+ const file = path.join(pkg, CONFIG_FILE);
12
+ if (!existsSync(file))
13
+ return EMPTY;
14
+ let raw;
15
+ try {
16
+ raw = JSON.parse(readFileSync(file, "utf8"));
17
+ }
18
+ catch (error) {
19
+ return { error: `${CONFIG_FILE} is not JSON (${error instanceof Error ? error.message : String(error)})` };
20
+ }
21
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
22
+ return { error: `${CONFIG_FILE} must be one JSON object` };
23
+ const body = raw;
24
+ const env = {};
25
+ if (body.env !== undefined) {
26
+ if (!body.env || typeof body.env !== "object" || Array.isArray(body.env))
27
+ return { error: `${CONFIG_FILE} env must be an object of strings` };
28
+ for (const [key, value] of Object.entries(body.env)) {
29
+ if (typeof value !== "string")
30
+ return { error: `${CONFIG_FILE} env.${key} must be a string` };
31
+ env[key] = value;
32
+ }
33
+ }
34
+ const testLine = pattern(body.testLine, "testLine");
35
+ if (typeof testLine === "string")
36
+ return { error: testLine };
37
+ const passLine = pattern(body.passLine, "passLine");
38
+ if (typeof passLine === "string")
39
+ return { error: passLine };
40
+ return { env, testLine, passLine };
41
+ }
42
+ /** The config, or the empty one when the file is unreadable. The runner refuses that case earlier. */
43
+ export function projectConfig(pkg) {
44
+ const read = readProjectConfig(pkg);
45
+ return "error" in read ? EMPTY : read;
46
+ }
47
+ function pattern(value, name) {
48
+ if (value === undefined || value === null)
49
+ return null;
50
+ if (typeof value !== "string" || value.length === 0)
51
+ return `${CONFIG_FILE} ${name} must be a non-empty string`;
52
+ try {
53
+ return new RegExp(value);
54
+ }
55
+ catch (error) {
56
+ return `${CONFIG_FILE} ${name} is not a regular expression (${error instanceof Error ? error.message : String(error)})`;
57
+ }
58
+ }