282 lines
8.4 KiB
JavaScript
282 lines
8.4 KiB
JavaScript
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 = "<AssetDeclaration><GameObject id=\"UnitA\"/></AssetDeclaration>";
|
|
const MOD_B_TEXT = "<AssetDeclaration><GameObject id=\"UnitB\"/></AssetDeclaration>";
|
|
|
|
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 = "<AssetDeclaration/>") {
|
|
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, "<AssetDeclaration/>");
|
|
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();
|
|
});
|