Saturday, April 5, 2025

Inspecting the DOM

The final thing I want to do in this extension is to check that I fully understand how to inspect the DOM.

TILL programs have a grid of squares. It would be good if, for each member of that grid, we could list out the label and all the styles that have been applied.

So, again, for cheapness' sake, I'm going to use a table. Column 1 is the row number; Column 2 shows the column number; Column 3 has the label and Column 4 has the list of styles.

It's easy enough to put that table in place in the HTML, so I've gone ahead and done that. Now, when we hit a breakpoint, we want to scrape the HTML and render that in the table in the side panel.

I don't think any of this is that complicated, but there are a couple of things I haven't done before.

Gathering the Styles and Text on Break

The biggest issue as far as I can see is deciding on a strategy. My immediate thought was to use a content script to scrape the HTML and use message passing from the debugger to the content script to trigger the action, then another message from the content script to the sidepanel in order to display it.

But then it occurred to me that we could also use "the DOM domain" from the service worker thread and then send the results directly to the side panel.

I considered this from different angles and I think I like the content script angle better, but let's give the DOM domain approach a go anyway.

Time passes ...

If you want to look at where I got to, you can check out the tag CDP_BROKEN_FIND_DOM. I made quite a bit of progress, but I didn't like the way the code was shaping up with lots of asynchronicity, but the killer problem would seem to be that the "DOM domain" does not have a function to recover the text of the node. The method requestChildNodes does not seem to return text nodes, or maybe I just wasn't using it correctly. Other than that, I could have made it work, just stopped.

In particular, I found myself in one of those "variable capture" scenarios that always make you break your code up more than you really want to, and I didn't want to either fix it or present "broken" and "hard to understand" code here, so I just gave up.

I turned to the content script approach and managed to get something working quite quickly that I actually liked. I'm not going to present that either, because when I say I manged to get something working quite quickly, what I didn't appreciate for a long while was that the content script will only look at the incoming message when the code continues from the breakpoint. Which, for me, is completely useless.

I tried to re-orient this using a function and then call that using the debugger domain's evaluateAtCallFrame method, but that claimed it could not see the function I had defined in my content script, which seemed plausible, but it seemed unfair that it treated the content script as "part of" the main window when that was unhelpful to me, but then didn't when that was unhelpful to me.

Again, I feel there is something I should be able to do, but could not figure it out.

What to do in this situation? Walk away and Google things.

I had probably seen the getOuterHTML method in the DOM domain before, but I don't want the outer html, with all its markup, but the inner text, with no markup.

But beggars can't be choosers.

So, I pulled my dead first attempt back from the brink, added code to get the outer html, fixed all the asynchronicity issues using a Tracker object like I used when recovering the state, and then dragged in the display code I had added for my content script approach when I thought it was working and ... more or less, everything worked.

What I Finally Did

So I'm now going to present the code that worked as if it was what I originally thought of, which it absolutely was not. As I say, if you want to look at the false trails, they are there, but I don't want you (or AI) blindly copying those as if they work.

So one of the bigger problems in using asynchronous APIs like the debugger and DOM domains is that you can't be quite sure how things come back. You can, of course, use async and await to linearize things, but you need to understand that, indeed, that will linearize things and you (may) pay a significant performance hit.

So, I built a Collector object like the Tracker object I built last time to collect all the DOM entries. It works in the same way: before you make a request, tell me what you are expecting back, and I will prepare the ground for it. When it all comes back, we can send the final response.
class RowCollector {
    constructor() {
        this.rows = [];
        this.need = 1;
        this.have = 0;
    }

    ready(nrows) {
        this.need += nrows;
        for (var i=0;i<nrows;i++) {
            this.rows[i] = { rowNum: i, rowInfo: [] };
        }
        this.sendWhenComplete();
    }

    rowHas(row, ncols) {
        this.need += ncols * 2;
        var cs = this.rows[row].rowInfo;
        for (var i=0;i<ncols;i++) {
            cs[i] = { colNum: i };
        }
        this.sendWhenComplete();
    }

    attrs(row, col, attrs) {
        this.rows[row].rowInfo[col].styles = attrs;
        this.sendWhenComplete();
    }

    html(row, col, html) {
        this.rows[row].rowInfo[col].outer = html;
        this.sendWhenComplete();
    }

    sendWhenComplete() {
        this.have++;
        console.log("need", this.need, "have", this.have);
        if (this.need == this.have) {
            console.log("sending collected dom", this.rows);
            chrome.runtime.sendMessage({ action: "present-dom", info: this.rows });
        }
    }
}

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/js/service-worker.js

Hopefully what this does will become clear as I explain each of the methods and how it calls this. As before, there is an intentional "asymmetry" (or symmetry, perhaps) between initializing need as 1 and then calling sendWhenComplete at the end of ready(), which increments have before testing if we have all the entries we need. As before, it's important to make sure that all the functions here increment need before calling sendWhenComplete so that it can never fire too soon. sendWhenComplete is called at the end of each of the setup methods both to increment have and to cover the case that a given array is empty: in that case its "inner" methods will never be called and it is possible that we are complete already.
            if (stepMode || breakpointLines[lineNo]) {
                breakpointSource = source;
                chrome.runtime.sendMessage({ action: "hitBreakpoint", line: lineNo });
                chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "state" }).then(state => {
                    copyObject(source, {}, state.result, copy => {
                        chrome.runtime.sendMessage({ action: "showState", state: copy });
                    })
                });
                var ret = new RowCollector();
                chrome.debugger.sendCommand(source, "DOM.getDocument", {}).then(doc => {
                    findRows(ret, doc.root.nodeId)
                });
            } else {

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/js/service-worker.js

When we hit a breakpoint, in addition to notifying the side panel and collecting the state, we now want to get the document using the DOM.getDocument method in the DOM domain.

This doesn't actually obtain the whole document of course; it just returns an opaque handle to a document object. Within this is root which is the "document element". The main feature of each of the Node objects returned by this API is the nodeId which is an integer identifying each of the nodes on the far side. We can then interact with those using other methods in the DOM domain. To avoid issues with variable capture, all of these interactions are in a pair of very small methods.

findRows scans the document (using querySelectorAll), looking for the .row elements, and calls the collector with the number of rows returned. As you can see above, that then initializes the return array with the relevant number of rows, assigning each one its number.
function findRows(collector, nodeId) {
    chrome.debugger.sendCommand(breakpointSource, "DOM.querySelectorAll", { nodeId: nodeId, selector: ".row" }).then(rows => {
        collector.ready(rows.nodeIds.length);
        findColumns(collector, rows.nodeIds);
    });
}

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/js/service-worker.js

Here, we delegate finding the columns to a nested method:
function findColumns(collector, rows) {
    var rowNum = 0;
    for (var rowNum=0;rowNum<rows.length;rowNum++) {
        collectRow(collector, rowNum, rows[rowNum]);
    }
}

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/js/service-worker.js

Which then uses collectRow to capture the rowNum correctly and dispatch the async method:
function collectRow(collector, rowNum, row) {
    chrome.debugger.sendCommand(breakpointSource, "DOM.querySelectorAll", { nodeId: row, selector: ".cell" }).then(cols => {
        collector.rowHas(rowNum, cols.nodeIds.length);
        findCells(collector, rowNum, cols.nodeIds);
    });
}

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/js/service-worker.js

Here the collector is told about the contents of the row, and it initializes the rowInfo structure while adding two to need for each cell, since we will be obtaining both styles and test asynchronously.

And then the pattern repeats trying to collect the cell within each column:
function findCells(collector, rowNum, cols) {
    var colNum = 0;
    for (var colNum=0;colNum<cols.length;colNum++) {
        collectCell(collector, rowNum, colNum, cols[colNum]);
    }
}

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/js/service-worker.js

Which then uses collectCell to capture the colNum correctly and dispatch the async methods to obtain the styles and the outer html:
function collectCell(collector, rowNum, colNum, c) {
    chrome.debugger.sendCommand(breakpointSource, "DOM.getAttributes", { nodeId: c }).then(
        attrs => collector.attrs(rowNum, colNum, attrs.attributes[1])
    );
    chrome.debugger.sendCommand(breakpointSource, "DOM.querySelector", { nodeId: c, selector: ".cell-text" }).then(
        res => {
            chrome.debugger.sendCommand(breakpointSource, "DOM.getOuterHTML", { nodeId: res.nodeId }).then(
                html => collector.html(rowNum, colNum, html.outerHTML)
            );
        }
    );
}

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/js/service-worker.js

Note that collecting the cell text involves two steps: one is finding the div with class cell-text, and the other is finding the "outer html" for that cell. Even so, no additional counting is required in the collector, because these two balance out and the collector is not involved.

Note that both these methods tell the collector the explicit row and column numbers so that it can put the entry in the right place; the earlier methods made sure that all the arrays were already in place and populated.

Finally, when we have gathered together all of the DOM information, sendWhenComplete is complete, and we send the finished array to the side panel.

Displaying the Results

Displaying the results is really nothing different to what we've done twice before, which is to populate a table. And I've tried to make it easy by preparing the data to be in the most amenable form.

What I had tried to do on my dead branch was to present the text and styles "cleanly", but given the fact that I can't recover the simple text now, that isn't possible anymore, so I'm just going to show the outer html and all of the styles (by definition, they all have cell, which isn't really a style so much as a marker).

We need to get the table body:
var dombody = document.getElementById("display-dom");

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/html/js/sidepanel.js

In the onMessage handler, we need to handle the present-dom method:
chrome.runtime.onMessage.addListener(function(request, sender, respondTo) {
    switch (request.action) {
    case "hitBreakpoint": {
        var l = request.line;
        breakAt = sourceLines[l].children[1];
        debuggerActive();
        break;
    }
    case "showState": {
        if (debugMode) {
            showState(request.state);
        }
        break;
    }
    case "present-dom": {
        if (debugMode) {
            presentDom(request.info);
        }
        break;
    }
    default: {
        console.log("message:", request);
        break;
    }
    }
});

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/html/js/sidepanel.js

Which calls presentDom, which is just basic table construction much like when we did the state table:
function presentDom(dom) {
    dombody.innerHTML = '';
    for (var r of dom) {
        for (var c of r.rowInfo) {
            var text = c.outer;
            presentDomRow(r.rowNum, c.colNum, text, c.styles);
        }
    }
}

function presentDomRow(row, col, label, styles) {
    var tr = document.createElement("tr");
    dombody.appendChild(tr);

    presentCell(tr, row);
    presentCell(tr, col);
    presentCell(tr, label);
    presentCell(tr, styles);
}

function presentCell(tr, str) {
    var td = document.createElement("td");
    td.appendChild(document.createTextNode(str));
    tr.appendChild(td);
}
    

CDP_DOM_DOMAIN_RECOVERY:cdp-till/plugin/html/js/sidepanel.js

And there we have it (at long last)

Conclusions

I can't believe that this was as complicated as it turned out to be. I'm also very confused whether it is my incompetence, lack of clarity in the documentation, or lack of consideration of this case which caused me to have so many problems. In particular, the fact that there doesn't seem to be a way of obtaining text nodes in the DOM domain seems implausible to me. Likewise the fact that content scripts are "isolated" from the main document JS environment, but nevertheless won't run when it has hit a breakpoint.

Am I missing something? If so, please let me know.

That notwithstanding, we did manage to scrape the DOM for styles and HTML, and we could have done anything we wanted if I had been prepared to put in the effort (and hack stuff), but it really wasn't that important to me and I'd used up my "discretionary" time bucket going around in circles. But maybe, at the end of the day, it was more instructive than just being successful first time.

Thursday, April 3, 2025

Restrict to TILL Files


We are currently popping up our sidepanel everywhere in Chrome. It seems that it should only be an option when there is a TILL file loaded into the current tab. How can we control that?

In the documentation, Google offers an example of how to control the sidepanel based on the URL. While this is not exactly what we want to do, the example makes it clear that you can control whether or not the sidepanel shows based on "the current tab". Let's see what we can do with that.

It says to add a listener to the tabs.onUpdated event source, like so:
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
    if (tab.url == "http://localhost:1399/") {
      await chrome.sidePanel.setOptions({
        tabId,
        path: 'html/sidepanel.html',
        enabled: true
      });
    } else {
      await chrome.sidePanel.setOptions({
        tabId,
        enabled: false
      });
    }
});

CDP_SIDEPANEL_URL:cdp-till/plugin/js/service-worker.js

But this appears to make no difference at all.

It turns out, upon reading the Mozilla documentation that this is concerned with any updates within a tab. So this will cover us when we use navigation within a tab, but not when we switch to different tabs.

It would seem for that, we need to call the sidePanel setOptions method directly. By default, we want to say that the sidepanel is "off" and then just turn it on for qualifying tabs.

So, at the top:
chrome.sidePanel
    .setPanelBehavior({ openPanelOnActionClick: true })
    .catch((error) => console.error(error));

chrome.sidePanel
    .setOptions({ enabled: false, path: "html/sidepanel.html" })
    .catch((error) => console.error(error));

CDP_SIDEPANEL_URL:cdp-till/plugin/js/service-worker.js

And then when we query the tabs and find a matching tab, turn it on:
chrome.tabs.query({ url: "http://localhost/*" }).then(tabs => {
    for (var tab of tabs) {
        if (tab.url == "http://localhost:1399/") {
            chrome.sidePanel
                .setOptions({ enabled: true, path: "html/sidepanel.html", tabId: tab.id })
                .catch((error) => console.error(error));
            chrome.debugger.attach({ tabId: tab.id }, "1.3");
            chrome.debugger.sendCommand({ tabId: tab.id }, "Debugger.enable");
        }
    }
});

CDP_SIDEPANEL_URL:cdp-till/plugin/js/service-worker.js

And we still have the listener if the user navigates to a TILL application within a tab.

What is a TILL application?

However, I'm not really happy with this solution. I can see how using a URL might be a good solution if you are deploying a specific website; but for a technology like this, I want to say "if the features I want are there". I don't want a long list of all possible websites.

So I want to look for something "on the page" that will tell me I'm dealing with a TILL app. The obvious thing, of course, is the till-runtime.js script. So I want to enable the extension if and only if the selected tab has a <script> tag whose src attribute ends with /till-runtime.js. It's not perfect, since a page may have a file with that name which is nothing to do with us, but choosing this approach does two didactic things for me:
  • It forces me to interrogate the actual page content without using the debugger;
  • It forces me to look at the DOM (before we come back and look at that in more detail next time).
As I understand it, in order to do this, we need to communicate with the page. Now, since we are actually writing the code on the page, we could add a message handler there, and, indeed, that might be the more reliable way to proceed. But from the point of view of trying something new, we are going to add a content script.

Content Scripts are pieces of JavaScript that, rather than running in the background or in the sidepanel are "attached to" the actual page. Imagine, if you will, that your extension has been able to add an extra <script> tag into the <head> portion of the HTML on the page. They run in the context of the page and have the ability to see what is going on there (with the usual caveat that these days almost everything is in a module so you have to be able to import anything you want to see, and even then you might see a duplicate, not the original).

But this is good enough for us to see if the runtime is embedded in the <head>.

So, let us write a content script that sends a message to the service worker when it loads, if the <head> has a script ending in /till-runtime.js:
var scripts = document.head.querySelectorAll("script");
for (var s of scripts) {
    if (s.src.endsWith("/till-runtime.js")) {
        chrome.runtime.sendMessage({ action: "haveTill" });
    }
}

CDP_SIDEPANEL_TILL:cdp-till/plugin/content/find-script.js

Allegedly, we can get this to load by adding it to the manifest:
{
    "manifest_version": 3,
    "name": "Till Debugger",
    "version": "2025.04.02",
    "permissions": ["debugger", "sidePanel", "webRequest"],
    "side_panel": {
        "default_path": "html/sidepanel.html"
    },
    "action": {
        "default_title": "Click to open debugger"
    },
    "background": {
        "service_worker": "js/service-worker.js"
    },
    "content_scripts": [
        {
            "matches": ["<all_urls>"],
            "js": ["content/find-script.js"]
        }
    ]
}

CDP_SIDEPANEL_TILL:cdp-till/plugin/manifest.json

But when I update the plugin, nothing happens. If I try adding tracing, it doesn't come out. The content script seems to be being ignored. It doesn't seem I need any permissions, and I don't seem to be obviously doing anything wrong.

Fortunately, I came across this comment on StackOverflow, which made it clear what the problem was, although it probably should have been obvious to me: I need to reload the page as well as updating the plugin. Presumably content scripts are only pulled into the webpage when the page is loaded. Now, everything works and it sends its message.

OK, so it would be good if someone were listening on the other end. Of course, someone is listening already, they are just ignoring the message. So let's add another case to onMessage in the service worker:
    case "haveTill": {
        if (!tabsWithTill.includes(sender.tab.id)) {
            tabsWithTill.push(sender.tab.id);
            chrome.debugger.attach({ tabId: sender.tab.id }, "1.3");
            chrome.debugger.sendCommand({ tabId: sender.tab.id }, "Debugger.enable");
            chrome.sidePanel.setOptions({
                tabId: sender.tab.id,
                path: 'html/sidepanel.html',
                enabled: true
            });
        }
        break;
    }

CDP_SIDEPANEL_TILL:cdp-till/plugin/js/service-worker.js

The main thing this does is to update a list tabsWithTill (I added this as an empty array at the top of the script). This is the list we will check before allowing the sidepanel to open. Meanwhile, I have also (temporarily) moved the code to enable the debugger here and, of course, enabled the sidepanel.

All of the code that queried the tabs and enabled the debugger has now just been deleted - that depended on the URLs in its entirety. Meanwhile, I have updated the code we just added to check on tabs being updated to say that when a new URL is loaded, we want to disable the TILL sidepanel until such time as the newly loaded page asks us to enable it. I think there is probably a race condition here, but I have no idea how to handle it. What I did notice was that you receive multiple events, one of which is loading and one of which is complete and in my loose testing, the message came through between these two. Thus, I only removed the tab id when it was a loading event with a valid url.
chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
    if (info.status != 'loading') {
        return;
    }
    if (!info.url) {
        return;
    }
    for (var i=0;i<tabsWithTill.length;i++) {
        if (tabsWithTill[i] == tabId) {
            tabsWithTill.splice(i, 1);
        }
    }
    await chrome.sidePanel.setOptions({
        tabId,
        enabled: false
    });
});

CDP_SIDEPANEL_TILL:cdp-till/plugin/js/service-worker.js

Waiting for the SidePanel

I feel that still isn't good enough for what I want.

I had to move the code to enable the debugger, but I wasn't happy with where I'd moved it. That code was intended to say that the sidepanel could open; I only want the debugger to function when the sidepanel is open. It seems to me that someone should receive an event when the sidepanel opens and then I can hook onto that to enable the debugger (and presumably disable it when the sidepanel closes).

Amazingly, it seems that there is not. It would seem it may be part of future work, but in the meantime this appears to be the best answer out there.

Experimenting with the plugin suggests that it is possible to see when the sidepanel opens simply by adding code to sidepanel.js: when the sidepanel opens, the HTML page loads and this loads and executes sidepanel.js. But there doesn't seem to be a corresponding close notification, so I'm not sure how much benefit this offers.

I can think of a number of hacks, but I'm not really that interested in hacking things here, so I'm just going to chalk this one up as "can't be done" and move on.

Conclusions

We managed to make it so that the sidepanel (and the debugger) are only operative on web pages that link in the till-runtime.js script.

To my surprise, we were not able to restrict the debugger to only run when the sidepanel was open, because we could not figure out when the sidepanel closed.

Wednesday, April 2, 2025

What is the Current State?


Now that we're at a breakpoint, we want to be able to see what the current state of the machine is.

I've already added a "tab" for that (State), but I haven't populated it yet.

So we now have two remaining tasks: collect the state from the running "process" and display the state in the State tab of the sidepanel.

Collecting the State

In theory, collecting the state is very easy; in practice it is an exercise in jumping through hoops dealing with remote objects.
            if (stepMode || breakpointLines[lineNo]) {
                breakpointSource = source;
                chrome.runtime.sendMessage({ action: "hitBreakpoint", line: lineNo });
                chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "state" }).then(state => {
                    copyObject(source, {}, state.result, copy => {
                        console.log("state =", copy);
                    })
                });
            } else {

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

This asks Chrome's "Debugger Domain" to provide us with the value of the variable state in the tab running the TILL program. It does, but what comes back is a Runtime.RemoteObject. This isn't very much use. We need to "pull" this object over here, piece by piece. The first thing is to "copy" an object or array, in copyObject:
function copyObject(source, objsSeen, remoteObj, done) {
    // there is always the possibility of cycles in data structures
    // if we have seen this object before, just say "it's that object"
    // it may or may not have been filled out yet, but for our purposes we are "done"
    if (objsSeen[remoteObj.objectId]) {
        done(objsSeen[remoteObj.objectId]);
        return;
    }

    // Otherwise, create the object now and store it in the map
    // We will be the only person filling it out
    var ret;
    if (remoteObj.subtype == 'array') {
        ret = [];
    } else {
        ret = {};
    }
    objsSeen[remoteObj.objectId] = ret;

    var tracker = new Tracker(done, ret);
    chrome.debugger.sendCommand(source, "Runtime.getProperties", { objectId: remoteObj.objectId }).then(
        props => copyProperties(source, objsSeen, ret, props, tracker)
    );
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

The first step is to detect and eliminate cycles by keeping track of which objects we have already recovered. In testing, this did not get exercised, and I'm not sure it does in simple TILL programs, but it is always a good idea to protect yourself against this kind of infinite loop. objsSeen is initialized in the top-level call above where an empty object ({}) is passed in.

We then create an appropriate "destination" object - either an array if so specified, or else just a vanilla object, recording it immediately in objsSeen, guaranteeing that this is the only time it will be recovered. Everything else is asynchronous, so we create a Tracker object which keeps track of how many properties have been requested and how many have been returned, and then request the object's properties from the API, passing the result to the copyProperties function.
function copyProperties(source, objsSeen, ret, props, tracker) {
    for (var p of props.result) {
        if (p.isOwn) {
            tracker.needOne();
            copyProperty(source, objsSeen, ret, p.name, p.value, tracker);
        }
    }
    tracker.haveOne();
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

The properties returned are the correct ones, but the values may be remote objects or arrays. So we need to go through all of the properties that come back, checking each one individually and requesting to copy any remote objects or arrays that we find. As we request each one, we bump the needOne value on the tracker; when everything is done, we state that we have asked for everything we need, and any time they are all there, the tracker can declare its work done.

The final piece of this jigsaw is the copyProperty method which handles each individual property:
function copyProperty(source, objsSeen, building, prop, remote, tracker) {
    if (remote.type == 'string') {
        building[prop] = remote.value;
        tracker.haveOne();
    } else if (remote.type == 'boolean') {
        building[prop] = remote.value;
        tracker.haveOne();
    } else if (remote.type == 'number') {
        if (prop === 'length' && Array.isArray(building)) {
            // nothing to see here ...
        } else {
            building[prop] = remote.value;
        }
        tracker.haveOne();
    } else if (remote.objectId) {
        copyObject(source, objsSeen, remote, have => {
            building[prop] = have;
            tracker.haveOne();
        });
    } else {
        console.log("how do I copy this?", prop, tracker.tracker, remote);
        tracker.haveOne();
    }
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

This just handles each of the cases, distinguishing between them as best it can. In each case, once the assignment is done, the tracker is notified. Note that in the case of the RemoteObject, the tracker is not notified until the end of the nested asynchronous call. If you put the notification earlier than this, it is possible that the tracker could complete while still waiting for a nested property to be evaluated.

Here is the code for the Tracker:
class Tracker {
    constructor(done, result) {
        this.done = done;
        this.result = result;
        this.need = 1;
        this.have = 0;
    }

    needOne() {
        this.need++;
    }

    haveOne() {
        this.have++;
        if (this.have == this.need) {
            this.done(this.result);
        }
    }
}

CDP_COLLECT_STATE:cdp-till/plugin/js/service-worker.js

Note that it starts by setting need to 1 but have to 0. This corresponds to the extra call of haveOne that happens at the end of copyProperties. Depending on the implementation of the remote calls, it may or may not be the case that we have called have as many times as we have called need before we have finished making all the calls: if that were the case, we would fire the callback prematurely. By setting the initial value of need to 1, we can ensure that we do not fire the callback until we have made that "final" call to haveOne.

You may well ask if we could have done this same thing with promises. I believe we could; the API appears to return a promise if you don't provide a callback. Contrary to the views of most of the community, however, I do not find code with promises in it clear or helpful. It seems to be a way for people to write code in a manner that is familiar to them, rather than one that says explicitly what it means. The same could be said for the class syntactic sugar as well as the "lambda" style of callbacks, and yet I have used those. True, but only sometimes, and much more in the code you see here - where my goal is to make the intent of my code simple, rather than precise. The only modern JavaScript feature I consider an unquestioned good is import/export.

Displaying this

Doing a good job of displaying this nested structure is more that I want to deal with. Let's face it, we're talking about a complex, nested tree structure. So I'm not going to. Instead, I'm going to use another table and unfold the first level of complexity into that, whether that is an object or an array. Of course, if there isn't a "first level of complexity", then I will just show the object. Below that, I'll pack things up in JSON or some such so that everything is there, albeit ugly. I will also throw away fields I don't like.

I'm not going to show it, but I have added a simple three-column table to the state table in sidepanel.html.

Let's kick everything off by finishing off where we left off above, and pass the state that we had so assiduously assembled over to the sidepanel.
                chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "state" }).then(state => {
                    copyObject(source, {}, state.result, copy => {
                        chrome.runtime.sendMessage({ action: "showState", state: copy });
                    })
                });

CDP_DISPLAY_STATE:cdp-till/plugin/js/service-worker.js

This will, of course, invoke our existing onMessage handler in sidepanel.js, so we need to add another case to that:
    case "showState": {
        if (debugMode) {
            showState(request.state);
        }
        break;
    }

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

This checks that we are currently in "debug mode" and then calls the code to show the current state in the state table.

This, of course, requires us to obtain the DOM entry for the table:
var statebody = document.getElementById("display-state");

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

And then it's just a question of doing all of the mindless DOM operations. The state is an object, i.e. a map, and we want to put the key in the first column of the table and then delegate the other two columns to an appropriate function - showing as an array, an object or JSON:
function showState(state) {
    statebody.innerHTML = '';
    var keys = Object.keys(state);
    keys.sort();
    for (var k of keys) {
        var tr = document.createElement("tr");
        statebody.appendChild(tr);

        var tkey = document.createElement("td");
        tkey.appendChild(document.createTextNode(k));
        tr.appendChild(tkey);

        var v = state[k];
        if (Array.isArray(v)) {
            showArray(v);
        } else if (typeof(v) == 'object') {
            showObject(v);
        } else {
            var ign = document.createElement("td");
            tr.appendChild(ign);
            showJSON(tr, v);
        }
    }
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

For arrays, we want to show all the members on separate rows below the key, with the var field empty, the index number in the middle column, and the value as JSON:
function showArray(arr) {
    for (var i=0;i<arr.length;i++) {
        var tr = document.createElement("tr");
        statebody.appendChild(tr);

        // leave the "var" column blank
        var ign = document.createElement("td");
        tr.appendChild(ign);

        var idx = document.createElement("td");
        idx.appendChild(document.createTextNode(i));
        tr.appendChild(idx);

        showJSON(tr, arr[i]);
    }
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

For objects, we want code suspiciously similar to showState but indented one column. If you try and refactor showState and showObject into one piece of code, you will end up with something that basically requires the tree structure that this should really have. It's too much work for me, but if you want to put the effort in and send me a pull request, I'll accept it with full credit.
function showObject(obj) {
    var keys = Object.keys(obj);
    keys.sort();
    for (var k of keys) {
        var tr = document.createElement("tr");
        statebody.appendChild(tr);

        // leave the "var" column blank
        var ign = document.createElement("td");
        tr.appendChild(ign);
        
        var tidx = document.createElement("td");
        tidx.appendChild(document.createTextNode(k));
        tr.appendChild(tidx);

        showJSON(tr, obj[k]);
    }
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

Finally, showJson shows the JSON representation of a value in the final column. Note that for simple types like strings and numbers, there is no additional ceremony to JSON, so it all just comes out as you would expect. I also take this opportunity to delete the methodCode field, since it doesn't add anything very much. I didn't bother deleting styles, although I'm dubious about its contribution as well.
function showJSON(tr, v) {
    // I don't want to show "methodCode"
    delete v["methodCode"];

    var json = JSON.stringify(v);
    var val = document.createElement("td");
    val.appendChild(document.createTextNode(json));
    tr.appendChild(val);
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

As one last wheeze, we want to clear all this out as soon as we press "continue", because it will be out of date almost immediately:
function debuggerInactive() {
    debugMode = false;
    breakAt.classList.remove("current-break");
    continueButton.classList.remove("available");
    stepButton.classList.remove("available");
    statebody.innerHTML = '';
}

CDP_DISPLAY_STATE:cdp-till/plugin/html/js/sidepanel.js

Conclusion

Yes, we can retrieve and show the machine state when we hit a breakpoint.

The only thing which is truly interesting here is the call to evaluateOnCallFrame, which was the same method we used before to determine the current line number. It was also good to see how you can copy objects (and arrays) recursively from the application tab to the service worker.

Sending the state to the sidepanel and displaying it was just an exercise in DOM manipulation.

Tuesday, April 1, 2025

Toolbar

While you were gone, I updated the sidepanel to have a toolbar and some tabs.

This was all strictly visual. The only controls I've added for now are continue and step. Let's implement those.

The idea is that once we've hit a breakpoint and are in "debug mode", we can press continue and it will go again until it hits the next user breakpoint. Alternatively, we can press "step" and it will only continue until it attempts to execute the next method or action (at the moment, we don't have action breakpoints set, but I'm going to add them after this episode).

Most debuggers have a "pause" or "interrupt" button, but given that our program is idle most of the time, that doesn't seem worth it.

Most of the code we are going to write is boring, vanilla JavaScript with just a couple of lines of interaction with the debugger that are little different to those we've already seen.

SidePanel Code

Starting with some true boilerplate, we now need to track whether or not we are in "debug" mode so that the buttons are only effective when they should be. We also need to find the buttons in the sidepanel DOM.
var debugMode = false;

var continueButton = document.querySelector(".tool-continue");
var stepButton = document.querySelector(".tool-step");

CDP_CONTINUE_STEP:cdp-till/plugin/html/js/sidepanel.js

We need to update the UI every time the debugger becomes active or inactive:
function debuggerActive() {
    debugMode = true;
    breakAt.classList.add("current-break");
    continueButton.classList.add("available");
    stepButton.classList.add("available");
}

function debuggerInactive() {
    debugMode = false;
    breakAt.classList.remove("current-break");
    continueButton.classList.remove("available");
    stepButton.classList.remove("available");
}

CDP_CONTINUE_STEP:cdp-till/plugin/html/js/sidepanel.js

We need handlers for the continue and step buttons:
function continueExecution(ev) {
    if (!debugMode) {
        return;
    }
    debuggerInactive();
    askContinue(false);
}

function stepExecution(ev) {
    if (!debugMode) {
        return;
    }
    debuggerInactive();
    askContinue(true);
}

CDP_CONTINUE_STEP:cdp-till/plugin/html/js/sidepanel.js

These basically do the same thing, which is to call the askContinue method with a flag indicating whether or not the continuation should be in "step" mode. askContinue turns around and passes the request on to the service worker thread, because that is the context in which we communicate with the debugger.
function askContinue(stepMode) {
    chrome.runtime.sendMessage({ action: "continue", stepMode: stepMode }).then(resp => {
        console.log("response", resp);
    });
}

CDP_CONTINUE_STEP:cdp-till/plugin/html/js/sidepanel.js

Having written all the code, we need to wire it up. At the end of the script, we bind the handlers to the buttons' click events:
continueButton.addEventListener("click", continueExecution);
stepButton.addEventListener("click", stepExecution);

CDP_CONTINUE_STEP:cdp-till/plugin/html/js/sidepanel.js

And we need to make sure that when we are notified that we have hit a breakpoint, we call the debuggerActive method:
    case "hitBreakpoint": {
        var l = request.line;
        breakAt = sourceLines[l].children[1];
        debuggerActive();
        break;

CDP_CONTINUE_STEP:cdp-till/plugin/html/js/sidepanel.js

(Note that a small refactoring happened here: the toggling of the style currentBreak has been moved into the active/inactive methods (above) and removed from hitBreakpoint here.)

Service Worker Code

Meanwhile, over in the service-worker.js file, we need to interact with the debugger. We need to keep track of two things. First, if we are in "step" mode or "run" mode, and then, when we are notified that we have hit a breakpoint, the "source" of that event so that we can respond later.
var stepMode = false;
var breakpointSource;

CDP_CONTINUE_STEP:cdp-till/plugin/js/service-worker.js

When the user presses one of the buttons, the notification comes in through the onMessage listener. We already have a case to deal with setting breakpoints, so we just need to add another case for continue, setting stepMode appropriately:
    case "continue": {
        stepMode = request.stepMode;
        chrome.debugger.sendCommand(breakpointSource, "Debugger.resume").then(resp => {
            console.log("resume response", resp);
        });
        break;

CDP_CONTINUE_STEP:cdp-till/plugin/js/service-worker.js

And then the final change is to say that we enter debug mode when we hit a classified breakpoint OR when we are in step mode.
        chrome.debugger.sendCommand(source, "Debugger.evaluateOnCallFrame", { callFrameId: params.callFrames[0].callFrameId, expression: "this.lineNo" }).then(resp => {
            var lineNo = resp.result.value;
            console.log("line #:", lineNo);
            if (stepMode || breakpointLines[lineNo]) {
                breakpointSource = source;
                chrome.runtime.sendMessage({ action: "hitBreakpoint", line: lineNo });
            } else {

CDP_CONTINUE_STEP:cdp-till/plugin/js/service-worker.js

And that's it. We can press the continue and step buttons to see what happens.

Conclusion

Continuing was never going to be difficult because we had all the pieces: it was just a question of wiring them up. Adding more runtime breakpoints, so that we can break at each of the actions, is even more repetitious, so I'm not even going to bother writing about it, just look at CDP_ACTION_BREAKPOINTS if you're interested.