diff --git a/package.json b/package.json index 332c6f1..8be2577 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "sideEffects": true, "scripts": { "test": "node --test test/*.test.mjs", - "test:wp": "php wordpress/test/generator-test.php" + "test:wp": "php wordpress/test/generator-test.php", + "test:wp:integration": "node wordpress/test/integration.mjs" }, "engines": { "node": ">=18" diff --git a/wordpress/README.md b/wordpress/README.md index 408d17a..93a474e 100644 --- a/wordpress/README.md +++ b/wordpress/README.md @@ -41,9 +41,32 @@ CI lints at **PHP 7.4**, the plugin's declared floor, not at whatever the runner Syntax that is fine on 8.x and fatal on 7.4 is precisely the failure a user hits and we would not. -There is no full WordPress integration test. The stub covers the generator, which is where -the logic is; the routing, enqueue and settings code is thin enough to read, and is the part -to check by hand in a real install before any release. +```bash +npm run test:wp:integration # boots a REAL WordPress and checks routing + front end +``` + +The integration run mounts the plugin into WordPress Playground and asserts the things that +only break in a real install: that `/llms.txt` is served **without** a canonical redirect, +as plain text with nosniff, built from the site's own pages, excluding password-protected +ones, and that the front end declares the discovery link, mounts the element, enqueues the +`:defined` guard and pins the script with an integrity hash. + +It is not in CI: it downloads WordPress and takes a couple of minutes, a poor trade against +a fast suite. Run it before a release and any time the routing changes. + +⚠️ It writes its fixture through a mounted mu-plugin rather than a blueprint `runPHP` step, +because that step was silently skipped on some boots. A skipped seed is worse than a failed +one: the suite then tests an unseeded site and reports whatever that produces. A cached +Playground directory made exactly that look like a pass once. + +## Assets + +`assets/` holds what WordPress.org renders on the listing: `banner-1544x500.png` and its +772x250 downsample, `icon-256x256.png` and its 128x128, and the three screenshots. The +banner and icon are rendered from HTML in the repo's history at 2x and downsampled, so the +pair cannot drift apart. + +Screenshots were captured against a real WordPress, not mocked. ## Publishing to the plugin directory diff --git a/wordpress/assets/banner-1544x500.png b/wordpress/assets/banner-1544x500.png new file mode 100644 index 0000000..15bcec9 Binary files /dev/null and b/wordpress/assets/banner-1544x500.png differ diff --git a/wordpress/assets/banner-772x250.png b/wordpress/assets/banner-772x250.png new file mode 100644 index 0000000..b645183 Binary files /dev/null and b/wordpress/assets/banner-772x250.png differ diff --git a/wordpress/assets/icon-128x128.png b/wordpress/assets/icon-128x128.png new file mode 100644 index 0000000..78a60c5 Binary files /dev/null and b/wordpress/assets/icon-128x128.png differ diff --git a/wordpress/assets/icon-256x256.png b/wordpress/assets/icon-256x256.png new file mode 100644 index 0000000..b065542 Binary files /dev/null and b/wordpress/assets/icon-256x256.png differ diff --git a/wordpress/assets/screenshot-1.png b/wordpress/assets/screenshot-1.png new file mode 100644 index 0000000..62a1750 Binary files /dev/null and b/wordpress/assets/screenshot-1.png differ diff --git a/wordpress/assets/screenshot-2.png b/wordpress/assets/screenshot-2.png new file mode 100644 index 0000000..e09df2b Binary files /dev/null and b/wordpress/assets/screenshot-2.png differ diff --git a/wordpress/assets/screenshot-3.png b/wordpress/assets/screenshot-3.png new file mode 100644 index 0000000..019924a Binary files /dev/null and b/wordpress/assets/screenshot-3.png differ diff --git a/wordpress/human-machine-view/human-machine-view.php b/wordpress/human-machine-view/human-machine-view.php index 6ae4d69..52c5f17 100644 --- a/wordpress/human-machine-view/human-machine-view.php +++ b/wordpress/human-machine-view/human-machine-view.php @@ -86,6 +86,29 @@ function hmv_query_vars( $vars ) { return $vars; } +add_filter( 'redirect_canonical', 'hmv_no_canonical_redirect', 10, 2 ); +/** + * Stops WordPress bouncing /llms.txt to /llms.txt/. + * + * With a trailing-slash permalink structure, redirect_canonical treats our route like any + * other and 301s it to a slashed URL. llms.txt is a FILE path by convention: every tool + * asks for /llms.txt, including this project's own component. Most clients follow the 301 + * and it works by luck, which is exactly the kind of thing that breaks against a fetcher + * that does not. + * + * Caught by running the plugin in a real WordPress rather than by reading the code. + * + * @param string|false $redirect The URL WordPress wants to redirect to. + * @param string $requested The requested URL. + * @return string|false + */ +function hmv_no_canonical_redirect( $redirect, $requested ) { + if ( get_query_var( 'hmv_llms' ) ) { + return false; + } + return $redirect; +} + add_action( 'template_redirect', 'hmv_maybe_serve_llms' ); /** Answers /llms.txt as plain text, or gets out of the way. */ function hmv_maybe_serve_llms() { diff --git a/wordpress/test/integration.mjs b/wordpress/test/integration.mjs new file mode 100644 index 0000000..72a7099 --- /dev/null +++ b/wordpress/test/integration.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * Boots a real WordPress with the plugin mounted and checks the things that only break in + * a real WordPress. + * + * The stub test in generator-test.php covers the generator, which is where the logic is. + * This covers the parts that are thin enough to read and still wrong: rewrite rules, + * canonical redirects, the enqueued guard, the SRI attribute. The trailing-slash bug this + * asserts against was invisible to linting, invisible to the stub, and obvious within one + * request of a real install. + * + * Not wired into CI: it downloads WordPress and takes a couple of minutes, which is a poor + * trade against a fast suite. Run it before a release, and any time the routing changes. + * + * node wordpress/test/integration.mjs + */ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN = resolve(HERE, "../human-machine-view"); +const PORT = Number(process.env.PORT || 9411); +const BASE = `http://127.0.0.1:${PORT}`; + +/** + * Seeding runs from a mounted mu-plugin, not a blueprint runPHP step. + * + * The blueprint step was silently skipped on some boots, which is worse than failing: the + * suite then tested an unseeded site and reported whatever that produced. Worse still, a + * cached site directory from an earlier run made it look like it had passed. A mu-plugin + * runs on every request, on this exact site, or the site does not work at all. + */ +const SEED_MU_PLUGIN = `ID, true ); } + foreach ( get_posts( array( 'name' => 'hello-world', 'post_type' => 'post', 'post_status' => 'any' ) ) as $p ) { + wp_delete_post( $p->ID, true ); + } + + wp_insert_post( array( + 'post_title' => 'A Real Page', + 'post_name' => 'a-real-page', + 'post_type' => 'page', + 'post_status' => 'publish', + ) ); + // Password-protected: must NOT reach a file written for machines to read. + wp_insert_post( array( + 'post_title' => 'Secret', + 'post_name' => 'secret', + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_password' => 'x', + ) ); + + update_option( 'blogname', 'Integration Fixture' ); + update_option( 'blogdescription', 'A fixture site.' ); + // The trailing slash is what provoked the canonical-redirect bug. Testing without it + // would test the easy case. + update_option( 'permalink_structure', '/%postname%/' ); + update_option( 'hmv_settings', array( + 'serve_llms' => 1, + 'show_pill' => 1, + 'position' => 'bottom-center', + 'accent' => '#a8f326', + 'max_pages' => 40, + 'summary' => 'A fixture.', + 'about' => 'About the fixture.', + 'facts' => '', + ) ); + + delete_transient( 'hmv_llms_txt' ); + hmv_add_rewrite(); + flush_rewrite_rules(); + update_option( 'hmv_fixture_seeded', 1 ); +}, 99 ); +`; + +const BLUEPRINT = { + landingPage: "/", + // Deliberately NOT logging in. Playground's login step sets a cookie via a redirect, and + // a bare fetch() does not keep cookies, so every request bounces and the boot check never + // succeeds. Nothing asserted below needs an admin session: it is all what a logged-out + // visitor and a crawler see, which is the case that matters. + login: false, + steps: [ + { step: "activatePlugin", pluginPath: "human-machine-view/human-machine-view.php" }, + ], +}; + +let failures = 0; +const check = (label, ok, detail = "") => { + console.log(` ${ok ? "ok " : "FAIL"} ${label}${ok || !detail ? "" : ` — ${detail}`}`); + if (!ok) failures++; +}; + +async function waitForServer(timeoutMs = 180000) { + const until = Date.now() + timeoutMs; + while (Date.now() < until) { + try { + const r = await fetch(BASE + "/", { redirect: "follow" }); + if (r.ok) return true; + } catch {} + await new Promise((r) => setTimeout(r, 2000)); + } + return false; +} + +async function main() { + const dir = mkdtempSync(join(tmpdir(), "hmv-int-")); + const bp = join(dir, "blueprint.json"); + writeFileSync(bp, JSON.stringify(BLUEPRINT, null, 2)); + + const muDir = join(dir, "mu-plugins"); + mkdirSync(muDir, { recursive: true }); + writeFileSync(join(muDir, "hmv-seed.php"), SEED_MU_PLUGIN); + + console.log("booting WordPress (this downloads it the first time)…"); + const server = spawn( + "npx", + [ + "--yes", + "@wp-playground/cli@latest", + "server", + `--port=${PORT}`, + `--blueprint=${bp}`, + `--mount=${PLUGIN}:/wordpress/wp-content/plugins/human-machine-view`, + `--mount=${muDir}:/wordpress/wp-content/mu-plugins`, + ], + { cwd: dir, stdio: process.env.HMV_DEBUG ? "inherit" : "ignore", detached: true } + ); + + try { + if (!(await waitForServer())) throw new Error("WordPress never came up"); + console.log(`\nrouting\n`); + + // The bug this file exists for: with a trailing-slash permalink structure, WordPress + // 301s /llms.txt to /llms.txt/. Every tool asks for the unslashed path. + const direct = await fetch(`${BASE}/llms.txt`, { redirect: "manual" }); + check( + "/llms.txt is served directly, with no canonical redirect", + direct.status === 200, + `got ${direct.status} -> ${direct.headers.get("location") || "-"}` + ); + check( + "served as plain text", + (direct.headers.get("content-type") || "").startsWith("text/plain"), + direct.headers.get("content-type") || "(none)" + ); + check("nosniff is set", direct.headers.get("x-content-type-options") === "nosniff"); + + const body = await direct.text(); + console.log(`\ncontent\n`); + check("built from the site's own pages", body.includes("A Real Page")); + check( + "password-protected pages are excluded", + !body.includes("Secret"), + "a machine reading must only contain what is already public" + ); + check("carries the owner's About", body.includes("About the fixture.")); + if (process.env.HMV_DEBUG) console.log("\n--- body ---\n" + body + "\n--- end ---\n"); + + console.log(`\nfront end\n`); + const home = await fetch(BASE + "/").then((r) => r.text()); + check("declares the discovery link", /rel=["']llms["']/.test(home)); + check("mounts the element", home.includes(" { + console.error("integration run failed:", err.message); + process.exit(1); +});