diff --git a/package/ego-browser/src/help-runtime.test.mjs b/package/ego-browser/src/help-runtime.test.mjs index 2716900d..f4e42939 100644 --- a/package/ego-browser/src/help-runtime.test.mjs +++ b/package/ego-browser/src/help-runtime.test.mjs @@ -5,7 +5,7 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { help, formatHelp } from "../dist/src/help-runtime.js"; +import { help, formatHelp, __setDocsForTests } from "../dist/src/help-runtime.js"; // Regression test for GitHub issue #84: the runtime used to build its docs map // by reading its own source, which produced an empty map whenever the SDK was @@ -42,6 +42,41 @@ test("formatHelp renders the signature for an embedded doc", () => { assert.ok(text.includes("click("), `expected signature in:\n${text}`); }); +test("help(name) falls back to the live function when embedded docs miss it", () => { + const live = async (selector, key = "Enter") => ({ selector, key }); + const doc = help({ live }, "live"); + assert.equal(typeof doc, "object"); + assert.equal(doc.name, "live"); + assert.ok(doc.signature.includes("live(")); + assert.ok(doc.async); + assert.notEqual(doc, "Unknown helper: live"); +}); + +test("help() lists live helpers when the embedded catalog is empty", () => { + __setDocsForTests("[]"); + try { + const list = help({ click: () => {}, waitFor: async () => {} }); + assert.ok(Array.isArray(list)); + assert.equal(list.length, 2); + assert.ok(list.some((d) => d.name === "click")); + assert.ok(list.some((d) => d.name === "waitFor")); + } finally { + __setDocsForTests(null); + } +}); + +test("help(name) still reports unknown when the helper is not live", () => { + __setDocsForTests("[]"); + try { + assert.equal( + help({}, "definitelyNotAHelper"), + "Unknown helper: definitelyNotAHelper", + ); + } finally { + __setDocsForTests(null); + } +}); + test("help works when the shipped bundle runs as an eval module", () => { // The app executes the SDK from an in-memory string, so its import.meta.url // ("file:///...[eval1]") is not a readable file — the exact condition that diff --git a/package/ego-browser/src/help-runtime.ts b/package/ego-browser/src/help-runtime.ts index 769bf208..bb92ce77 100644 --- a/package/ego-browser/src/help-runtime.ts +++ b/package/ego-browser/src/help-runtime.ts @@ -33,25 +33,17 @@ export function help( ): HelperDoc | HelperDoc[] | string { const docs = getDocsMap(); if (names.length === 0) { - const all = [...docs.values()].filter((d) => d.name in helpers); - return all; + const fromDocs = [...docs.values()].filter((d) => d.name in helpers); + if (fromDocs.length > 0) return fromDocs; + return listFallbackDocs(helpers); } if (names.length === 1) { - const doc = docs.get(names[0]); - if (!doc) return `Unknown helper: ${names[0]}`; + const name = names[0]; + const doc = docs.get(name) || fallbackDoc(name, helpers[name]); + if (!doc) return `Unknown helper: ${name}`; return doc; } - return names.map( - (n) => - docs.get(n) || { - name: n, - signature: n, - description: null, - params: [], - returns: null, - async: false, - }, - ); + return names.map((n) => docs.get(n) || fallbackDoc(n, helpers[n]) || emptyDoc(n)); } export function formatHelp(doc: HelperDoc): string { @@ -95,3 +87,68 @@ function parseEmbeddedDocs(raw: string): HelperDoc[] { return []; } } + +function listFallbackDocs(helpers: Record): HelperDoc[] { + return Object.keys(helpers) + .filter((name) => name !== "help" && typeof helpers[name] === "function") + .sort() + .map((name) => fallbackDoc(name, helpers[name])) + .filter((doc): doc is HelperDoc => Boolean(doc)); +} + +function fallbackDoc(name: string, value: unknown): HelperDoc | null { + if (typeof value !== "function") return null; + const src = Function.prototype.toString.call(value); + const isAsync = /^\s*async\b/.test(src); + const paramMatch = + src.match(/^(?:async\s+)?(?:function[\s\w$]*)?\s*\(([^)]*)\)/) || + src.match(/^(?:async\s*)?\(([^)]*)\)\s*=>/); + const rawParams = paramMatch?.[1]?.trim() ?? ""; + const paramNames = rawParams + ? rawParams + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + : []; + const params: ParamInfo[] = paramNames.map((part) => ({ + name: part.replace(/^\.\.\./, "").replace(/\s*=[\s\S]*$/, "") || part, + type: null, + description: null, + optional: part.includes("=") || part.startsWith("..."), + rest: part.startsWith("..."), + default: null, + })); + const paramSig = paramNames.join(", "); + const returns = isAsync ? "Promise<...>" : null; + return { + name, + signature: `${name}(${paramSig})${returns ? ` → ${returns}` : ""}`, + description: + "Available helper. Embedded help docs were empty, so this signature was recovered from the live function.", + params, + returns, + async: isAsync, + }; +} + +function emptyDoc(name: string): HelperDoc { + return { + name, + signature: name, + description: null, + params: [], + returns: null, + async: false, + }; +} + +export function __setDocsForTests(raw: string | null): void { + if (raw === null) { + cache = null; + return; + } + cache = new Map(); + for (const doc of parseEmbeddedDocs(raw)) { + cache.set(doc.name, doc); + } +}