Conversation
JETPACK-2685 splits the scriptable half of the port verification rule (JETPACK-2573) out of its manual checklist: step 2 (computed styles and geometry) and step 3 (the network panel), compared with a port's feature flag off versus on. Adds tools/port-verification, a standalone CLI (`verify-port run|capture| diff`) built on Playwright. `run` captures a page with the flag off, pauses for the reviewer to flip the flag on the site, captures again, and prints a Markdown report to paste into the PR. The diff and report logic (src/diff.js, src/report.js) is pure and unit-tested against fixtures here; the Playwright capture half (src/capture.js) needs a live site and is documented as unverified in this sandbox (no GPU). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! |
PR review on #52579 found three issues in the port verification script: - Blocker: boot hides #wpfooter by design (see projects/js-packages/base-styles/admin-page-layout.scss, so every live run reported it CHANGED with bogus zero-rect deltas -- getBoundingClientRect() on a display:none element is all zeros. capture.js now records a per-target hidden flag; diff.js reports a visibility change as its own hidden-changed status instead of diffing a meaningless rect; report.js renders it as OK (hidden by design) only for targets whose selectors.js entry sets allowHidden (just the footer). An unexpected visibility change on any other target still surfaces as a CHANGED finding. - report.js printed the raw request URL in the network section, so a nonce value survived into Markdown meant to be pasted into a public PR. Added diff.js redactUrl(), which strips the same ignored query params used for matching, and used it for display too. - Dropped v and t from DEFAULT_IGNORED_QUERY_PARAMS -- generic enough to carry real state (an API version, a tab filter) that stripping them could hide a genuine step-3 difference. Added --ignore-query-param for a site that needs them ignored anyway. Also hardened capture.js login(): it now waits for #dashboard-widgets/#wpbody after submit and fails with a clear message instead of silently proceeding to capture wp-login.php own failure page on a wrong password (mirrors tools/performance/scripts/measure-lcp.js). 12 new tests (25 to 37), covering the hidden/missing distinction, the nonce redaction, and the v/t opt-in. node --test and eslint --max-warnings=0 both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> EOF )
Code Coverage SummaryThis PR did not change code coverage! That could be good or bad, depending on the situation. Everything covered before, and still is? Great! Nothing was covered before? Not so great. 🤷 |
Review of #52579 found three ways the tool reports a clean pass on a page it should flag: * The response listener started before `login()`, so step 3 diffed wp-login.php and the Dashboard alongside the target page. Clear the buffer after login. * `diffNetwork` keyed a Map by method + path, so a repeated request kept only its last occurrence -- a 404 refetched into a 200 reported nothing. Group every occurrence, compare the distinct statuses, and report a changed occurrence count on its own. * A non-numeric `--tolerance` became `NaN`, and `delta > NaN` is false for every delta, so step 2 reported OK for every target. Reject it instead. Also: abort when a capture lands on wp-login.php, since a fresh browser profile has no cookies to arrive authenticated with; add `_ajax_nonce` and `_nonce` to the ignored params, so admin-ajax calls match between loads and their nonces stay out of a report pasted into a public PR; give an unmeasured target its own row, so a skipped `--control-selector` no longer reads as a passing check. Option parsing moves to `src/options.js` to be testable without running `main()`. 55 tests, up from 37. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Self-review round, pushed as Blockers
Also fixed
55 tests, up from 37. ESLint and Prettier clean. Still needs a reviewer with a site. |
dhasilva
left a comment
There was a problem hiding this comment.
Thanks for scripting this. The pure diff/report/options layer is well factored and well tested.
I also ran the Playwright half, which had never executed, against a local stub of wp-admin on 127.0.0.1 (no live site). Login, the post-login buffer clear, both wp-login.php guards and the snapshot shape all work. The default selectors are still unverified against a real wp-admin page. The same run turned up most of the findings below. The blockers are cases where the run pause or the report produces a clean result the tool didn't earn.
Verdict: Needs changes before merge.
Tests/lint (worktree at 87d3999):
pnpm install --frozen-lockfilesucceeds.pnpm testintools/port-verification: 55/55 pass.pnpm exec eslint . --max-warnings=0, rootpnpm exec eslint tools/port-verificationandprettier --checkare all clean.- Dependencies are fine:
playwrightresolves to the already-locked 1.60.0, and the lockfile only adds the importer.
See inline comments.
Generated by Claude.
| console.log( `Capturing with the flag OFF: ${ options.url }` ); | ||
| const before = await capturePage( options ); | ||
|
|
||
| const rl = readline.createInterface( { input: process.stdin, output: process.stdout } ); |
There was a problem hiding this comment.
[blocker] The prompt and the progress lines go to stdout. The documented run … > port-verification-report.md (in the README and this PR's testing instructions) therefore hides the "Flip … press Enter" prompt from the terminal and writes it into the report instead. I ran it that way against a local stub. stderr was empty, and the file started with Capturing with the flag OFF: …, the prompt and Capturing with the flag ON: …, all above the ## Port verification heading. When --out is set, Report written to … (L96) lands there too.
Fix: keep stdout for the report only. Use output: process.stderr here, and console.error for the progress lines and the Report written to line.
Generated by Claude.
| await rl.question( | ||
| `\nFlip ${ options.flag ?? 'the port flag' } ON on the site now, then press Enter to continue... ` | ||
| ); | ||
| rl.close(); |
There was a problem hiding this comment.
[blocker] The pause can finish without anyone flipping the flag, and neither case gets reported:
- stdin at EOF (
< /dev/null, a pipeline, an agent's non-TTY shell). readline closes,rl.question()never settles, the event loop drains, and the process exits 0 with no report. Verified locally. - Input that arrives before the prompt, such as a stray Enter during the first capture (verified with piped input). It answers the prompt immediately, so the second capture is flag-off again and the report says
0 geometry finding(s), 0 network finding(s).
Fix: refuse run when ! process.stdin.isTTY and point to capture + diff instead. Fail if readline closes before an answer arrives. Ask for something a stray keypress can't produce, such as typing on or the flag name. My comment on capture.js L186 suggests a way to prove the flip actually took effect.
Generated by Claude.
| beforeCapturedAt: before.meta?.capturedAt, | ||
| afterCapturedAt: after.meta?.capturedAt, | ||
| }, | ||
| undefined, |
There was a problem hiding this comment.
[blocker] Because this passes undefined, the report always judges against DEFAULT_GEOMETRY_TARGETS, where control is selector: null, required: false. That holds whatever --control-selector the captures actually used. I reproduced two results with diff:
- A
--control-selector '.typo-button'that matched nothing gives| Control | skipped (optional) | no selector given (--control-selector) |, even though a selector was given. - A control present flag-off and gone flag-on gives
| Control | ok (not present, optional) | not found in after (flag on) |and0 geometry finding(s). A renamed class is exactly what a port produces.
The second result is the "a check that did not run reads as a pass" case this PR sets out to prevent. Fix: write the resolved targets into meta in capturePage(), pass them from the snapshots to formatReport() here (the two-step diff then gets them too), and set control to required: true whenever it has a selector.
Generated by Claude.
| page.on( 'response', response => { | ||
| const request = response.request(); | ||
| network.push( { | ||
| url: response.url(), | ||
| method: request.method(), | ||
| status: response.status(), | ||
| resourceType: request.resourceType(), | ||
| } ); |
There was a problem hiding this comment.
[suggestion] Only response is recorded, so step 3 never sees a request that gets no response at all: a DNS failure, a refused connection, a mixed-content or CSP block. My flag-on stub page loaded http://nonexistent.invalid/boot.js, and it appeared nowhere in the report. A port that emits an asset URL with the wrong host fails exactly this way.
Record failures too, so they show up with their own status:
page.on( 'requestfailed', request => {
network.push( {
url: request.url(),
method: request.method(),
status: 0,
failure: request.failure()?.errorText,
resourceType: request.resourceType(),
} );
} );Generated by Claude.
| function isHidden( el ) { | ||
| const cs = window.getComputedStyle( el ); | ||
| return cs.display === 'none' || cs.visibility === 'hidden'; |
There was a problem hiding this comment.
[suggestion] There are two ways the control row can compare the wrong thing:
display: noneon an ancestor. The element's owndisplayis unchanged, soisHidden()returns false and the zero rect is reported as four px deltas. That is whathiddenexists to prevent. I reproduced it: a button inside a hidden wrapper came out asx: 178px -> 0px … height: 31px -> 0px.! el.checkVisibility( { visibilityProperty: true } )also checks ancestors.- First match wins.
document.querySelector()(L112) silently takes the first match. With the README's.components-button, that can be a different button on each side. Boot's template hides every direct child of#wpbody-contentexcept the app root, so a hidden legacy button can come before the real one. RecordquerySelectorAll( selector ).lengthand flag the control when it matches more than once. The README example could also use a selector that matches only one control.
Generated by Claude.
| try { | ||
| const parsed = stripQueryParams( new URL( request.url ), ignoreQueryParams ); | ||
| parsed.searchParams.sort(); | ||
| return `${ request.method } ${ parsed.pathname }${ parsed.search }`; |
There was a problem hiding this comment.
[suggestion] Two things about this key:
- The host is dropped (
pathnameonly). The same path on two hosts collapses into one key, andstatusChanged/countChangedrows, which print the key, don't say which host they mean. Both captures hit the same site, so${ parsed.host }${ parsed.pathname }costs nothing. - Probably noisy (not verified against a real site). Tracks pixels (
pixel.wp.com/t.gif) carry per-event values such as the_tstimestamp. Every Tracks event a Jetpack page fires would then get a new key on each load and be listed under both "only with flag off" and "only with flag on", burying real findings. A host- or path-level ignore that defaults topixel.wp.comwould stop that, and so would keyingt.gifby_enalone.
Generated by Claude.
| '### Step 3 -- network panel', | ||
| '', | ||
| `- Only with flag off (${ network.onlyBefore.length }):`, | ||
| requestList( network.onlyBefore, options ), | ||
| `- Only with flag on (${ network.onlyAfter.length }):`, | ||
| requestList( network.onlyAfter, options ), | ||
| `- Status code changed (${ network.statusChanged.length }):`, | ||
| statusChangeList( network.statusChanged ), | ||
| `- Request count changed (${ network.countChanged.length }):`, | ||
| countChangeList( network.countChanged ), |
There was a problem hiding this comment.
[suggestion] Step 2 got a NOT MEASURED row, but step 3 has no equivalent. If either capture (or both) recorded no requests, every bucket prints none and the summary says 0 network finding(s). Have diffNetwork() return per-side totals, print Compared N requests (flag off) with M (flag on), and count an empty side as a finding. It would also help to warn when before.meta.url !== after.meta.url: a two-step diff of mismatched files currently prints only the flag-on URL.
Generated by Claude.
| *.snapshot.json | ||
| *-report.md |
There was a problem hiding this comment.
[suggestion] These patterns don't match the README's own example names: off.json, on.json and report.md, plus > report.md in the PR's testing instructions (checked with git check-ignore). Snapshots store raw request URLs with live _wpnonce values, because redaction only happens in the report. Rename the examples to off.snapshot.json / on.snapshot.json / port-verification-report.md, or widen the patterns.
Generated by Claude.
| Steps 5 and 6 are the ones most worth automating next: both known post-ship regressions on | ||
| already-ported dashboards ([#51963](https://github.com/Automattic/jetpack/pull/51963), | ||
| [#52096](https://github.com/Automattic/jetpack/pull/52096)) were in RTL and colour-scheme, not | ||
| in anything steps 2 or 3 here would have caught. The geometry side extends cheaply: capture the |
There was a problem hiding this comment.
[suggestion] #52096 isn't a colour-scheme fix. It's "keep dashboard sidebars beside the content on boot 0.21", a layout change that step 2 could plausibly catch. The colour-scheme regression is #51619, which is how docs/wp-build-porting-checklist.md in #52578 cites it. Swap the link, and soften "not in anything steps 2 or 3 here would have caught".
Generated by Claude.
| "verify-port": "./bin/verify-port.js" | ||
| }, | ||
| "scripts": { | ||
| "test": "node --test src/*.test.js", |
There was a problem hiding this comment.
[suggestion] Nothing runs these 55 tests in CI. tools/performance gets its unit tests into the JS tests job through the root composer.json (scripts.test-js: cd tools/performance && pnpm test:unit). Adding cd tools/port-verification && pnpm test there would do the same for this tool.
Related: the README's reason for leaving capture.js untested (L152–153: "needs a real Chromium against a real site … no GPU") doesn't hold. Headless Chromium needs no GPU, and I ran capture against a ~100-line node:http stub of wp-admin on 127.0.0.1. A smoke test like that, skipped when no browser is installed, would cover the login, buffer-clear and hidden-detection paths, none of which have any tests today.
Generated by Claude.
… asked for Three cases where the tool produced a clean result it had not earned: * The prompt and progress lines went to stdout, so the documented `run … > report.md` wrote the "flip the flag" prompt into the report and hid it from the terminal. stdout now carries the report alone. * `run` accepted a bare Enter, so EOF (a pipeline, a non-TTY shell) exited 0 with no report, and a keypress buffered during the first capture answered the prompt early and captured flag-off twice. It now refuses a non-TTY, asks for the flag name, and fails if input closes first. * The report judged every run against the default targets, where `control` is optional and has no selector. A control that a port renamed away read as "ok (not present, optional)". Captures now record the targets they used, and a control with a selector is required. Also from that review: record `requestfailed`, so a bad host or a CSP block is not invisible; use `checkVisibility` so an ancestor's `display:none` is a visibility change rather than four px deltas; report when a control selector matches more than one element; keep rects unrounded so a sub-pixel tolerance means something; assert the landed URL still matches the requested path, with `--autologin-url` for hosts whose link logs you in; add `--load-state` for a page whose requests never settle; apply `--wait-selector` to the flag-on capture only and require its absence flag-off, which proves the flip landed; keep the host in the request key and drop per-event hosts such as `pixel.wp.com`; report per-side request totals and count an empty side; warn when the two snapshots are of different pages. Docs and wiring: `.gitignore` now matches the filenames the README uses; the colour-scheme regression is #51619, not #52096; root `composer.json` `test-js` runs these tests, the hook `tools/performance` already used. `capture.js` is no longer untested. `test/stub-wp-admin.js` is a `node:http` stub of wp-login.php, a Dashboard and one flag-dependent admin page, and `pnpm test:smoke` drives real headless Chromium against it — 11 tests covering login, the buffer clear, both redirect guards, ancestor-aware hidden detection and the failed-request path. It skips itself when no browser is installed. The README's claim that this needed a GPU was wrong. 70 unit tests, up from 55, plus the 11 smoke tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First run of capture.js against a real site, on Jetpack 16.3-a.1: * Login never worked on a site with Jetpack SSO, which is most Jetpack sites. SSO leaves the classic form in the DOM but hidden behind "Log in with username and password", so filling it timed out. Click the toggle first. Pick it by visibility, not by class: the visible one on the test site was `.jetpack-sso-toggle.wpcom`, which is not the one its name suggests. * `_cacheBuster` was not an ignored query param, so every `wp-json` call appeared under both "only with flag off" and "only with flag on". On the Search dashboard that was 4 of 24 network findings. Verified against the `search-wp-build` flag on a real Search dashboard: the four default selectors all resolve, geometry comes back clean on all four frame targets, and step 3 shows the port swapping `jp-search-dashboard.js` for boot's module graph. After the `_cacheBuster` fix, 20 findings, all real. 71 unit tests, 11 smoke tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — this caught three real holes, and the stub turned out to be the most useful part. I've committed that idea: All 13 findings taken, plus a live Jurassic Ninja run that found two more. Now at Blockers
Suggestions — all taken
Two you were right to correct me on The CI hook exists and I missed it — root The live run, since you asked the right question Ran it against a JN site on Jetpack 16.3-a.1 with the
Results after those fixes: all four default selectors resolve, geometry clean on all four frame targets, and step 3 showing the port swap cleanly — One correction to my own testing instructions, which you may want to know for other ports: — Terminator Generated by Claude. |
Re-running the live check at 86f9772 failed with "Login did not land in wp-admin", then passed twice. About one run in four. `login()` gated its navigation on the caller's --load-state, which defaults to `networkidle`. wp-admin never really goes idle -- heartbeat, JITM -- so the wait sometimes timed out, and the catch reported it as bad credentials, which is a misleading error on top of a flaky one. The login hop now uses `domcontentloaded` and leans on the selector waits, which are the real evidence of being logged in. --load-state still applies to the page capture, where settling is the point. 8 of 8 live logins clean afterwards, against roughly 1 in 4 failing before. That is evidence rather than proof at this sample size, but the mechanism matches: a page that never goes idle cannot be waited on that way. Adding a `#loginform` wait then broke 9 of the 11 smoke tests, because the stub's form had no `id`, which real wp-login.php does have. Fixed the stub rather than dropping the wait -- that gap is the same kind of divergence that hid the SSO bug until a real site ran it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up, because the live verification above was not the last word. Re-running the live check against the committed code at Your 8 of 8 live logins clean afterwards, against roughly 1 in 4 failing before. Evidence rather than proof at that sample size, but the mechanism matches: a page that never goes idle cannot be waited on that way. Adding a Now at — Terminator Generated by Claude. |
anomiex
left a comment
There was a problem hiding this comment.
Please add an entry in .github/CODEOWNERS for whoever is going to own this new thing. @Automattic/jetpack-monorepo doesn't have the bandwidth to own this special-purpose tool.
Fixes JETPACK-2685
Proposed changes
tools/port-verification/, a CLI that scripts steps 2 and 3 of the JETPACK-2573 port verification rule — the two of the seven that a machine can do better than a reviewer.#wpwrap,#wpbody-content,#wpadminbar,#wpfooterand one--control-selectorof your choosing, flag off against flag on. A 4px shift is invisible in a screenshot and obvious in a number.design-tokens.css404 and a renamed JITM message path in the My Jetpack pilot, both zero-pixel changes.runcaptures, pauses for you to flip the flag, captures again and prints a Markdown report.captureanddiffare separate subcommands for a two-terminal workflow.src/capture.js. The diff, the report and the option parsing are pure functions insrc/diff.js,src/report.jsandsrc/options.js, covered by 70 unit tests against fixtures.bin/verify-port.jslazy-loads the capture module, sodiffand--helpwork without Playwright installed.test/stub-wp-admin.jsis anode:httpstub of wp-login.php, a Dashboard and one flag-dependent admin page.pnpm test:smokedrives real headless Chromium against it, covering login, the post-login buffer clear, both redirect guards, ancestor-aware hidden detection and the failed-request path. It skips itself when no browser is installed, sopnpm teststays browser-free.> report.mdis safe.runneeds a TTY and asks you to type the flag name, because a bare Enter can be answered by a stray keypress buffered during the first capture — which would capture the same state twice and report no differences.No changelog entry:
tools/is outsideprojects/.tools/check-changelogger-use.phpagrees.The lockfile grows by six lines and nothing else.
playwright@^1.48.0already resolves to 1.60.0 fortools/performanceunder the identical specifier, so this adds an importer entry and no package.Related product discussion/links
Does this pull request change what data or activity we track or use?
No. A developer tool that reads a page you point it at. Credentials come from
WP_ADMIN_USER/WP_ADMIN_PASSor--user/--pass, are used only to fill the login form, and are not written to the snapshot files. Nonces are stripped from a request URL before the report prints it, since the report is meant to be pasted into a public PR.Testing instructions
Run here, no site needed:
cd tools/port-verification && pnpm test— 70 pass. Also runs in CI now, via the rootcomposer.jsontest-jsscript.pnpm setup:browsers && pnpm test:smoke— 11 pass against real headless Chromium and the local stub. No site, no network.node bin/verify-port.js run --url <any> < /dev/null— refuses a non-TTY instead of exiting 0 with no report.node bin/verify-port.js diff --before <a>.snapshot.json --after <b>.snapshot.json --tolerance 8px— exits 1, rather than acceptingNaNand passing every geometry check.pnpm exec eslint tools/port-verification --max-warnings=0andprettier --check— clean.Verified against a live Jurassic Ninja site (Jetpack 16.3-a.1, Jetpack connected), using the
search-wp-buildflag — a real wp-build port behind a flag the companion CLI can flip:Results: all four default selectors resolved (
#wpfootercorrectly detected as hidden rather than as a zero rect), 90 requests captured with the flag off against 98 with it on, and geometry clean on all four frame targets — the Search port does not move the frame. Step 3 showed the port swappingjp-search-dashboard.js/.cssfor boot's module graph (script-modules/boot/index.min.js,editor.min.js,routes/dashboard/content.min.js): 20 findings, all real.That run found the last two defects, both fixed here. Login had never worked on a site with Jetpack SSO — SSO leaves the classic form in the DOM but hidden behind "Log in with username and password" — and
_cacheBusterwas not an ignored query param, so everywp-jsoncall was reported on both sides at once.runis the interactive equivalent of the above, for a terminal where you flip the flag by hand. Note thatrsm_jetpack_ui_modernization_*are notFeature_Flagsflags, sowp companion feature-flagcannot flip them;search-wp-buildcan.What is still unverified: RTL and a non-default admin colour scheme (steps 5 and 6), which this tool does not cover at all — see "Not covered" in the README.
🤖 Generated with Claude Code