Files
codegraph/__tests__/arkts-resolution.test.ts
Colby Mchenry 0a9f3de72c fix: make the full test suite pass on Windows (#2053)
* test: close resolution graphs before removing Windows fixtures

Close ArkTS and JVM graph connections in afterEach, including failed assertions. Verified both files on Windows 11 and macOS (32 tests).

* test: compare explore source lines across LF and CRLF checkouts

Normalize only line endings when matching emitted source against checked-out fixtures. Preserve the closure coverage and whole-line assertions. Both files pass on Windows 11 and macOS (15 tests).

* test: exercise old Git fallback without a POSIX shell shim

Inject only the unsupported ls-files invocation and run every other Git command against real repositories. Assert that each rejected call retries, use portable graph paths, and clean up fixtures. Passes on Windows 11 and macOS.

* test: normalize Python fixture before generating CRLF cases

The tests added in #2004 produced CRCRLF from a Windows checkout. Normalize the input to LF before constructing either variant. All 28 native and WASM cases pass on Windows 11 and macOS.

* test: allow time for eviction fixture indexes on Windows

The #2036 eviction test builds and reconciles nine real indexes and consistently exceeds its default five-second timeout on the VM. Allow 15 seconds for this test alone, preserving every assertion. The full file passes on Windows 11 and macOS (21 tests).

* test: finish MCP processes before resetting Windows fixtures

Launch actual servers with their runtime flags and await termination before directory removal. Use asynchronous removal retries for daemon fixtures. Establish a ready daemon and stop its first proxy before simulating PID reuse, then make source stale without opening a competing SQLite writer. Preserve lock and database byte-equality assertions. All touched files validated on Windows 11 and macOS.

* test: allow Windows named-pipe fallback polling to finish

The VM takes up to 25.7 seconds to complete the nominal six-second retry loop. Allow 30 seconds for the three fallback waits and 40 seconds for the two shorter enclosing tests, retaining their lock-preservation and read-only assertions. The full daemon file is validated on Windows 11 and macOS.

* fix(daemon): retry transient Windows PID-file sharing violations

A transient EPERM replacing daemon.pid aborted startup, observed in the quiet-client test and reproduced with a real locked handle at ba3c21e5. Retry only Windows sharing errors with five bounded delays, rechecking ownership each time and cleaning temporary files. Keep publication synchronous before client processing. Cover transient/permanent failures, changed ownership, non-retryable errors, and a real Windows handle; require a confirmed daemon round-trip before measuring a quiet session. Validated on Windows 11 and macOS.
2026-09-27 16:26:10 +00:00

447 lines
16 KiB
TypeScript

/**
* ArkTS end-to-end resolution tests.
*
* Pins the precision contract for build()-DSL attribute chains: a chained
* `.attr(...)` resolves ONLY to a decorator-marked attribute helper
* (`@Extend`/`@Styles`/…) — a framework attribute like `.width(...)` must
* NEVER link to an arbitrary same-named symbol elsewhere in the project
* (measured on the OpenHarmony samples monorepo, that fallthrough produced
* 36k wrong edges — single properties with thousands of false callers).
*
* Also pins the ohpm workspace bridge: a bare `import { X } from "data"`
* follows the oh-package.json5 `file:` dependency to the member module.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
describe('ArkTS attribute-chain resolution precision', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links .titleStyle() to the @Extend helper but never .width() to a decoy symbol', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkts-'));
fs.mkdirSync(path.join(tmpDir, 'pages'));
fs.mkdirSync(path.join(tmpDir, 'decoy'));
// A decoy: symbols named after framework attributes, in another file.
fs.writeFileSync(
path.join(tmpDir, 'decoy/Decoy.ets'),
'export class Decoy {\n' +
' width: number = 0;\n' +
'}\n' +
'export function height(v: number): number {\n' +
' return v * 2;\n' +
'}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'pages/Home.ets'),
'@Extend(Text) function titleStyle(size: number) {\n' +
' .fontSize(size)\n' +
'}\n' +
'\n' +
'@Component\n' +
'struct Home {\n' +
' build() {\n' +
' Column() {\n' +
' Text("hello")\n' +
' .titleStyle(24)\n' +
' .width(100)\n' +
' }\n' +
' .height(50)\n' +
' }\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const fns = cg.getNodesByKind('function');
const titleStyle = fns.find((n) => n.name === 'titleStyle');
expect(titleStyle).toBeDefined();
expect(titleStyle?.decorators).toContain('Extend');
const structs = cg.getNodesByKind('struct');
const home = structs.find((n) => n.name === 'Home');
expect(home).toBeDefined();
// build -> titleStyle via the decorator-gated attribute strategy.
const methods = cg.getNodesByKind('method');
const build = methods.find((n) => n.qualifiedName === 'Home::build');
expect(build).toBeDefined();
const buildCallees = cg.getOutgoingEdges(build!.id).map((e) => e.target);
expect(buildCallees).toContain(titleStyle!.id);
// The decoys named after framework attributes must have NO callers.
const decoyWidth = cg
.getNodesByKind('property')
.find((n) => n.name === 'width' && n.filePath.includes('Decoy'));
expect(decoyWidth).toBeDefined();
expect(cg.getIncomingEdges(decoyWidth!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
const decoyHeight = fns.find((n) => n.name === 'height' && n.filePath.includes('Decoy'));
expect(decoyHeight).toBeDefined();
expect(cg.getIncomingEdges(decoyHeight!.id).filter((e) => e.kind === 'calls')).toHaveLength(0);
});
});
describe('ArkTS ohpm workspace import resolution', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('resolves a bare workspace import through oh-package.json5 file: deps', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-'));
fs.mkdirSync(path.join(tmpDir, 'core/data/src/main/ets'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'feature/goods/src/main/ets'), { recursive: true });
// Member module "data" with an Index.ets barrel (ohpm entry convention).
fs.writeFileSync(
path.join(tmpDir, 'core/data/oh-package.json5'),
'{\n // ohpm module manifest\n "name": "data",\n "main": "Index.ets",\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/Index.ets'),
"export { CartRepository } from './src/main/ets/CartRepository';\n"
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/src/main/ets/CartRepository.ets'),
'export class CartRepository {\n' +
' addToCart(id: string): void {\n' +
' console.log(id);\n' +
' }\n' +
'}\n'
);
// Consumer module declares the sibling via a file: dependency and imports
// it by bare name.
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/oh-package.json5'),
'{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data", // local module\n },\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/src/main/ets/GoodsViewModel.ets'),
'import { CartRepository } from "data";\n' +
'\n' +
'export class GoodsViewModel {\n' +
' private cart: CartRepository = new CartRepository();\n' +
'\n' +
' add(id: string): void {\n' +
' this.cart.addToCart(id);\n' +
' }\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const classes = cg.getNodesByKind('class');
const repo = classes.find((n) => n.name === 'CartRepository');
const vm = classes.find((n) => n.name === 'GoodsViewModel');
expect(repo).toBeDefined();
expect(vm).toBeDefined();
// add() -> addToCart() across the module boundary.
const methods = cg.getNodesByKind('method');
const add = methods.find((n) => n.qualifiedName === 'GoodsViewModel::add');
const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart');
expect(add).toBeDefined();
expect(addToCart).toBeDefined();
const targets = cg.getOutgoingEdges(add!.id).map((e) => e.target);
expect(targets).toContain(addToCart!.id);
});
});
describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links assigning methods to build(), but not read-only methods', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-state-'));
fs.writeFileSync(
path.join(tmpDir, 'Page.ets'),
'@Entry\n@Component\nstruct Page {\n' +
' @State todos: string[] = [];\n' +
' @State count: number = 0;\n' +
'\n' +
' addTodo(t: string): void {\n' +
' this.todos.push(t);\n' +
' }\n' +
'\n' +
' reset(): void {\n' +
' this.count = 0;\n' +
' }\n' +
'\n' +
' describeCount(): string {\n' +
' return `count is ${this.count}`;\n' +
' }\n' +
'\n' +
' build() {\n' +
' Column() {\n' +
' Text(this.describeCount())\n' +
' }\n' +
' }\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const methods = cg.getNodesByKind('method');
const build = methods.find((n) => n.qualifiedName === 'Page::build')!;
const addTodo = methods.find((n) => n.qualifiedName === 'Page::addTodo')!;
const reset = methods.find((n) => n.qualifiedName === 'Page::reset')!;
const describeCount = methods.find((n) => n.qualifiedName === 'Page::describeCount')!;
const synthEdgesTo = (from: string) =>
cg
.getOutgoingEdges(from)
.filter(
(e) =>
e.target === build.id &&
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-state'
);
// Array mutator and plain assignment both count as state writes.
expect(synthEdgesTo(addTodo.id)).toHaveLength(1);
expect(synthEdgesTo(reset.id)).toHaveLength(1);
// A read-only method gets NO re-render edge — the precision line.
expect(synthEdgesTo(describeCount.id)).toHaveLength(0);
});
});
describe('ArkUI @ohos.events.emitter bridge', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links emit → on through a shared named constant, chased through a local EventsId', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter-'));
fs.writeFileSync(
path.join(tmpDir, 'Bus.ets'),
"import emitter from '@ohos.events.emitter';\n" +
'\n' +
'export class EmitterConst {\n' +
' static readonly ADD_EVENT_ID: number = 2;\n' +
'}\n' +
'\n' +
'class EventsId {\n' +
' eventId: number;\n' +
' constructor(eventId: number) {\n' +
' this.eventId = eventId;\n' +
' }\n' +
'}\n' +
'\n' +
'export class Bus {\n' +
' subscribeCart(callback: Function): void {\n' +
' let addGoodDataId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
' emitter.on(addGoodDataId, (eventData) => {\n' +
' callback(eventData);\n' +
' });\n' +
' }\n' +
'\n' +
' publishAdd(goodId: number): void {\n' +
' let addToCartId: EventsId = new EventsId(EmitterConst.ADD_EVENT_ID);\n' +
' emitter.emit(addToCartId);\n' +
' }\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const methods = cg.getNodesByKind('method');
const publishAdd = methods.find((n) => n.qualifiedName === 'Bus::publishAdd')!;
const subscribeCart = methods.find((n) => n.qualifiedName === 'Bus::subscribeCart')!;
const bridged = cg
.getOutgoingEdges(publishAdd.id)
.filter(
(e) =>
e.target === subscribeCart.id &&
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-emitter'
);
expect(bridged).toHaveLength(1);
});
it('numeric-literal event ids never pair across files', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-emitter2-'));
fs.writeFileSync(
path.join(tmpDir, 'A.ets'),
"import emitter from '@ohos.events.emitter';\n" +
'export function fireA(): void {\n' +
' emitter.emit({ eventId: 1 });\n' +
'}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'B.ets'),
"import emitter from '@ohos.events.emitter';\n" +
'export function listenB(): void {\n' +
' emitter.on({ eventId: 1 }, () => {});\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const fns = cg.getNodesByKind('function');
const fireA = fns.find((n) => n.name === 'fireA')!;
const listenB = fns.find((n) => n.name === 'listenB')!;
const bridged = cg
.getOutgoingEdges(fireA.id)
.filter((e) => e.target === listenB.id);
expect(bridged).toHaveLength(0);
});
});
describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('links the navigating method to the target page struct, standard layout only', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-arkui-router-'));
fs.mkdirSync(path.join(tmpDir, 'entry/src/main/ets/pages'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'entry/src/main/ets/pages/Detail.ets'),
'@Entry\n@Component\nstruct Detail {\n build() {\n Column() {\n Text("detail")\n }\n }\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'entry/src/main/ets/pages/Home.ets'),
"import router from '@ohos.router';\n" +
'\n' +
'@Entry\n@Component\nstruct Home {\n' +
' openDetail(id: string): void {\n' +
" router.pushUrl({ url: 'pages/Detail', params: { id: id } });\n" +
' }\n' +
'\n' +
' build() {\n' +
' Column() {\n' +
" Button('go').onClick(this.openDetail)\n" +
' }\n' +
' }\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const methods = cg.getNodesByKind('method');
const openDetail = methods.find((n) => n.qualifiedName === 'Home::openDetail')!;
const detail = cg.getNodesByKind('struct').find((n) => n.name === 'Detail')!;
const bridged = cg
.getOutgoingEdges(openDetail.id)
.filter(
(e) =>
e.target === detail.id &&
(e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'arkui-route'
);
expect(bridged).toHaveLength(1);
});
});
describe('ohpm main entry (custom barrel + .ts consumer)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
it('resolves a bare import through a custom main, from an .ets AND a .ts consumer', async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ohpm-main-'));
fs.mkdirSync(path.join(tmpDir, 'core/data/src'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, 'feature/goods/src'), { recursive: true });
// Custom entry — NOT the Index.ets convention.
fs.writeFileSync(
path.join(tmpDir, 'core/data/oh-package.json5'),
'{\n "name": "data",\n "main": "src/entry.ets",\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/src/entry.ets'),
"export { CartRepository } from './CartRepository';\n"
);
fs.writeFileSync(
path.join(tmpDir, 'core/data/src/CartRepository.ets'),
'export class CartRepository {\n addToCart(id: string): void {\n console.log(id);\n }\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/oh-package.json5'),
'{\n "name": "goods",\n "dependencies": {\n "data": "file:../../core/data",\n },\n}\n'
);
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/src/GoodsVm.ets'),
'import { CartRepository } from "data";\n' +
'export class GoodsVm {\n' +
' private cart: CartRepository = new CartRepository();\n' +
' add(id: string): void {\n this.cart.addToCart(id);\n }\n' +
'}\n'
);
// The .ts consumer — resolves through the manifest's entry, no `.ets`
// in the TypeScript candidate list required.
fs.writeFileSync(
path.join(tmpDir, 'feature/goods/src/report.ts'),
'import { CartRepository } from "data";\n' +
'export function report(cart: CartRepository): string {\n' +
' return typeof cart;\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
const classes = cg.getNodesByKind('class');
const repo = classes.find((n) => n.name === 'CartRepository')!;
expect(repo).toBeDefined();
// .ets consumer: cross-module method call connects.
const methods = cg.getNodesByKind('method');
const add = methods.find((n) => n.qualifiedName === 'GoodsVm::add')!;
const addToCart = methods.find((n) => n.qualifiedName === 'CartRepository::addToCart')!;
expect(cg.getOutgoingEdges(add.id).map((e) => e.target)).toContain(addToCart.id);
// .ts consumer: the type annotation reference reaches the .ets class.
const report = cg.getNodesByKind('function').find((n) => n.name === 'report')!;
expect(cg.getOutgoingEdges(report.id).map((e) => e.target)).toContain(repo.id);
});
});