javascriptnode.jscode-coveragev8instrumentation

Node.js precision coverage with Inspector API


I am using the Inspector API to get precision coverage for JavaScript. I have implemented a target function in target.js (line annotations in target.js are added to reference them in the explanation) and a test runner in main.js that is supposed to collect precise coverage information. You can test the setup for yourself by running node main.js.

const inspector = require("node:inspector");
const fs = require("fs");

const target = require("./target.js").target;

async function run(session, data) {
    await new Promise((resolve, reject) => {
        session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true }, (err) => {
            err ? reject(err) : resolve();
        });
    });

    console.log("Input: ", data, " - Output: ", target(data));

    return await new Promise((resolve, reject) => {
        session.post("Profiler.takePreciseCoverage", (err, params) => {
            err ? reject(err) : resolve(params);
        });
    });
}

function printCoverage(result) {
    const content = fs.readFileSync("./target.js", { encoding: "utf-8" });

    for (let scriptCoverage of result) {
        if (!scriptCoverage.url.endsWith("target.js")) {
            continue;
        }

        for (let func of scriptCoverage.functions) {
            for (let range of func.ranges) {
                console.log(
                    func.functionName,
                    range.startOffset,
                    range.endOffset,
                    range.count,
                    content.substring(range.startOffset, range.endOffset).replaceAll("\n", "").replaceAll("\t", " ").replaceAll("    ", " ")
                );
            }
        }
    }
}

async function main() {
    inspector.open(9696);

    const session = new inspector.Session();
    session.connect();

    await new Promise((resolve, reject) => {
        session.post("Profiler.enable", (err) => {
            if (err) {
                reject(err);
            }

            resolve();
        });
    });

    const param1 = await run(session, "a");
    printCoverage(param1.result);

    const param2 = await run(session, "ab");
    printCoverage(param2.result);

    const param3 = await run(session, "ac");
    printCoverage(param3.result);
}

main();
// main.js
/* 1: */ function target(data) {
/* 2: */     if (data.length === 1) {
/* 3: */         return 1;
/* 4: */     }
/* 5: */ 
/* 6: */     if (data.length == 2) {
/* 7: */         if (data[0] === "a" && data[1] === "b") {
/* 8: */             return 2;
/* 9: */         }
/* a: */ 
/* b: */         if (data[0] === "a") {
/* c: */             return 3;
/* d: */         }
/* e: */     }
/* f: */ }

module.exports = {
    target: target
};
// target.js

Unfortunately, the output below looks quite unintuitive to me. I would have expected the coverage information to be:

Instead, the coverage output simply seems to tell me that the target()-function is covered and certain ranges in target() are not covered. Am I using the API wrong? Or is my expectation for block coverage wrong? How do I get 'true' basic block coverage in JavaScript?

Input:  a  - Output:  1
target 0 259 1 function target(data) { if (data.length === 1) {  return 1; } if (data.length == 2) {  if (data[0] === "a" && data[1] === "b") {   return 2;  }  if (data[0] === "a") {   return 3;  } }}
target 76 257 0  if (data.length == 2) {  if (data[0] === "a" && data[1] === "b") {   return 2;  }  if (data[0] === "a") {   return 3;  } }
Input:  ab  - Output:  2
target 0 259 1 function target(data) { if (data.length === 1) {  return 1; } if (data.length == 2) {  if (data[0] === "a" && data[1] === "b") {   return 2;  }  if (data[0] === "a") {   return 3;  } }}
target 51 76 0 {  return 1; }
target 187 251 0   if (data[0] === "a") {   return 3;  }
Input:  ac  - Output:  3
target 0 259 1 function target(data) { if (data.length === 1) {  return 1; } if (data.length == 2) {  if (data[0] === "a" && data[1] === "b") {   return 2;  }  if (data[0] === "a") {   return 3;  } }}
target 51 76 0 {  return 1; }
target 154 187 0 {   return 2;  }

Edit 1:

I think the picture below is better suited to explain my expectations. There are 9 basic blocks in the source code (if you include the implicit return undefined) and I want to retrieve which basic blocks have been covered.

For example, the input "ab" would cover the five blocks A,B,E,G,I. However, from the Inspector API, I only get the information that the entire function is covered and that certain bytes from this function are not covered. From this I can (as far as I can tell) neither derive the number of basic blocks in the function nor the blocks executed.

Flow diagram of the source code


Solution

  • I found the function in the v8 source code responsible for merging basic blocks. As a quick fix, I removed the calls after RewritePositionSingletonsToRanges and it seems to work:

    input:  ab
    function: target
    
    Start   | End   | Count
    ================================================
    49      | 324   | 1     |
    100     | 125   | 0     |
    125     | 154   | 1     |
    154     | 307   | 1     |
    184     | 202   | 0     |
    204     | 237   | 0     |
    237     | 268   | 1     |
    268     | 301   | 0     |
    307     | 323   | 1     |
    

    Note, that a function has to be called at least once since profiling started to appear in the list. For example, if we have the calls:

    startCoverage();
    foo()
    takeCoverage();
    rareFoo();
    takeCoverage();
    foo();
    takeCoverage();
    

    the function rareFoo (and its basic blocks) will only appear in the second and third coverage information.