Files
codegraph/__tests__/browser-host-globals.test.ts
T
Colby MchenryandClaude Opus 5.5 f1da755a91 fix(resolution): calls on the browser's own objects link no project method (#3026)
`window.open('/help')`, `localStorage.getItem('token')`,
`location.reload()` and `history.back()` linked to a project class's
method of the same name, like an app's `WindowService::open` or
`StorageService::getItem`, so callers and impact on such a wrapper
listed every direct browser call in the app. Three readings did it:
- an untyped receiver went to matchMethodCall's guesses, by the
  receiver's word (`window` ~ WindowService) or a lone method name;
- a chain from `window`, which the extractor records by its bare method
  name so a project object hung on `window` can be found (#1707), fell to
  the exact-name match when the chain names no project object:
  `window.localStorage.getItem()`;
- a JS `self.close()` was read as `this.close()` by bareCallReceiver and
  callReceiverKind, though a `self` the file doesn't bind is the global
  object (a worker's scope, or `window.self`).

Now, in the JS family:
- a call on one of JS_BROWSER_HOST_GLOBALS (window, self, globalThis,
  document, localStorage, sessionStorage, location, history, navigator,
  screen, performance) gets no edge when the file writes that name only
  as a receiver (`document.x`, `history.back()`, `self?.close()`) or after
  `typeof`, decided before the receiver-type reader, whose backward scan
  has no scope. Any other mention (a parameter, destructured ones
  included, a declaration, an import, an argument) keeps the name the
  file's own: outline passes its Document model as `({ document }:
  Props)`, and those `document.save()` calls keep their links;
- matchCollapsedObjectCall declines a `window`/`globalThis` chain whose
  first link is one of them, after its project-object lookup; a chain
  through a project object (`window.App.init()`) is unchanged;
- an unbound `self` reaches matchCollapsedObjectCall as
  `globalThis.self`, and callReceiverKind no longer counts it as the
  instance. A `self` the file binds (`var self = this`) reads as before.

Validation: new browser-host-globals tests (4) pass with the fix. On
main the first fails with 8 wrong edges and the third (outline's
destructured `document`, a `typeof window` guard) with 2; the two that
pin what must keep working (a bound, imported or parameter name; `this`,
`var self = this`, `window.App`) pass on both. A first version that
asked only whether the file binds the name dropped outline's
destructured `document` calls; the third test fails against it.
A/B against main: only removals. excalidraw 12 (`window.open` ->
Portal::open, `document.createElement` -> DraggableTool::createElement,
a worker's `self.postMessage` -> WorkerPool::postMessage), outline 21
(test and app `sessionStorage`/`localStorage` calls -> MemoryStorage,
`window.document.body.*`, `window.history.back()` -> a Props property),
bitwarden apps/browser + apps/web 7 (`window.postMessage`,
`window.location.reload()`, `window.navigator.permissions.query()`), and
express unchanged. 39 of the 40 linked a project method by name only;
the 40th linked outline's own `declare global` typing of a browser API
(Document.modelContext.registerTool).

Reimplements PR #2915 by @KissFeng.

Fixes #2907

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:57:27 +00:00

188 lines
7.0 KiB
TypeScript

/**
* Calls on the browser's own objects link to no project method (#2907).
*
* `window.open('/help')`, `localStorage.getItem('token')`, `location.reload()`
* and `history.back()` are DOM calls, yet each linked to a project class's
* method of the same name — an app's `WindowService.open`, `StorageService.
* getItem`, `PageService.back` — so `callers` and impact on such a wrapper
* listed every direct DOM call in the app. Three readings did it: a receiver
* with no type was guessed by its word or a lone method name; a chain from
* `window` (`window.localStorage.getItem()`), recorded by its bare method name
* so a project global hung on `window` can be found (#1707), fell to the
* exact-name match; and a JS `self.close()` was read as `this.close()`,
* though an unbound `self` is the global object.
*
* A name the file binds or imports is still its own, and `this` is still the
* instance.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let dirs: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
graphs = [];
dirs = [];
});
async function index(files: Record<string, string>): Promise<CodeGraph> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-host-globals-'));
dirs.push(dir);
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), content);
}
const cg = await CodeGraph.init(dir, { index: true });
graphs.push(cg);
return cg;
}
/** `caller -> target` for every call edge out of `fromFile`, by qualified name. */
function callsFrom(cg: CodeGraph, fromFile: string): string[] {
return cg
.getNodesInFile(fromFile)
.flatMap((n) =>
cg
.getOutgoingEdges(n.id)
.filter((e) => e.kind === 'calls')
.map((e) => `${n.qualifiedName} -> ${cg.getNode(e.target)?.qualifiedName}`)
)
.sort();
}
const SERVICES = [
'export class WindowService {',
' open(url: string): void {}',
' close(): void {}',
' postMessage(m: unknown): void {}',
'}',
'export class StorageService {',
' getItem(key: string): string | null { return null; }',
'}',
'export class PageService {',
' reload(): void {}',
' back(): void {}',
'}',
'',
].join('\n');
describe('calls on browser host objects', () => {
it('link no project method, written directly or through window or self', async () => {
const cg = await index({
'src/services.ts': SERVICES,
'src/browser.ts': [
"export function openHelp(): void { window.open('/help'); }",
"export function readToken(): string | null { return localStorage.getItem('token'); }",
'export function refresh(): void { location.reload(); }',
'export function goBack(): void { history.back(); }',
"export function readTokenChained(): string | null { return window.localStorage.getItem('token'); }",
'export function goBackChained(): void { window.history.back(); }',
'export function closeSelf(): void { self.close(); }',
'export class Popup {',
' dismiss(): void { self.close(); }',
' close(): void {}',
'}',
'',
].join('\n'),
'src/worker.js': "self.postMessage('ready');\nexport function reply(x) { self.postMessage(x); }\n",
});
expect(callsFrom(cg, 'src/browser.ts')).toEqual([]);
expect(callsFrom(cg, 'src/worker.js')).toEqual([]);
});
it('still link a name the file binds, imports or takes as a parameter', async () => {
const cg = await index({
'src/router.ts': [
'export class Router { forward(): void {} }',
'export function createRouter(): Router { return new Router(); }',
'',
].join('\n'),
'src/nav.ts': 'export const history = { push(to: string): void {} };\n',
'src/loc.ts': 'export class Loc { reload(): void {} }\n',
'src/uses.ts': [
"import { createRouter } from './router';",
"import { history } from './nav';",
"import { Loc } from './loc';",
'export function local(): void {',
' const navigator = createRouter();',
' navigator.forward();',
'}',
"export function imported(): void { history.push('/x'); }",
'export function param(location: Loc): void { location.reload(); }',
'',
].join('\n'),
});
expect(callsFrom(cg, 'src/uses.ts')).toEqual([
'imported -> history::push',
'local -> Router::forward',
'local -> createRouter',
'param -> Loc::reload',
]);
});
it('still link a destructured parameter named like one, and decline after a `typeof` guard', async () => {
// outline's commands and scenes take their Document model as `({ document }: Props)`.
const cg = await index({
'src/models/Model.ts': 'export class Model {\n save(): void {}\n}\n',
'src/models/Document.ts': "import Model from './Model';\nexport default class Document extends Model {\n save(): void {}\n archive(): void {}\n}\n",
'src/services.ts': SERVICES,
'src/DocumentPublish.tsx': [
"import type Document from './models/Document';",
'',
'type Props = {',
' /** The document to publish. */',
' document: Document;',
'};',
'',
'export function DocumentPublish({ document }: Props) {',
' const publish = async () => {',
' await document.save();',
' };',
' return publish;',
'}',
'',
].join('\n'),
'src/help.ts': [
'export function openHelp(): void {',
" if (typeof window !== 'undefined') window.open('/help');",
'}',
'export function goBack(nav: { canGoBack: boolean }): void {',
' if (nav.canGoBack && window.history.length > 1) history.back();',
'}',
'',
].join('\n'),
});
expect(callsFrom(cg, 'src/DocumentPublish.tsx')).toEqual(['DocumentPublish::publish -> Document::save']);
expect(callsFrom(cg, 'src/help.ts')).toEqual([]);
});
it('still read `this`, a `self` the file binds to it, and a project object hung on window', async () => {
const cg = await index({
'src/dialog.js': [
'export class Dialog {',
' close() {}',
' open() { this.close(); }',
' later() { var self = this; setTimeout(function () { self.close(); }); }',
'}',
'export const store = {',
' close() {},',
' open() { this.close(); },',
'};',
'',
].join('\n'),
'src/app.js': 'window.App = {\n init() {},\n};\nexport function boot() { window.App.init(); }\n',
});
expect(callsFrom(cg, 'src/dialog.js')).toEqual([
'Dialog::later -> Dialog::close',
'Dialog::open -> Dialog::close',
'store::open -> store::close',
]);
expect(callsFrom(cg, 'src/app.js')).toEqual(['boot -> window.App::init']);
});
});