import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; // ── Minimal vscode shim for ModWorkspace (multi-project behavior) ────── const stubState = { workspaceFolders: [], textDocuments: [], activeEditor: null, config: { sdkPath: "", indexSageXml: true, reportUnresolvedReferences: "warning", diagnoseUnknownElements: true, definitionMode: "all", additionalDataSearchPaths: [], }, }; class RelativePattern { constructor(base, pattern) { this.base = base; this.pattern = pattern; } } class OutputChannel { appendLine() {} dispose() {} } class StatusBarItem { constructor() { this.name = ""; this.command = ""; this.text = ""; this.tooltip = ""; } show() { this.visible = true; } hide() { this.visible = false; } dispose() {} } function makeWatcher() { return { onDidCreate: () => ({ dispose() {} }), onDidChange: () => ({ dispose() {} }), onDidDelete: () => ({ dispose() {} }), dispose() {}, }; } const require = createRequire(import.meta.url); const Module = require("module"); const origResolve = Module._resolveFilename; Module._resolveFilename = function (request, ...args) { if (request === "vscode") return "vscode-stub"; return origResolve.call(this, request, ...args); }; require.cache["vscode-stub"] = { id: "vscode-stub", filename: "vscode-stub", loaded: true, exports: { RelativePattern, StatusBarAlignment: { Left: 1 }, workspace: { getConfiguration: () => ({ get: (key, def) => stubState.config[key] ?? def, }), get workspaceFolders() { return stubState.workspaceFolders; }, get textDocuments() { return stubState.textDocuments; }, createFileSystemWatcher: () => makeWatcher(), onDidCloseTextDocument: () => ({ dispose() {} }), }, window: { createOutputChannel: () => new OutputChannel(), createStatusBarItem: () => new StatusBarItem(), get activeTextEditor() { return stubState.activeEditor; }, }, commands: { executeCommand: async () => undefined, }, }, }; const { ModWorkspace } = require("../out/workspace.js"); // ── Fixtures ──────────────────────────────────────────────────────────── const FAKE_SDK = fileURLToPath(new URL("./fixtures/fakesdk", import.meta.url)); let tmpRoot; let modA; let modB; let container; let storageDir; const MOD_A_TEXT = ""; const MOD_B_TEXT = ""; test.before(() => { stubState.config.sdkPath = FAKE_SDK; tmpRoot = mkdtempSync(join(tmpdir(), "ra3-multimod-")); modA = join(tmpRoot, "container", "ModA"); modB = join(tmpRoot, "container", "ModB"); container = join(tmpRoot, "container"); storageDir = join(tmpRoot, "storage"); mkdirSync(join(modA, "Data"), { recursive: true }); mkdirSync(join(modB, "Data"), { recursive: true }); writeFileSync(join(modA, "Data", "Mod.xml"), MOD_A_TEXT); writeFileSync(join(modB, "Data", "Mod.xml"), MOD_B_TEXT); }); test.after(() => { if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true }); }); function makeDoc(fsPath, text = "") { return { uri: { fsPath, scheme: "file", toString: () => `file://${fsPath}`, }, languageId: "xml", isDirty: false, version: 1, getText: () => text, }; } function makeWorkspace(folders) { stubState.workspaceFolders = folders; stubState.textDocuments = []; stubState.activeEditor = null; const context = { storageUri: { fsPath: storageDir }, globalStorageUri: null, subscriptions: [], }; return new ModWorkspace(context); } async function waitForIndex(ws, doc) { for (let i = 0; i < 500; i++) { const idx = await ws.getIndex(doc); if (idx?.complete && idx.stats.assetCount > 0) return idx; await new Promise((r) => setTimeout(r, 20)); } throw new Error(`timed out waiting for index of ${doc.uri.fsPath}`); } function stateForRoot(ws, root) { const wanted = resolve(root).toLowerCase(); return [...ws.states.values()].find( (s) => resolve(s.root).toLowerCase() === wanted, ); } test("single project folder is indexed immediately on initialize", async () => { const ws = makeWorkspace([{ uri: { fsPath: modA } }]); await ws.initialize(); assert.equal(ws.getProjectRoots().length, 1); const idx = ws.activeIndex(); assert.ok(idx); assert.equal(resolve(idx.stats.projectDir), resolve(modA)); assert.ok(idx.assetsById.has("unita")); ws.dispose(); }); test("container folder discovers two projects and indexes lazily", async () => { const ws = makeWorkspace([{ uri: { fsPath: container } }]); await ws.initialize(); assert.equal(ws.getProjectRoots().length, 2); assert.equal(ws.activeIndex(), null); const docA = makeDoc(join(modA, "Data", "Mod.xml"), MOD_A_TEXT); const docB = makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT); assert.equal(ws.getProjectRootFor(docA), resolve(modA)); assert.equal(ws.getProjectRootFor(docB), resolve(modB)); assert.ok( ws.searchPaths(docA).DATA.some((d) => resolve(d) === resolve(join(modA, "Data"))), ); // Opening ModA's document builds only ModA. ws.onDocumentOpened(docA); const idxA = await waitForIndex(ws, docA); assert.equal(resolve(idxA.stats.projectDir), resolve(modA)); assert.ok(idxA.assetsById.has("unita")); const stateB = stateForRoot(ws, modB); assert.ok(stateB); assert.equal(stateB.index, null); assert.equal(stateB.buildCount, 0); // Opening ModB's document builds ModB. ws.onDocumentOpened(docB); const idxB = await waitForIndex(ws, docB); assert.equal(resolve(idxB.stats.projectDir), resolve(modB)); assert.ok(idxB.assetsById.has("unitb")); ws.dispose(); }); test("with multiple projects the active editor's project builds on initialize", async () => { const ws = makeWorkspace([{ uri: { fsPath: container } }]); stubState.activeEditor = { document: makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT), }; await ws.initialize(); const idx = ws.activeIndex(); assert.ok(idx); assert.equal(resolve(idx.stats.projectDir), resolve(modB)); ws.dispose(); }); test("workspace folder changes add and remove projects", async () => { const ws = makeWorkspace([{ uri: { fsPath: container } }]); await ws.initialize(); assert.equal(ws.getProjectRoots().length, 2); stubState.workspaceFolders = [{ uri: { fsPath: modA } }]; ws.onWorkspaceFoldersChanged(); assert.equal(ws.getProjectRoots().length, 1); assert.equal(resolve(ws.getProjectRoots()[0]), resolve(modA)); stubState.workspaceFolders = [{ uri: { fsPath: container } }]; ws.onWorkspaceFoldersChanged(); assert.equal(ws.getProjectRoots().length, 2); ws.dispose(); }); test("an unrelated active XML document falls back without recursion", async () => { const ws = makeWorkspace([{ uri: { fsPath: container } }]); const outside = join(tmpRoot, "outside.xml"); writeFileSync(outside, ""); stubState.activeEditor = { document: makeDoc(outside) }; await ws.initialize(); // No project contains the active document, so nothing builds eagerly and // activeIndex resolves to the first project (or null) without recursing. assert.equal(ws.getProjectRoots().length, 2); const idx = ws.activeIndex(); assert.equal(idx, null); ws.dispose(); }); test("rebuilds for both projects complete through the serialized queue", async () => { const ws = makeWorkspace([{ uri: { fsPath: container } }]); await ws.initialize(); const docA = makeDoc(join(modA, "Data", "Mod.xml"), MOD_A_TEXT); const docB = makeDoc(join(modB, "Data", "Mod.xml"), MOD_B_TEXT); const p1 = ws.rebuild(false, "test-a", docA); const p2 = ws.rebuild(false, "test-b", docB); await Promise.all([p1, p2]); const idxA = await waitForIndex(ws, docA); const idxB = await waitForIndex(ws, docB); assert.equal(resolve(idxA.stats.projectDir), resolve(modA)); assert.equal(resolve(idxB.stats.projectDir), resolve(modB)); assert.ok(idxA.assetsById.has("unita")); assert.ok(idxB.assetsById.has("unitb")); ws.dispose(); });