Skip to content

Page Health Developer Guide

Ed Mozley edited this page Oct 28, 2026 · 1 revision

Page health β€” Developer Guide

How FreeITSM checks that every page's JavaScript can be read by the browser: System β†’ Debug Tools β†’ D019 in the app, and the Page health job on GitHub. Added in 4.4.0 after #222.

Why it exists, in one paragraph. In 4.3.0 the Assets page never finished loading. One comment had swallowed the code after it, so the page's inline script could not be parsed β€” and a script that cannot be parsed runs not at all: no loader, no click handlers, nothing. php -l passed (the PHP was fine), the page returned HTTP 200, every test passed, and the reporter found it after upgrading. A syntax error in a page's script is the worst kind of front-end fault and the cheapest to catch, but only something that reads JavaScript can see it.


1. Two checks, one page list

D019 (in the app) Page health (CI)
Runs in the administrator's browser on GitHub, every push and pull request
Sees each page as the browser receives it, signed in each page's source - there is no database to sign in to
Parses with the browser, new Function() Node, vm.Script
Also catches a script file that 404s; a page that answers HTTP 500 or carries a PHP fatal; scripts PHP builds as strings a fault before it is ever released
Misses nothing it can reach - but it needs someone to press Run scripts PHP builds as strings (Tz::scriptTag()); missing files
Code system/debug-tools/runners/page-health.php scripts/page_health_ci.php, .github/workflows/page-health.yml

Both take their pages from includes/page_health.php, so they can never disagree about what "every page" means.


2. The page list - includes/page_health.php

Nothing in FreeITSM listed every page (the survey found none: the waffle menu lists modules, getSystemAreas() lists System, tests list API endpoints). So the list is found, not kept:

  • A page is any .php file in an app folder that writes an HTML document as raw HTML - <!DOCTYPE html at the start of the file or straight after a ?> (pageHealthWritesDocument()) - or requires a shared page template that does (pageHealthTemplates(): today tool-page.php, case_report_page.php and the portal's header.php). The two-line Debug Tools pages are found through tool-page.php.
  • Not pages: anything under api/, includes/, assets/, tests/, scripts/, cron/, setup/ and the other non-app folders (PAGE_HEALTH_SKIP_DIRS), and files starting with _ (_registry.php).
  • The module of each page comes from the waffle menu's $modules - read from its source, because that file prints the menu when included. A copy of the list would drift the first time a module was added.
  • Never opened (PAGE_HEALTH_NEVER_OPEN): anything that does something just by being opened. Today only the sign-out pages, which are not on the list anyway (they write no HTML) - the entry is the safety net for one that ever does.

⚠️ TRAP: "contains <!DOCTYPE html" is not enough. csrf.php and SlackProvider.php echo an HTML string. Matching them as templates made every handler that includes csrf.php a "page". Only raw HTML counts.

php -r 'require "includes/page_health.php"; print_r(pageHealthPages("."));' prints the list (263 pages at 4.4.0).


3. D019 in the browser

A Debug Tool that runs in the browser

Every other Debug Tool is a PHP script that prints plain text. This one cannot be - a server does not run JavaScript, which is the whole point. So the registry gained one optional key:

'runner'   => 'page-health',

system/debug-tools/includes/tool-page.php then includes system/debug-tools/runners/<runner>.php twice - $RUNNER_PART = 'html' inside the card, in place of Run / output panel, and $RUNNER_PART = 'script' after i18n.js so window.t() exists. Everything else on the card (when to run it, what it checks, side effects) is the shared template, so D019 looks like every other tool. A future browser-side tool adds a runner file and the key - nothing else.

The tool's file endpoint, api/system/debug-tools/D019_page_health.php, returns the list as JSON (pages, with switched-off modules marked to skip, and the last result). D019_page_health_save.php stores the last result - POST only (csrfRequireWriteMethod()), counts clamped, no page content - in system_settings.page_health_last.

Checking a page

For each page (four at a time):

  1. fetch() it with redirect: 'manual'. A redirect, 400, 403 or 404 is skipped, with the reason: every page on the list is a file that exists, so those mean "needs a record to show" or "not for this account" - by design. 500 is a problem.
  2. Look for a PHP fatal in the body - it is still served as HTTP 200.
  3. Pull out every <script>: classic inline ones, and each same-origin src - fetched once however many pages load it.
  4. Compile each with new Function(code) - parse only.

⚠️ TRAP: never find the error line by running the script. The obvious way to get a line number is to inject the code into a hidden frame and read window.onerror. But a broken script wrapped as (function(){ ... }) can close the wrapper itself and run, with the administrator's session. new Function() parses its body as a function body on its own, so nothing escapes. The line is found by binary search: the shortest prefix of the script that fails with the same message.

⚠️ TRAP: an unclosed bracket blames line 1. V8 wraps new Function's body in its own { ... }), so a missing } surfaces as "Unexpected token ')'" at every prefix, and the search lands on line 1. closesWith() adds up to three closing brackets; if the code then compiles, it is reported as "something opened is never closed - the line shown is the end of the script".

⚠️ TRAP: HTML comments. The waffle menu carries a comment that shows a <script src="toast.js"> tag. Read as real, it put a missing toast.js on 220 pages. The scanner matches comments and skips them; whichever of a comment or a script starts first is matched whole, so a <!-- inside script code stays code.

Line numbers in D019's report are lines of the page source as the browser received it (View source), not of the PHP file. The report shows the code around the line, which is what you search for.

What the screen shows

A progress bar (red once anything fails) with the page being checked; a row per module that turns green, red or grey as its pages finish; modules with a problem move to the top, open; each problem with its message and the code around the line; Problems only, Copy and Download. The last result also shows on System β†’ Database Verification under the backup notice - green, red, or a reminder when it has never run or ran on an earlier version ($phState in system/db-verify/index.php).


4. CI - scripts/page_health_ci.php

php scripts/page_health_ci.php            check; exit 1 on any problem
php scripts/page_health_ci.php --list     what would be checked
php scripts/page_health_ci.php --dump F   the scripts as JSON, to parse elsewhere

What it parses: every inline script in every page and in the includes that write scripts into pages (the menu, headers), plus every .js the app ships except vendor libraries and *.min.js.

PHP inside a script cannot run here, so htmlView() rebuilds each file as the browser would get it, as far as can be known: raw HTML kept, each PHP block replaced by a stand-in of the same number of lines - so the report's line numbers are the file's own:

PHP block Stand-in Why
prints anything (<?=, or contains echo / print) null it stands for a value: var x = <?php echo json_encode($a); ?>;
anything else (if, foreach, require) /*php*/ both branches of an if stay, and nothing stray lands inside an object or argument list

⚠️ TRAP: the tokenizer, not a regex. htmlView() uses token_get_all(). A regex could not tell a <script> in the HTML from one in a PHP docblock ("the tag that publishes..."), nor see that <?php require ...; echo x; ?> prints - nine false alarms, all gone with the tokenizer.

⚠️ TRAP: invalid UTF-8 made the payload empty. One source file with a byte that is not valid UTF-8 made json_encode() return false - so Node received nothing, parsed nothing, and the run would have passed. PH_JSON substitutes bad bytes.

Parsing is one Node process: new vm.Script(code, { filename, lineOffset }) parses without running, and the first line of the error's stack is file:LINE. Without Node (Ed's machine) the script says so and exits 0 - run D019 instead, or --dump and parse the JSON in a browser.


5. How it was proved

Control D019 CI
A page with the exact #222 mistake "missing ) after argument list", the swallowed line shown above same, line 10 of the PHP file
4.3.0's own asset-management/index.php - fails at line 2652, "missing ) after argument list" - the reporter's error
A script file with an unclosed bracket flagged, "never closed", end of file flagged
A script src that does not exist "answered with HTTP 404" (not visible to a source check)
The whole app, controls removed All 232 pages healthy, 6,314 scripts read, 30 skipped 659 scripts, all parse

First real find: cmdb/audit.php had loaded assets/js/theme.js since it was written (#972). No such file has ever existed - a 404 on every visit. Removed.

D019's checks were run in real time - headless Chrome kept alive with --remote-debugging-port, the console read as it happened. A virtual clock cannot be trusted for this kind of test; see Floating windows - Testing.

Not covered

  • Errors while a script runs - a button calling a function that does not exist. The scripts are parsed, never run. A later version could load each page in a hidden frame and record what breaks; that runs the pages' code, so it would be a separate, opt-in pass.
  • Wrong behaviour - a page can parse perfectly and still be wrong.
  • ES modules (type="module") - none today; both checks skip them.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally