mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(agent-readiness): WebMCP uses registerTool + static import (#3316)
isitagentready.com reported "No WebMCP tools detected on page load"
on prod. Two compounding bugs in PR #3356:
1) API shape mismatch. Deployed code calls
navigator.modelContext.provideContext({ tools }), but the scanner
SKILL and shipping Chrome implementation use
navigator.modelContext.registerTool(tool, { signal }) per tool with
AbortController-driven teardown. The older provideContext form is
kept as a fallback.
2) Dynamic-import timing. The webmcp module was lazy-loaded from a
deep init phase, so the chunk resolved after the scanner probe
window elapsed.
Fix:
- Rewrite registerWebMcpTools to prefer registerTool with an
AbortController. provideContext becomes a legacy fallback. Returns
the AbortController so teardown paths exist.
- Static-import webmcp in App.ts and call registerWebMcpTools
synchronously at the start of init, before any await. Bindings
close over lazy refs so throw-on-null guards still fire correctly
when a tool is invoked later.
Test additions lock in registerTool-precedes-provideContext ordering,
AbortController pattern, static import, and call-before-first-await.
* fix(agent-readiness): WebMCP readiness wait + teardown on destroy (#3316)
Addresses three findings on PR #3361.
P1 — startup race. Early registration is required for scanner probes,
but a tool invoked during the window between register and Phase-4 UI
init threw "Search modal is not initialised yet." Both scanners and
agents that probe-and-invoke hit this. Bindings now await a uiReady
promise that resolves after searchManager.init and countryIntel.init.
A 10s timeout keeps a broken init from hanging the caller. After
readiness, a still-null target is a real failure and still throws.
Mechanics: App constructor builds uiReady as a Promise with its
resolve stored on the instance; Phase-4 end calls resolveUiReady;
waitForUiReady races uiReady against a timeout; both bindings await it.
P2 — AbortController was returned and dropped. registerWebMcpTools
returns a controller so callers can unregister on teardown, but App
discarded it. Stored on App now and aborted in destroy, so test
harnesses and SPA re-inits don't accumulate stale registrations.
P2 — test coverage. Added assertions for: bindings await
waitForUiReady before accessing state; resolveUiReady fires after
countryIntel.init; waitForUiReady uses Promise.race with a timeout;
destroy aborts the stored controller. Kept silent-success guard
assertions so bindings still throw when state is absent post-readiness.
Tests: 16 webmcp, 6682 full suite, all green.
* test(webmcp): tighten init()/destroy() regex anchoring (#3316)
Addresses P2 from PR #3361 review. The init() and destroy() body
captures used lazy `[\s\S]+?\n }` which stops at the first
2-space-indent close brace. An intermediate `}` inside init (e.g.
some exotic scope block) would truncate the slice; the downstream
`.split(/\n\s+await\s/)` would then operate on a smaller string and
could let a refactor slip by without tripping the assertion.
Both regexes now end with a lookahead for the next class member
(`\n\n (?:public|private) `), so the capture spans the whole method
body regardless of internal braces. If the next-member anchor ever
breaks, the match returns null and the `assert.ok` guard fails
loudly instead of silently accepting a short capture.
P1 (AbortController silently dropped) was already addressed in
f3bbd2170 — `this.webMcpController` is stored and destroy() aborts
it. Greptile reviewed the first push.
This commit is contained in:
99
src/App.ts
99
src/App.ts
@@ -64,6 +64,7 @@ import { fetchBootstrapData, getBootstrapHydrationState, markBootstrapAsLive, ty
|
||||
import { describeFreshness } from '@/services/persistent-cache';
|
||||
import { DesktopUpdater } from '@/app/desktop-updater';
|
||||
import { CountryIntelManager } from '@/app/country-intel';
|
||||
import { registerWebMcpTools } from '@/services/webmcp';
|
||||
import { SearchManager } from '@/app/search-manager';
|
||||
import { RefreshScheduler } from '@/app/refresh-scheduler';
|
||||
import { PanelLayoutManager } from '@/app/panel-layout';
|
||||
@@ -112,6 +113,18 @@ export class App {
|
||||
private modules: { destroy(): void }[] = [];
|
||||
private unsubAiFlow: (() => void) | null = null;
|
||||
private unsubFreeTier: (() => void) | null = null;
|
||||
// Resolves once Phase-4 UI modules (searchManager, countryIntel) have
|
||||
// initialised so WebMCP bindings can await readiness before touching
|
||||
// the nullable UI targets. Avoids the startup race where an agent
|
||||
// discovers a tool via early registerTool and invokes it before the
|
||||
// target panel exists.
|
||||
private uiReady!: Promise<void>;
|
||||
private resolveUiReady!: () => void;
|
||||
// Returned by registerWebMcpTools when running in a registerTool-capable
|
||||
// browser — aborting it unregisters every tool. destroy() triggers it
|
||||
// so that test harnesses / same-document re-inits don't accumulate
|
||||
// duplicate registrations.
|
||||
private webMcpController: AbortController | null = null;
|
||||
private visiblePanelPrimed = new Set<string>();
|
||||
private visiblePanelPrimeRaf: number | null = null;
|
||||
private bootstrapHydrationState: BootstrapHydrationState = getBootstrapHydrationState();
|
||||
@@ -417,6 +430,10 @@ export class App {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) throw new Error(`Container ${containerId} not found`);
|
||||
|
||||
this.uiReady = new Promise<void>((resolve) => {
|
||||
this.resolveUiReady = resolve;
|
||||
});
|
||||
|
||||
const PANEL_ORDER_KEY = 'panel-order';
|
||||
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
|
||||
|
||||
@@ -803,6 +820,33 @@ export class App {
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const initStart = performance.now();
|
||||
|
||||
// WebMCP — register synchronously before any init awaits so agent
|
||||
// scanners (isitagentready.com, in-browser agents) find the tools on
|
||||
// their first probe. No-op in browsers without navigator.modelContext.
|
||||
// Bindings await `this.uiReady` (resolves after Phase-4 UI init) so
|
||||
// a tool invoked during the startup window waits for the target
|
||||
// panel to exist instead of throwing. A 10s timeout keeps a genuinely
|
||||
// broken state from hanging the caller. Store the returned controller
|
||||
// so destroy() can unregister every tool on teardown.
|
||||
this.webMcpController = registerWebMcpTools({
|
||||
openCountryBriefByCode: async (code, country) => {
|
||||
await this.waitForUiReady();
|
||||
if (!this.state.countryBriefPage) {
|
||||
throw new Error('Country brief panel is not initialised');
|
||||
}
|
||||
await this.countryIntel.openCountryBriefByCode(code, country);
|
||||
},
|
||||
resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
|
||||
openSearch: async () => {
|
||||
await this.waitForUiReady();
|
||||
if (!this.state.searchModal) {
|
||||
throw new Error('Search modal is not initialised');
|
||||
}
|
||||
this.state.searchModal.open();
|
||||
},
|
||||
});
|
||||
|
||||
await initDB();
|
||||
await initI18n();
|
||||
const aiFlow = getAiFlowSettings();
|
||||
@@ -1032,34 +1076,8 @@ export class App {
|
||||
this.searchManager.init();
|
||||
this.eventHandlers.setupMapLayerHandlers();
|
||||
this.countryIntel.init();
|
||||
|
||||
// WebMCP — expose a small set of UI tools to in-page agents.
|
||||
// Feature-detects `navigator.modelContext`; no-ops in browsers without it.
|
||||
// Registered after search+country modules so the bound callbacks have real
|
||||
// targets; tools intentionally route through the same paths a click takes
|
||||
// so paywall/auth gates still apply to agent invocations.
|
||||
//
|
||||
// Bindings throw when a required UI target is missing so the tool's
|
||||
// withInvocationLogging shim catches it and reports ok:false instead of
|
||||
// a misleading "Opened …" success — the underlying UI methods silently
|
||||
// no-op on null targets.
|
||||
void import('@/services/webmcp').then(({ registerWebMcpTools }) => {
|
||||
registerWebMcpTools({
|
||||
openCountryBriefByCode: async (code, country) => {
|
||||
if (!this.state.countryBriefPage) {
|
||||
throw new Error('Country brief panel is not initialised yet');
|
||||
}
|
||||
await this.countryIntel.openCountryBriefByCode(code, country);
|
||||
},
|
||||
resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
|
||||
openSearch: () => {
|
||||
if (!this.state.searchModal) {
|
||||
throw new Error('Search modal is not initialised yet');
|
||||
}
|
||||
this.state.searchModal.open();
|
||||
},
|
||||
});
|
||||
});
|
||||
// Unblock any WebMCP tool invocations that arrived during startup.
|
||||
this.resolveUiReady();
|
||||
|
||||
// Phase 5: Event listeners + URL sync
|
||||
this.eventHandlers.init();
|
||||
@@ -1212,6 +1230,31 @@ export class App {
|
||||
this.cachedModeBannerEl = null;
|
||||
this.state.map?.destroy();
|
||||
disconnectAisStream();
|
||||
// Unregister every WebMCP tool so a same-document re-init (tests,
|
||||
// HMR, SPA harness) doesn't leave the browser with stale bindings
|
||||
// pointing at a disposed App.
|
||||
this.webMcpController?.abort();
|
||||
this.webMcpController = null;
|
||||
}
|
||||
|
||||
// Waits for Phase-4 UI modules (searchManager + countryIntel) to finish
|
||||
// initialising. WebMCP bindings call this before touching nullable UI
|
||||
// state so a tool invoked during startup waits rather than throwing;
|
||||
// the timeout guards against a genuinely broken init path hanging the
|
||||
// agent forever.
|
||||
private async waitForUiReady(timeoutMs = 10_000): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`UI did not initialise within ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([this.uiReady, timeout]);
|
||||
} finally {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDeepLinks(): void {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// WebMCP — in-page agent tool surface.
|
||||
//
|
||||
// Registers a small set of tools via `navigator.modelContext.provideContext`
|
||||
// so that browsers implementing the draft WebMCP spec
|
||||
// (webmachinelearning.github.io/webmcp) can drive the site the same way a
|
||||
// human does. Tools MUST route through existing UI code paths so agents
|
||||
// inherit every auth/entitlement gate a browser user is subject to — they
|
||||
// are not a backdoor around the paywall.
|
||||
// Registers a small set of tools via `navigator.modelContext.registerTool`
|
||||
// so browsers implementing the WebMCP spec as shipped in Chrome
|
||||
// (developer.chrome.com/blog/webmcp-epp, webmachinelearning.github.io/webmcp)
|
||||
// can drive the site the same way a human does. Tools MUST route through
|
||||
// existing UI code paths so agents inherit every auth/entitlement gate a
|
||||
// browser user is subject to — they are not a backdoor around the paywall.
|
||||
//
|
||||
// Current tools mirror the static Agent Skills set (#3310) for consistency:
|
||||
// 1. openCountryBrief({ iso2 }) — opens the country deep-dive panel.
|
||||
@@ -14,6 +14,11 @@
|
||||
// The two v1 tools don't branch on auth state, so a single registration at
|
||||
// init time is correct. Any future Pro-only tool MUST re-register on
|
||||
// sign-in/sign-out (see feedback_reactive_listeners_must_be_symmetric.md).
|
||||
//
|
||||
// Scanner compatibility: isitagentready.com probes for
|
||||
// `navigator.modelContext.registerTool` invocations during initial page load.
|
||||
// Register synchronously from App.ts (no dynamic import, no init-phase
|
||||
// awaits) so the probe finds the tools before it gives up.
|
||||
|
||||
import { track } from './analytics';
|
||||
|
||||
@@ -36,7 +41,11 @@ interface WebMcpTool {
|
||||
}
|
||||
|
||||
interface WebMcpProvider {
|
||||
provideContext(ctx: { tools: WebMcpTool[] }): void;
|
||||
// Chrome-implemented form — one call per tool, unregistration via AbortSignal.
|
||||
registerTool?: (tool: WebMcpTool, options?: { signal?: AbortSignal }) => void;
|
||||
// Older editor-draft form — kept as a compatibility fallback for browsers
|
||||
// shipping the batch-registration shape. Harmless no-op when absent.
|
||||
provideContext?: (ctx: { tools: WebMcpTool[] }) => void;
|
||||
}
|
||||
|
||||
interface NavigatorWithWebMcp extends Navigator {
|
||||
@@ -46,7 +55,11 @@ interface NavigatorWithWebMcp extends Navigator {
|
||||
export interface WebMcpAppBindings {
|
||||
openCountryBriefByCode(code: string, country: string): Promise<void>;
|
||||
resolveCountryName(code: string): string;
|
||||
openSearch(): void;
|
||||
// Returns a Promise because implementations may await a readiness signal
|
||||
// (e.g. waiting for the search modal to exist during startup) before
|
||||
// dispatching. Tool executes must `await` it so rejections surface to
|
||||
// withInvocationLogging's catch path.
|
||||
openSearch(): void | Promise<void>;
|
||||
}
|
||||
|
||||
const ISO2 = /^[A-Z]{2}$/;
|
||||
@@ -109,7 +122,7 @@ export function buildWebMcpTools(app: WebMcpAppBindings): WebMcpTool[] {
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: withInvocationLogging('openSearch', async () => {
|
||||
app.openSearch();
|
||||
await app.openSearch();
|
||||
return textResult('Opened search palette.');
|
||||
}),
|
||||
},
|
||||
@@ -118,13 +131,31 @@ export function buildWebMcpTools(app: WebMcpAppBindings): WebMcpTool[] {
|
||||
|
||||
// Registers tools with the browser's WebMCP provider, if present.
|
||||
// Safe to call on every load: no-op in browsers without `navigator.modelContext`.
|
||||
// Returns true if registration actually happened (for tests / telemetry).
|
||||
export function registerWebMcpTools(app: WebMcpAppBindings): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
// Returns an AbortController whose `.abort()` tears down every registration
|
||||
// (for the `registerTool` path); null when WebMCP is absent or only the
|
||||
// legacy `provideContext` form is available (no per-call teardown in that shape).
|
||||
export function registerWebMcpTools(app: WebMcpAppBindings): AbortController | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
const provider = (navigator as NavigatorWithWebMcp).modelContext;
|
||||
if (!provider || typeof provider.provideContext !== 'function') return false;
|
||||
if (!provider) return null;
|
||||
|
||||
const tools = buildWebMcpTools(app);
|
||||
provider.provideContext({ tools });
|
||||
track('webmcp-registered', { toolCount: tools.length });
|
||||
return true;
|
||||
|
||||
// Chrome-implemented form — preferred, and the shape isitagentready.com scans for.
|
||||
if (typeof provider.registerTool === 'function') {
|
||||
const controller = new AbortController();
|
||||
for (const tool of tools) {
|
||||
provider.registerTool(tool, { signal: controller.signal });
|
||||
}
|
||||
track('webmcp-registered', { toolCount: tools.length, api: 'registerTool' });
|
||||
return controller;
|
||||
}
|
||||
|
||||
// Older editor-draft form — batch registration, no per-call teardown.
|
||||
if (typeof provider.provideContext === 'function') {
|
||||
provider.provideContext({ tools });
|
||||
track('webmcp-registered', { toolCount: tools.length, api: 'provideContext' });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,18 +15,29 @@ const WEBMCP_PATH = resolve(ROOT, 'src/services/webmcp.ts');
|
||||
const src = readFileSync(WEBMCP_PATH, 'utf-8');
|
||||
|
||||
describe('webmcp.ts: draft-spec contract', () => {
|
||||
it('feature-detects navigator.modelContext before calling provideContext', () => {
|
||||
// The detection gate must run before any call. If a future refactor
|
||||
// inverts the order, this regex stops matching and fails.
|
||||
it('prefers registerTool (Chrome-implemented form) over provideContext (legacy)', () => {
|
||||
// isitagentready.com scans for navigator.modelContext.registerTool calls.
|
||||
// The registerTool branch must come first; provideContext is a legacy
|
||||
// fallback. If a future refactor inverts order, the scanner will miss us.
|
||||
const registerIdx = src.search(/typeof provider\.registerTool === 'function'/);
|
||||
const provideIdx = src.search(/typeof provider\.provideContext === 'function'/);
|
||||
assert.ok(registerIdx >= 0, 'registerTool branch missing');
|
||||
assert.ok(provideIdx >= 0, 'provideContext fallback missing');
|
||||
assert.ok(
|
||||
registerIdx < provideIdx,
|
||||
'registerTool must be checked before provideContext (Chrome-impl form is the primary target)',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses AbortController for registerTool teardown (draft-spec pattern)', () => {
|
||||
assert.match(
|
||||
src,
|
||||
/typeof provider\.provideContext !== 'function'\) return false[\s\S]+?provider\.provideContext\(/,
|
||||
'feature detection must short-circuit before provideContext is invoked',
|
||||
/const controller = new AbortController\(\)[\s\S]+?provider\.registerTool\(tool, \{ signal: controller\.signal \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('guards against non-browser runtimes (navigator undefined)', () => {
|
||||
assert.match(src, /typeof navigator === 'undefined'\) return false/);
|
||||
assert.match(src, /typeof navigator === 'undefined'\) return null/);
|
||||
});
|
||||
|
||||
it('ships at least two tools (acceptance criterion: >=2 tools)', () => {
|
||||
@@ -76,31 +87,116 @@ describe('webmcp.ts: tool behaviour (source-level invariants)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// App.ts wiring — guards against silent-success bugs where a binding
|
||||
// forwards to a nullable UI target whose no-op the tool then falsely
|
||||
// reports as success. Bindings MUST throw when the target is absent
|
||||
// so withInvocationLogging's catch path can return isError:true.
|
||||
describe('webmcp App.ts binding: guard against silent success', () => {
|
||||
// App.ts wiring — guards against two classes of bug:
|
||||
// (1) Silent success when a binding forwards to a nullable UI target.
|
||||
// (2) Startup race when a tool is invoked during the window between
|
||||
// early registration (needed for scanners) and Phase-4 UI init.
|
||||
// Bindings await a readiness signal before touching UI state and fall
|
||||
// through to a throw if the signal never resolves; withInvocationLogging
|
||||
// converts that throw into isError:true.
|
||||
describe('webmcp App.ts binding: readiness + teardown', () => {
|
||||
const appSrc = readFileSync(resolve(ROOT, 'src/App.ts'), 'utf-8');
|
||||
const bindingBlock = appSrc.match(
|
||||
/registerWebMcpTools\(\{[\s\S]+?\}\);\s*\}\);/,
|
||||
/registerWebMcpTools\(\{[\s\S]+?\}\);/,
|
||||
);
|
||||
|
||||
it('the WebMCP binding block exists in App.ts init', () => {
|
||||
assert.ok(bindingBlock, 'could not locate registerWebMcpTools(...) in App.ts');
|
||||
});
|
||||
|
||||
it('openSearch binding throws when searchModal is absent', () => {
|
||||
it('is imported statically (not via dynamic import)', () => {
|
||||
// Scanner timing: dynamic import defers registration past the probe
|
||||
// window. A static import lets the synchronous call at init-start run
|
||||
// before any await in init(), catching the first scanner probe.
|
||||
assert.match(
|
||||
appSrc,
|
||||
/^import \{ registerWebMcpTools \} from '@\/services\/webmcp';$/m,
|
||||
'registerWebMcpTools must be imported statically',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
appSrc,
|
||||
/import\(['"]@\/services\/webmcp['"]\)/,
|
||||
"no dynamic import('@/services/webmcp') — defers past scanner probe window",
|
||||
);
|
||||
});
|
||||
|
||||
it('is called before the first await in init()', () => {
|
||||
// Anchor the end of the capture to the NEXT class-level member
|
||||
// (public/private) so an intermediate 2-space-indent `}` inside
|
||||
// init() can't truncate the body. A lazy `[\s\S]+?\n }` match
|
||||
// would stop at the first such closing brace and silently shrink
|
||||
// the slice we search for the pre-await pattern.
|
||||
const initBody = appSrc.match(
|
||||
/public async init\(\): Promise<void> \{([\s\S]*?)\n \}(?=\n\n (?:public|private) )/,
|
||||
);
|
||||
assert.ok(initBody, 'could not locate init() body (anchor to next class member missing)');
|
||||
const preAwait = initBody[1].split(/\n\s+await\s/, 2)[0];
|
||||
assert.match(
|
||||
preAwait,
|
||||
/registerWebMcpTools\(/,
|
||||
'registerWebMcpTools must be invoked before the first await in init()',
|
||||
);
|
||||
});
|
||||
|
||||
it('both bindings await the UI-readiness signal before touching state', () => {
|
||||
// Before-fix regression: openSearch threw immediately on first
|
||||
// invocation during startup. Both bindings must wait for Phase-4
|
||||
// UI init to complete, then check the state, then dispatch.
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openSearch:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.searchModal/,
|
||||
'openSearch must await waitForUiReady() before accessing searchModal',
|
||||
);
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openCountryBriefByCode:[\s\S]+?await this\.waitForUiReady\(\)[\s\S]+?this\.state\.countryBriefPage/,
|
||||
'openCountryBriefByCode must await waitForUiReady() before accessing countryBriefPage',
|
||||
);
|
||||
});
|
||||
|
||||
it('bindings still throw (not silently succeed) when state is absent after readiness', () => {
|
||||
// The silent-success guard from PR #3356 review must survive the
|
||||
// readiness refactor. After awaiting readiness, a missing target is
|
||||
// a real failure — throw so withInvocationLogging returns isError.
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openSearch:[\s\S]+?if \(!this\.state\.searchModal\)[\s\S]+?throw new Error/,
|
||||
);
|
||||
});
|
||||
|
||||
it('openCountryBriefByCode binding throws when countryBriefPage is absent', () => {
|
||||
assert.match(
|
||||
bindingBlock[0],
|
||||
/openCountryBriefByCode:[\s\S]+?if \(!this\.state\.countryBriefPage\)[\s\S]+?throw new Error/,
|
||||
);
|
||||
});
|
||||
|
||||
it('uiReady is resolved after Phase-4 UI modules initialise', () => {
|
||||
// waitForUiReady() hangs forever if nothing ever resolves uiReady.
|
||||
// The resolve must live right after countryIntel.init() so that all
|
||||
// Phase-4 modules are ready by the time waiters unblock.
|
||||
assert.match(
|
||||
appSrc,
|
||||
/this\.countryIntel\.init\(\);[\s\S]{0,200}this\.resolveUiReady\(\)/,
|
||||
'resolveUiReady() must fire after countryIntel.init() in Phase 4',
|
||||
);
|
||||
});
|
||||
|
||||
it('waitForUiReady enforces a timeout so a broken init cannot hang the agent', () => {
|
||||
assert.match(
|
||||
appSrc,
|
||||
/private async waitForUiReady\(timeoutMs = [\d_]+\)[\s\S]+?Promise\.race\(\[this\.uiReady/,
|
||||
);
|
||||
});
|
||||
|
||||
it('destroy() aborts the WebMCP controller so re-inits do not duplicate registrations', () => {
|
||||
// Same anchoring as init() — end at the next class member so an
|
||||
// intermediate 2-space-indent close brace can't truncate the capture.
|
||||
const destroyBody = appSrc.match(
|
||||
/public destroy\(\): void \{([\s\S]*?)\n \}(?=\n\n (?:public|private) )/,
|
||||
);
|
||||
assert.ok(destroyBody, 'could not locate destroy() body (anchor to next class member missing)');
|
||||
assert.match(
|
||||
destroyBody[1],
|
||||
/this\.webMcpController\?\.abort\(\)/,
|
||||
'destroy() must abort the stored WebMCP AbortController',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user