diff --git a/herdr-insights/README.md b/herdr-insights/README.md index 83b4f8c..a68c431 100644 --- a/herdr-insights/README.md +++ b/herdr-insights/README.md @@ -8,11 +8,10 @@ Reads local sessions from **Pi, Codex, Claude Code, and OpenCode**. ![Insights](preview.png) -*Preview uses synthetic data. The installed dashboard only shows recorded usage.* - ## Install -Requires Herdr and **Node.js 22.15+**. No dependencies or account needed. +Installing requires Herdr and **Node.js 22.15+**. No dependencies or account needed. +The uninstaller also runs on older Node versions. Ask your agent: @@ -26,9 +25,10 @@ cd pi-extensions npm run install:local --prefix herdr-insights -- --open ``` -The installer runs `herdr-app check`, then opens an **Insights** tab. It installs -only `mods/insights/` under `~/.config/herdr-app`; your layouts, keys, and terminals -are unchanged. Set `HERDR_APP_HOME` to use a different app home. +The installer runs `herdr-app check`; with `--open` it then opens an **Insights** +tab. It installs owned files only under `mods/insights/` and writes its install +manifest to `data/insights/` in `~/.config/herdr-app`; your layouts, keys, and +terminals are unchanged. Set `HERDR_APP_HOME` to use a different app home. ## Use diff --git a/herdr-insights/RECIPE.md b/herdr-insights/RECIPE.md index a383771..59a90c8 100644 --- a/herdr-insights/RECIPE.md +++ b/herdr-insights/RECIPE.md @@ -6,9 +6,6 @@ and opens in its own tab without changing terminals, layouts, or keys. ![Insights](preview.png) -The screenshot uses synthetic data for illustration. Never include those rows or -future model names as defaults: the installed mod shows only recorded usage. - **What you see:** A vertically scrollable dashboard centered at a maximum width of 1180px. Use Herdr theme colors, 1px borders, 8px card corners, 6px control corners, no decorative shadows, and 4/8/12/16/24px spacing. Page padding is 12px @@ -46,7 +43,7 @@ metrics or heatmap. Menus cap choices at 250 while retaining selected values. Week, month, and current-year periods are calendar-to-date; past years are full. Compare with the same calendar portion of the previous period, clamping month ends. All has no comparison. The chart always spans 26 weeks ending at the selected -period's end, rather than switching to daily buckets as in the illustrative preview. +period's end. Weekly heatmap mode repeats each week's total on every day in that week; cumulative mode includes all preceding history. A streak ending yesterday still @@ -65,8 +62,9 @@ are **Insights**; the native mod ID is `insights`. semantic colors, `on_size_change`, and fresh callback contexts. Do not retain `cx`. Cancel tasks/timers and release native editor/input state on close. Use native animations, never redraw timers. No DOM, web view, shell interpolation, or network. -`ui.js` centralizes surfaces, typography, spacing, and controls; bundle monochrome -provider/model SVGs in `logos/`. Pure `analytics.js` runs in Node and prepares totals, +`ui.js` centralizes surfaces, typography, spacing, and controls; bundle provider/model +SVGs in `logos/`. Some logos carry brand colors, so do not assume they are +monochrome or theme-tintable. Pure `analytics.js` runs in Node and prepares totals, facets, chart buckets, heatmap, and leaderboard comparisons. The UI invokes a local Node `presenter.mjs` through Herdr `exec` with separate @@ -93,8 +91,10 @@ cacheWrite, and reasoning field specs. `roles` filters the mapped role; `include is a list of `{path, equals}` or `{path, exists}` conditions. Reject unknown keys and executable mappings. `core/config.mjs` defines and validates the full schema. -Provide dependency-free Node install/uninstall scripts. Require Node >=22.15 before -writing anything. Install only the mod tree and generate `runtime.json` with the +Provide dependency-free Node install/uninstall scripts. The installer requires +Node >=22.15 and validates it before writing anything; the uninstaller intentionally +has no Node gate so it remains usable on older runtimes. Install only the mod tree +(plus its manifest under `data/insights/`) and generate `runtime.json` with the absolute installer Node path; the UI uses that path, falling back to `node` on PATH only for manual copies. Track ownership/hashes in a separate install manifest. Refuse symlinks and unowned collisions; refuse edited owned files unless explicitly @@ -114,8 +114,6 @@ a backup before rebuilding; read-only prepare must not repair or create stores. Never write layouts, keys, source transcripts, or an existing user's usage data from the installer. Do not copy an absolute runtime path from another machine. -The preview is a native UI screenshot with synthetic data; the production dashboard -must not imitate its daily-bucket mock behavior or fabricated model list. **Done when:** Package tests and checks pass, including distribution naming, PNG preview, recipe structure, no layouts, token accounting, deduplication, diff --git a/herdr-insights/scripts/check.mjs b/herdr-insights/scripts/check.mjs index 6b14d85..8ad3326 100644 --- a/herdr-insights/scripts/check.mjs +++ b/herdr-insights/scripts/check.mjs @@ -9,6 +9,7 @@ import { collectSourceFiles, lstatOrNull, } from "./lib.mjs"; +import { inspectDistribution } from "./distribution.mjs"; const errors = []; const notes = []; @@ -116,7 +117,6 @@ async function checkPackage() { async function checkFiles() { for (const relative of [ ".gitignore", - "README.md", "RECIPE.md", "preview.png", "mods/insights/main.js", @@ -138,18 +138,6 @@ async function checkFiles() { } async function checkDocs() { - const readme = await readText(path.join(PACKAGE_ROOT, "README.md")); - if (readme !== null) { - for (const heading of ["# Insights", "## Install", "## Use", "## Privacy", "## Development"]) { - if (!readme.includes(heading)) fail(`README.md is missing ${heading}`); - } - for (const phrase of ["![Insights](preview.png)", "insights.json", "herdr-app check", "Node.js 22.15+", "synthetic data"]) { - if (!readme.toLowerCase().includes(phrase.toLowerCase())) { - fail(`README.md does not mention ${JSON.stringify(phrase)}`); - } - } - } - const recipe = await readText(path.join(PACKAGE_ROOT, "RECIPE.md")); if (recipe !== null) { const ordered = [ @@ -209,22 +197,9 @@ async function checkTestInventory() { } async function checkDistribution() { - if (path.basename(PACKAGE_ROOT) !== "herdr-insights") fail("Distribution folder must be herdr-insights"); - if (await lstatOrNull(path.join(PACKAGE_ROOT, "layouts"))) fail("Herdr mods must not ship layouts/"); - try { - const preview = await fs.readFile(path.join(PACKAGE_ROOT, "preview.png")); - if (preview.length < 24 || preview.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" - || preview.toString("ascii", 12, 16) !== "IHDR" - || preview.readUInt32BE(16) === 0 || preview.readUInt32BE(20) === 0) { - fail("preview.png must be a PNG image with nonzero dimensions"); - } - const mod = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "mods/insights/mod.json"), "utf8")); - if (mod.title !== "Insights" || mod.place !== "none") { - fail('mod.json must use title "Insights" and place "none"'); - } - } catch (error) { - fail(`Invalid Herdr distribution: ${error.message}`); - } + const result = await inspectDistribution(PACKAGE_ROOT); + for (const message of result.errors) fail(message); + notes.push(...result.notes); } async function checkSourceIfPresent() { diff --git a/herdr-insights/scripts/distribution.mjs b/herdr-insights/scripts/distribution.mjs new file mode 100644 index 0000000..70e324b --- /dev/null +++ b/herdr-insights/scripts/distribution.mjs @@ -0,0 +1,45 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { lstatOrNull } from "./lib.mjs"; +import { validatePng } from "./png.mjs"; + +export async function inspectDistribution(packageRoot) { + const errors = []; + const notes = []; + + if (path.basename(packageRoot) !== "herdr-insights") { + errors.push("Distribution folder must be herdr-insights"); + } + for (const [relative, message] of [ + ["layouts", "Herdr mods must not ship layouts/"], + ["keys.json", "Herdr mods must not ship keys.json"], + ]) { + try { + if (await lstatOrNull(path.join(packageRoot, relative))) errors.push(message); + } catch (error) { + errors.push(`Cannot inspect ${relative}: ${error.message}`); + } + } + + try { + const preview = await fs.readFile(path.join(packageRoot, "preview.png")); + const image = validatePng(preview); + notes.push(`preview.png is a complete ${image.width}x${image.height} PNG`); + } catch (error) { + errors.push(`preview.png is not a complete, valid PNG image: ${error.message}`); + } + + try { + const mod = JSON.parse( + await fs.readFile(path.join(packageRoot, "mods/insights/mod.json"), "utf8"), + ); + if (mod.title !== "Insights" || mod.place !== "none") { + errors.push('mod.json must use title "Insights" and place "none"'); + } + } catch (error) { + errors.push(`Invalid Herdr distribution: ${error.message}`); + } + + return { errors, notes }; +} diff --git a/herdr-insights/scripts/install.mjs b/herdr-insights/scripts/install.mjs index b3f6dae..45baa90 100644 --- a/herdr-insights/scripts/install.mjs +++ b/herdr-insights/scripts/install.mjs @@ -126,7 +126,6 @@ async function addParentIssues(targetDirectory, target, issues) { async function install(options) { assertSupportedNodeVersion(); - const appHome = await resolveAppHome(options.appHome); const sourceRoot = options.source ? path.resolve(expandHome(options.source)) : PACKAGE_ROOT; @@ -137,6 +136,9 @@ async function install(options) { source.files.push(createRuntimeFile()); source.files.sort((left, right) => left.relative.localeCompare(right.relative)); + // Validate the source before creating the app home. A bad --source must not + // leave a new target directory behind. + const appHome = await resolveAppHome(options.appHome); const targetDirectory = modDir(appHome); const targetDataDirectory = dataDir(appHome); const targetManifest = manifestPath(appHome); diff --git a/herdr-insights/scripts/lib.mjs b/herdr-insights/scripts/lib.mjs index c7ec03c..5261a26 100644 --- a/herdr-insights/scripts/lib.mjs +++ b/herdr-insights/scripts/lib.mjs @@ -80,12 +80,22 @@ export function expandHome(value) { return value; } -export async function resolveAppHome(explicit) { +export async function resolveAppHome(explicit, options = {}) { const configured = explicit && explicit.length > 0 ? explicit : process.env.HERDR_APP_HOME || path.join(os.homedir(), ".config", "herdr-app"); const absolute = path.resolve(expandHome(configured)); + const create = options.create !== false; + + if (!create) { + const existing = await lstatOrNull(absolute); + if (!existing) return null; + if (!existing.isDirectory() || existing.isSymbolicLink()) { + throw new Error(`App home is not a directory: ${absolute}`); + } + return fs.realpath(absolute); + } try { await fs.mkdir(absolute, { recursive: true }); @@ -96,7 +106,7 @@ export async function resolveAppHome(explicit) { } const stat = await fs.lstat(absolute); - if (!stat.isDirectory()) { + if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`App home is not a directory: ${absolute}`); } return fs.realpath(absolute); diff --git a/herdr-insights/scripts/png.mjs b/herdr-insights/scripts/png.mjs new file mode 100644 index 0000000..e794c20 --- /dev/null +++ b/herdr-insights/scripts/png.mjs @@ -0,0 +1,78 @@ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const CRC_TABLE = new Uint32Array(256); + +for (let index = 0; index < 256; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + CRC_TABLE[index] = value >>> 0; +} + +function crc32(buffer, start, end) { + let crc = 0xffffffff; + for (let index = start; index < end; index += 1) { + crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buffer[index]) & 0xff]; + } + return (crc ^ 0xffffffff) >>> 0; +} + +// Parse the PNG container without decoding pixels. Chunk bounds, CRCs, the +// IHDR dimensions, at least one IDAT chunk, and the terminal IEND chunk make +// truncation and corruption fail without imposing optional screen-size rules. +export function validatePng(input) { + const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input); + if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new Error("missing the PNG signature"); + } + + let offset = PNG_SIGNATURE.length; + let width = 0; + let height = 0; + let chunkCount = 0; + let sawIdat = false; + let sawIend = false; + + while (offset < buffer.length) { + if (sawIend) throw new Error("has data after the IEND chunk"); + if (buffer.length - offset < 12) throw new Error("has a truncated chunk header"); + + const length = buffer.readUInt32BE(offset); + const typeStart = offset + 4; + const dataStart = offset + 8; + const crcOffset = dataStart + length; + if (length > 0x7fffffff || crcOffset + 4 > buffer.length) { + throw new Error("has a truncated chunk"); + } + + const type = buffer.toString("ascii", typeStart, dataStart); + if (!/^[A-Za-z]{4}$/.test(type)) throw new Error("has an invalid chunk type"); + const expectedCrc = buffer.readUInt32BE(crcOffset); + const actualCrc = crc32(buffer, typeStart, crcOffset); + if (expectedCrc !== actualCrc) throw new Error(`has an invalid ${type} chunk CRC`); + + if (chunkCount === 0) { + if (type !== "IHDR" || length !== 13) { + throw new Error("does not start with a valid IHDR chunk"); + } + width = buffer.readUInt32BE(dataStart); + height = buffer.readUInt32BE(dataStart + 4); + if (width === 0 || height === 0) throw new Error("has zero image dimensions"); + } else if (type === "IHDR") { + throw new Error("has more than one IHDR chunk"); + } + if (type === "IDAT") sawIdat = true; + if (type === "IEND") { + if (length !== 0) throw new Error("has a non-empty IEND chunk"); + sawIend = true; + } + + chunkCount += 1; + offset = crcOffset + 4; + } + + if (chunkCount === 0) throw new Error("has no PNG chunks"); + if (!sawIdat) throw new Error("has no IDAT image data"); + if (!sawIend) throw new Error("is missing the terminal IEND chunk"); + return { width, height, chunks: chunkCount }; +} diff --git a/herdr-insights/scripts/uninstall.mjs b/herdr-insights/scripts/uninstall.mjs index 64f4a0e..c6e93ac 100644 --- a/herdr-insights/scripts/uninstall.mjs +++ b/herdr-insights/scripts/uninstall.mjs @@ -3,6 +3,7 @@ import path from "node:path"; import { assertNoSymlinkComponents, + isInside, lstatOrNull, localPath, manifestPath, @@ -57,42 +58,39 @@ function parseArgs(argv) { return options; } -async function pruneEmptyDirectories(directory) { - let entries; - try { - entries = await fs.readdir(directory, { withFileTypes: true }); - } catch (error) { - if (error.code === "ENOENT") return true; - throw error; +function ownedParentDirectories(targetDirectory, target) { + const parents = []; + let current = path.dirname(target); + while (isInside(targetDirectory, current)) { + parents.push(current); + if (path.resolve(current) === path.resolve(targetDirectory)) break; + current = path.dirname(current); } + return parents; +} - let empty = true; - for (const entry of entries) { - if (entry.isSymbolicLink()) { - empty = false; - continue; - } - if (entry.isDirectory()) { - const childEmpty = await pruneEmptyDirectories(path.join(directory, entry.name)); - if (!childEmpty) empty = false; - continue; +async function pruneOwnedDirectories(directories) { + const ordered = [...new Set(directories)].sort( + (left, right) => right.split(path.sep).length - left.split(path.sep).length, + ); + for (const directory of ordered) { + try { + await fs.rmdir(directory); + } catch (error) { + if (error.code === "ENOENT" || error.code === "ENOTEMPTY" || error.code === "EEXIST") { + continue; + } + throw error; } - empty = false; - } - - if (!empty) return false; - try { - await fs.rmdir(directory); - return true; - } catch (error) { - if (error.code === "ENOTEMPTY" || error.code === "EEXIST") return false; - if (error.code === "ENOENT") return true; - throw error; } } async function uninstall(options) { - const appHome = await resolveAppHome(options.appHome); + const appHome = await resolveAppHome(options.appHome, { create: false }); + if (!appHome) { + console.log("Insights is not installed by this package; nothing to remove."); + return; + } const targetDirectory = modDir(appHome); const targetManifest = manifestPath(appHome); const manifest = await readManifest(appHome); @@ -118,6 +116,7 @@ async function uninstall(options) { const removed = []; const kept = []; + const ownedParents = []; const remaining = Object.create(null); for (const [relative, oldHash] of Object.entries(manifest.files)) { @@ -149,10 +148,11 @@ async function uninstall(options) { await fs.unlink(target); removed.push(relative); + ownedParents.push(...ownedParentDirectories(targetDirectory, target)); } - if (targetStat) { - await pruneEmptyDirectories(targetDirectory); + if (targetStat && removed.length > 0) { + await pruneOwnedDirectories(ownedParents); } const remainingEntries = Object.entries(remaining); diff --git a/herdr-insights/tests/distribution.test.mjs b/herdr-insights/tests/distribution.test.mjs index 628b495..1ce6a2c 100644 --- a/herdr-insights/tests/distribution.test.mjs +++ b/herdr-insights/tests/distribution.test.mjs @@ -1,32 +1,79 @@ import assert from "node:assert/strict"; -import { readFile, readdir, lstat } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, lstat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import path from "node:path"; import test from "node:test"; +import { inspectDistribution } from "../scripts/distribution.mjs"; +import { validatePng } from "../scripts/png.mjs"; + const root = fileURLToPath(new URL("../", import.meta.url)); const read = (relative) => readFile(path.join(root, relative), "utf8"); test("Herdr distribution has the requested folder and display name, with no layouts or bindings", async () => { assert.equal(path.basename(root), "herdr-insights"); assert.deepEqual(JSON.parse(await read("mods/insights/mod.json")), { place: "none", title: "Insights" }); - assert.deepEqual(await readdir(path.join(root, "mods")), ["insights"]); + const modEntries = (await readdir(path.join(root, "mods"))).filter((name) => name !== ".DS_Store"); + assert.deepEqual(modEntries, ["insights"]); await assert.rejects(lstat(path.join(root, "layouts")), { code: "ENOENT" }); await assert.rejects(lstat(path.join(root, "keys.json")), { code: "ENOENT" }); assert.match(await read("RECIPE.md"), /^# Insights\n/); }); -test("Community preview is a PNG referenced by both human and agent instructions", async () => { +test("Community preview is a complete PNG referenced by the recipe", async () => { + const image = await readFile(path.join(root, "preview.png")); + const parsed = validatePng(image); + assert.ok(parsed.width > 0 && parsed.height > 0); + const recipe = await read("RECIPE.md"); + assert.match(recipe, /!\[Insights\]\(preview\.png\)/); + assert.doesNotMatch(recipe, /image2\.png/); +}); + +test("PNG validation rejects truncated and corrupted previews", async () => { const image = await readFile(path.join(root, "preview.png")); - assert.equal(image.subarray(0, 8).toString("hex"), "89504e470d0a1a0a"); - assert.equal(image.toString("ascii", 12, 16), "IHDR"); - assert.ok(image.readUInt32BE(16) > 0 && image.readUInt32BE(20) > 0); - for (const file of ["README.md", "RECIPE.md"]) { - const text = await read(file); - assert.match(text, /!\[Insights\]\(preview\.png\)/); - assert.match(text, /synthetic data/i); - assert.doesNotMatch(text, /image2\.png/); + assert.throws( + () => validatePng(image.subarray(0, image.length - 12)), + /IEND|truncated/i, + ); + assert.throws(() => validatePng(image.subarray(0, 40)), /truncated|IEND/i); + + let offset = 8; + let dataStart = null; + while (offset + 12 <= image.length) { + const length = image.readUInt32BE(offset); + const type = image.toString("ascii", offset + 4, offset + 8); + if (type === "IDAT") { + dataStart = offset + 8; + break; + } + offset += 12 + length; } + assert.notEqual(dataStart, null); + const corrupted = Buffer.from(image); + corrupted[dataStart] ^= 0xff; + assert.throws(() => validatePng(corrupted), /CRC/i); +}); + +test("Distribution inspection rejects keys.json and layouts without ignoring them", async (t) => { + const temporary = await mkdtemp(path.join(tmpdir(), "insights-distribution-")); + t.after(async () => rm(temporary, { recursive: true, force: true })); + const packageRoot = path.join(temporary, "herdr-insights"); + await mkdir(path.join(packageRoot, "mods", "insights"), { recursive: true }); + await copyFile(path.join(root, "preview.png"), path.join(packageRoot, "preview.png")); + await writeFile( + path.join(packageRoot, "mods", "insights", "mod.json"), + '{"place":"none","title":"Insights"}\n', + ); + + assert.deepEqual((await inspectDistribution(packageRoot)).errors, []); + + await writeFile(path.join(packageRoot, "keys.json"), "{}\n"); + assert.match((await inspectDistribution(packageRoot)).errors.join("\n"), /keys\.json/); + await rm(path.join(packageRoot, "keys.json")); + + await mkdir(path.join(packageRoot, "layouts")); + assert.match((await inspectDistribution(packageRoot)).errors.join("\n"), /layouts/i); }); test("Recipe follows Herdr's required order and remains self-contained", async () => { diff --git a/herdr-insights/tests/install.test.mjs b/herdr-insights/tests/install.test.mjs index f64a7de..72cb643 100644 --- a/herdr-insights/tests/install.test.mjs +++ b/herdr-insights/tests/install.test.mjs @@ -564,6 +564,36 @@ test("uninstall removes a clean install, keeps data, and is safe without a manif ); }); +test("uninstall of a missing app home creates nothing", async (t) => { + const root = await temporaryRoot(t); + const appHome = path.join(root, "missing app home"); + const result = runUninstall({ appHome }); + assertSuccess(result); + assert.match(result.stdout, /nothing to remove/); + assert.equal(await exists(appHome), false); +}); + +test("uninstall prunes only owned-file parents and preserves nested unowned empty directories", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSource(root); + const appHome = path.join(root, "app home"); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + + const unownedNested = path.join(appHome, "mods", "insights", "unowned-empty", "nested"); + const unownedWithinOwnedParent = path.join(appHome, "mods", "insights", "icons", "local-empty", "nested"); + await fs.mkdir(unownedNested, { recursive: true }); + await fs.mkdir(unownedWithinOwnedParent, { recursive: true }); + + assertSuccess(runUninstall({ appHome })); + assert.equal(await exists(unownedNested), true); + assert.equal(await exists(unownedWithinOwnedParent), true); + assert.equal(await exists(path.join(appHome, "mods", "insights", "unowned-empty")), true); + assert.equal(await exists(path.join(appHome, "mods", "insights", "icons")), true); + assert.equal(await exists(path.join(appHome, "mods", "insights", "core")), false); + assert.equal(await exists(path.join(appHome, "mods", "insights", "collector.mjs")), false); +}); + test("source validation requires main.js and mod.json with place none", async (t) => { const root = await temporaryRoot(t); const fake = await createFakeRunner(root); @@ -576,6 +606,7 @@ test("source validation requires main.js and mod.json with place none", async (t const wrongPlace = runInstall({ appHome: wrongPlaceApp, fake, sourceRoot: wrongPlaceRoot }); assertFailure(wrongPlace); assert.match(wrongPlace.stderr, /place.*none/i); + assert.equal(await exists(wrongPlaceApp), false); assert.equal(await exists(path.join(wrongPlaceApp, "mods", "insights")), false); const noMainRoot = path.join(root, "no main source"); @@ -586,6 +617,7 @@ test("source validation requires main.js and mod.json with place none", async (t const noMain = runInstall({ appHome: noMainApp, fake, sourceRoot: noMainRoot }); assertFailure(noMain); assert.match(noMain.stderr, /main\.js/); + assert.equal(await exists(noMainApp), false); assert.equal(await exists(path.join(noMainApp, "mods", "insights")), false); assert.deepEqual(await readLog(fake), []); }); diff --git a/herdr-insights/tests/native-async-check.mjs b/herdr-insights/tests/native-async-check.mjs index b235021..7702327 100644 --- a/herdr-insights/tests/native-async-check.mjs +++ b/herdr-insights/tests/native-async-check.mjs @@ -107,6 +107,15 @@ function sleep(ms) { return new Promise((done) => setTimeout(done, ms)); } +async function waitForPath(target, timeout) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (existsSync(target)) return true; + await sleep(100); + } + return existsSync(target); +} + function cliEnv(home) { return { ...process.env, HERDR_APP_HOME: home }; } @@ -145,6 +154,8 @@ if (requestedRows !== null && (!Number.isInteger(requestedRows) || requestedRows const keep = process.argv.includes("--keep"); const settingsMode = process.argv.includes("--settings"); const menuMode = process.argv.includes("--menu"); +if (settingsMode && menuMode) throw new Error("--settings and --menu are mutually exclusive"); +const requestedMode = settingsMode ? "settings" : menuMode ? "menu" : "dashboard"; const zoomOut = Number(argument("--zoom-out", "0")); if (!Number.isInteger(zoomOut) || zoomOut < 0 || zoomOut > 6) { throw new Error("--zoom-out must be an integer from 0 to 6"); @@ -158,6 +169,8 @@ const logPath = "/tmp/insights-native-async.log"; const shotPath = argument("--shot", "/tmp/insights-native-async.png"); const mod = join(home, "mods", "insights"); const storePath = join(home, "data", "insights", "insights.json"); +const markerPath = join(home, "rendered-requested-state.json"); +const markerScript = join(home, "write-render-marker.mjs"); const appBinary = process.env.HERDR_APP_BINARY ?? "/Applications/Herdr.app/Contents/MacOS/Herdr"; let app = null; let output = ""; @@ -193,17 +206,34 @@ try { cpSync(modSource, mod, { recursive: true }); writeFileSync(join(mod, "runtime.json"), `${JSON.stringify({ node: process.execPath })}\n`); - if (settingsMode || menuMode) { + writeFileSync( + markerScript, + 'import { writeFileSync } from "node:fs";\nwriteFileSync(process.argv[2], "rendered\\n");\n', + ); + { const mainPath = join(mod, "main.js"); const main = readFileSync(mainPath, "utf8"); const marker = " this.commandTask = cx.spawn(async (commandCx) => {"; - if (!main.includes(marker)) throw new Error("cannot find main.js settings injection point"); + const timeout = "const ACTIVITY_TIMEOUT_MS = 35_000;"; + const returnStart = ' return v_flex().id("insights-root").size_full()'; + const returnEnd = [ + " .child(h_flex().w_full().justify_center().p(padding)", + " .child(v_flex().w_full().max_w(1180).gap(G.lg).children(content)));", + ].join("\n"); + if (!main.includes(marker) || !main.includes(timeout) || !main.includes(returnStart) + || !main.includes(returnEnd)) { + throw new Error("cannot find main.js marker or settings injection point"); + } let injected = ""; if (settingsMode) injected += " this.showSettings = true;\n"; if (menuMode) { injected += [ " cx.timer.after(4000, (menuCx) => {", - " if (!this.dashboard) return;", + " if (!this.dashboard) {", + " this.error = 'Menu check: dashboard was not ready';", + " menuCx.notify();", + " return;", + " }", " this.openFilter = \"ins-model-filter\";", " this.openOptions = buildFacetOptions(\"model\", this.dashboard.facets.model, normalizeConfig(this.snapshot.config));", " menuCx.notify();", @@ -211,7 +241,34 @@ try { "", ].join("\n"); } - writeFileSync(mainPath, main.replace(marker, `${injected}${marker}`)); + const instrumentation = [ + `const __INSIGHTS_RENDER_CHECK = ${JSON.stringify({ mode: requestedMode, path: markerPath, script: markerScript })};`, + "function __insightsRequestedStateRendered(view) {", + " if (__INSIGHTS_RENDER_CHECK.mode === 'settings') {", + " return view.showSettings && Boolean(view.configEditor) && Boolean(view.refreshInput);", + " }", + " if (__INSIGHTS_RENDER_CHECK.mode === 'menu') {", + " return !view.showSettings && Boolean(view.dashboard) && view.openFilter === 'ins-model-filter' && Boolean(view.openOptions);", + " }", + " return !view.showSettings && Boolean(view.dashboard) && !view.dashboardError;", + "}", + "", + timeout, + ].join("\n"); + const renderTail = [ + returnEnd, + " if (!this.__renderMarkerSent && __insightsRequestedStateRendered(this)) {", + " this.__renderMarkerSent = true;", + " exec(this.nodeBinary, [__INSIGHTS_RENDER_CHECK.script, __INSIGHTS_RENDER_CHECK.path]).then(() => {}, () => {});", + " }", + " return __view;", + ].join("\n"); + const patched = main + .replace(timeout, instrumentation) + .replace(returnStart, ' const __view = v_flex().id("insights-root").size_full()') + .replace(returnEnd, renderTail) + .replace(marker, `${injected}${marker}`); + writeFileSync(mainPath, patched); } mkdirSync(join(home, "layouts"), { recursive: true }); writeFileSync( @@ -314,6 +371,10 @@ try { if (finalStatus.code !== 0) throw new Error(finalStatus.stderr || "herdr-app status failed"); status = JSON.parse(finalStatus.stdout); + if (!(await waitForPath(markerPath, 5000))) { + throw new Error(`native async check never rendered requested ${requestedMode} state`); + } + if (status.layout !== "insights-async") throw new Error(`unexpected layout ${JSON.stringify(status.layout)}`); const errors = Array.isArray(status.errors) ? status.errors : []; if (errors.length) throw new Error(`Herdr reported mod errors: ${JSON.stringify(errors)}`); @@ -321,7 +382,7 @@ try { throw new Error("the app log contains the old interrupted promise continuation"); } writeFileSync(logPath, output); - console.log(`native async check passed: ${preparedRows} rows, no mod errors${appearance ? `, ${appearance}` : ""}${settingsMode ? ", settings" : ""}${menuMode ? ", menu" : ""}${zoomOut ? `, zoom out ${zoomOut}` : ""}`); + console.log(`native async check passed: ${preparedRows} rows, no mod errors, ${requestedMode} rendered${appearance ? `, ${appearance}` : ""}${settingsMode ? ", settings" : ""}${menuMode ? ", menu" : ""}${zoomOut ? `, zoom out ${zoomOut}` : ""}`); console.log(`status: layout=${status.layout} overlays=${status.overlays?.length ?? 0}`); console.log(`screenshot: ${shotPath}`); console.log(`log: ${logPath}`); diff --git a/herdr-insights/tests/native-loaded-check.mjs b/herdr-insights/tests/native-loaded-check.mjs index f69965c..edd7cd9 100644 --- a/herdr-insights/tests/native-loaded-check.mjs +++ b/herdr-insights/tests/native-loaded-check.mjs @@ -4,7 +4,9 @@ // `herdr-app check` normally renders the empty initial state, so it cannot see // a populated dashboard crossing the 50 ms render budget. This harness injects // an already-presented dashboard through applySnapshot in init, then checks the -// first render. The true exec -> Task -> timer lifecycle is covered separately +// first render. It deliberately replaces runtime resolution and refresh timers +// in the temporary mod copy, so no collector runs and no user transcript roots +// are scanned. The true exec -> Task -> timer lifecycle is covered separately // by tests/native-async-check.mjs. // // Usage: @@ -83,15 +85,23 @@ const snapshot = { ...baseSnapshot, rows }; const home = mkdtempSync(join(tmpdir(), "insights-native-loaded-")); try { + const fixtureRoot = join(home, "fixture-sessions"); + snapshot.config = { + ...(snapshot.config && typeof snapshot.config === "object" ? snapshot.config : {}), + sources: [{ id: "fixture", label: "Fixture", adapter: "pi", enabled: true, roots: [fixtureRoot] }], + }; + const mod = join(home, "mods/insights"); cpSync(modSource, mod, { recursive: true }); - writeFileSync(join(mod, "runtime.json"), `${JSON.stringify({ node: process.execPath })}\n`); const mainPath = join(mod, "main.js"); let main = readFileSync(mainPath, "utf8"); const constant = "const MIN_CELL = 12;"; + const runtimeCall = " this.resolveRuntime(cx);"; const init = " this.commandTask = cx.spawn(async (commandCx) => {"; - if (!main.includes(constant) || !main.includes(init)) throw new Error("cannot find main.js injection points"); + if (!main.includes(constant) || !main.includes(runtimeCall) || !main.includes(init)) { + throw new Error("cannot find main.js injection points"); + } const today = todayKey(); const options = { filters: { provider: "all", model: "all" }, @@ -112,6 +122,14 @@ try { error: null, }; main = main.replace(constant, `const INJECTED_PRESENTATION = ${JSON.stringify(presentation)};\n${constant}`); + main = main.replace( + runtimeCall, + " // Static render check: no runtime resolution, collector exec, or refresh timer.\n" + + " this.resolveRuntime = () => {};\n" + + " this.refresh = () => {};\n" + + " this.schedule = () => {};\n" + + " this.runtimeReady = true;\n", + ); let injected = " this.applySnapshot(INJECTED_PRESENTATION, cx);\n" + " if (!this.dashboard) throw new Error('Loaded check did not apply the populated dashboard');\n"; if (mode === "settings") injected += " this.openSettings(cx);\n"; @@ -127,7 +145,7 @@ try { const result = spawnSync("herdr-app", ["check", mod], { cwd: resolve(here, ".."), - env: { ...process.env, HERDR_APP_HOME: home }, + env: { ...process.env, HERDR_APP_HOME: home, HOME: home }, encoding: "utf8", timeout: 120_000, });