mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:50:42 +00:00
test: speed up import-heavy tests
This commit is contained in:
@@ -1,21 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs";
|
||||
import {
|
||||
collectTypeScriptInventory,
|
||||
collectModuleReferencesFromSource,
|
||||
normalizeRepoPath,
|
||||
resolveRepoSpecifier,
|
||||
visitModuleSpecifiers,
|
||||
writeLine,
|
||||
} from "./lib/guard-inventory-utils.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
resolveSourceRoots,
|
||||
runAsScript,
|
||||
toLine,
|
||||
} from "./lib/ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -37,7 +35,7 @@ function pushEntry(entries, entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) {
|
||||
function scanPluginSdkExtensionFacadeSmells(source, filePath) {
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (!relativeFile.startsWith("src/plugin-sdk/")) {
|
||||
return [];
|
||||
@@ -45,28 +43,28 @@ function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) {
|
||||
|
||||
const entries = [];
|
||||
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
for (const { kind, line, specifier } of collectModuleReferencesFromSource(source)) {
|
||||
if (kind !== "export") {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
pushEntry(entries, {
|
||||
category: "plugin-sdk-extension-facade",
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
line,
|
||||
kind,
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: "plugin-sdk public surface re-exports extension-owned implementation",
|
||||
});
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
|
||||
function scanRuntimeTypeImplementationSmells(source, filePath) {
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (!/^src\/plugins\/runtime\/types(?:-[^/]+)?\.ts$/.test(relativeFile)) {
|
||||
return [];
|
||||
@@ -74,39 +72,32 @@ function scanRuntimeTypeImplementationSmells(sourceFile, filePath) {
|
||||
|
||||
const entries = [];
|
||||
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isImportTypeNode(node) &&
|
||||
ts.isLiteralTypeNode(node.argument) &&
|
||||
ts.isStringLiteral(node.argument.literal)
|
||||
) {
|
||||
const specifier = node.argument.literal.text;
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
if (
|
||||
resolvedPath &&
|
||||
(/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) ||
|
||||
/^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath))
|
||||
) {
|
||||
pushEntry(entries, {
|
||||
category: "runtime-type-implementation-edge",
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, node.argument.literal),
|
||||
kind: "import-type",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: "runtime type file references implementation shim directly",
|
||||
});
|
||||
}
|
||||
for (const { kind, line, specifier } of collectModuleReferencesFromSource(source)) {
|
||||
if (kind !== "dynamic-import") {
|
||||
continue;
|
||||
}
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
if (
|
||||
resolvedPath &&
|
||||
(/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) ||
|
||||
/^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath))
|
||||
) {
|
||||
pushEntry(entries, {
|
||||
category: "runtime-type-implementation-edge",
|
||||
file: relativeFile,
|
||||
line,
|
||||
kind: "import-type",
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: "runtime type file references implementation shim directly",
|
||||
});
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
|
||||
function scanRuntimeServiceLocatorSmells(source, filePath) {
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
if (
|
||||
!relativeFile.startsWith("src/plugin-sdk/") &&
|
||||
@@ -120,47 +111,25 @@ function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
|
||||
const runtimeStoreCalls = [];
|
||||
const mutableStateNodes = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isFunctionDeclaration(statement) && statement.name) {
|
||||
const isExported = statement.modifiers?.some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
||||
);
|
||||
if (isExported) {
|
||||
exportedNames.add(statement.name.text);
|
||||
}
|
||||
} else if (ts.isVariableStatement(statement)) {
|
||||
const isExported = statement.modifiers?.some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
||||
);
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name) && isExported) {
|
||||
exportedNames.add(declaration.name.text);
|
||||
}
|
||||
if (
|
||||
!isExported &&
|
||||
(statement.declarationList.flags & ts.NodeFlags.Let) !== 0 &&
|
||||
ts.isIdentifier(declaration.name)
|
||||
) {
|
||||
mutableStateNodes.push(declaration.name);
|
||||
}
|
||||
}
|
||||
const lines = source.split(/\r?\n/);
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = index + 1;
|
||||
const exportedFunction = line.match(/^\s*export\s+function\s+([A-Za-z_$][\w$]*)/);
|
||||
if (exportedFunction) {
|
||||
exportedNames.add(exportedFunction[1]);
|
||||
}
|
||||
const exportedVariable = line.match(/^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/);
|
||||
if (exportedVariable) {
|
||||
exportedNames.add(exportedVariable[1]);
|
||||
}
|
||||
for (const mutableMatch of line.matchAll(/^\s*let\s+([A-Za-z_$][\w$]*)/g)) {
|
||||
mutableStateNodes.push({ line: lineNumber, text: mutableMatch[1] });
|
||||
}
|
||||
if (line.includes("createPluginRuntimeStore")) {
|
||||
runtimeStoreCalls.push({ line: lineNumber });
|
||||
}
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === "createPluginRuntimeStore"
|
||||
) {
|
||||
runtimeStoreCalls.push(node.expression);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
|
||||
const getterNames = [...exportedNames].filter((name) => /^get[A-Z]/.test(name));
|
||||
const setterNames = [...exportedNames].filter((name) => /^set[A-Z]/.test(name));
|
||||
|
||||
@@ -169,7 +138,7 @@ function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
|
||||
pushEntry(entries, {
|
||||
category: "runtime-service-locator",
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, callNode),
|
||||
line: callNode.line,
|
||||
kind: "runtime-store",
|
||||
specifier: "createPluginRuntimeStore",
|
||||
resolvedPath: relativeFile,
|
||||
@@ -183,7 +152,7 @@ function scanRuntimeServiceLocatorSmells(sourceFile, filePath) {
|
||||
pushEntry(entries, {
|
||||
category: "runtime-service-locator",
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, identifier),
|
||||
line: identifier.line,
|
||||
kind: "mutable-state",
|
||||
specifier: identifier.text,
|
||||
resolvedPath: relativeFile,
|
||||
@@ -201,18 +170,16 @@ export async function collectArchitectureSmells() {
|
||||
const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
return await collectTypeScriptInventory({
|
||||
ts,
|
||||
files,
|
||||
compareEntries,
|
||||
collectEntries(sourceFile, filePath) {
|
||||
return [
|
||||
...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath),
|
||||
...scanRuntimeTypeImplementationSmells(sourceFile, filePath),
|
||||
...scanRuntimeServiceLocatorSmells(sourceFile, filePath),
|
||||
];
|
||||
},
|
||||
});
|
||||
const entriesByFile = await Promise.all(
|
||||
files.map(async (filePath) => {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
const entries = scanPluginSdkExtensionFacadeSmells(source, filePath);
|
||||
entries.push(...scanRuntimeTypeImplementationSmells(source, filePath));
|
||||
entries.push(...scanRuntimeServiceLocatorSmells(source, filePath));
|
||||
return entries;
|
||||
}),
|
||||
);
|
||||
return entriesByFile.flat().toSorted(compareEntries);
|
||||
})();
|
||||
try {
|
||||
return await architectureSmellsPromise;
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
import {
|
||||
BUNDLED_PLUGIN_PATH_PREFIX,
|
||||
BUNDLED_PLUGIN_ROOT_DIR,
|
||||
} from "./lib/bundled-plugin-paths.mjs";
|
||||
import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mjs";
|
||||
import {
|
||||
collectModuleReferencesFromSource,
|
||||
diffInventoryEntries,
|
||||
normalizeRepoPath,
|
||||
resolveRepoSpecifier,
|
||||
@@ -107,7 +107,7 @@ async function collectExtensionModuleReferences() {
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
references: collectModuleReferences(source),
|
||||
references: collectModuleReferencesFromSource(source),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -125,49 +125,6 @@ function mayContainModuleSpecifier(source) {
|
||||
);
|
||||
}
|
||||
|
||||
function collectModuleReferences(source) {
|
||||
const lineStarts = computeLineStarts(source);
|
||||
return ts.preProcessFile(source, true, true).importedFiles.map((reference) => ({
|
||||
kind: inferModuleReferenceKind(source, reference.pos),
|
||||
line: lineFromPosition(lineStarts, reference.pos),
|
||||
specifier: reference.fileName,
|
||||
}));
|
||||
}
|
||||
|
||||
function computeLineStarts(source) {
|
||||
const lineStarts = [0];
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source.charCodeAt(index) === 10) {
|
||||
lineStarts.push(index + 1);
|
||||
}
|
||||
}
|
||||
return lineStarts;
|
||||
}
|
||||
|
||||
function lineFromPosition(lineStarts, position) {
|
||||
let low = 0;
|
||||
let high = lineStarts.length - 1;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (lineStarts[middle] <= position) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return high + 1;
|
||||
}
|
||||
|
||||
function inferModuleReferenceKind(source, specifierStart) {
|
||||
const importIndex = source.lastIndexOf("import", specifierStart);
|
||||
const exportIndex = source.lastIndexOf("export", specifierStart);
|
||||
if (exportIndex > importIndex) {
|
||||
return "export";
|
||||
}
|
||||
const importPrefix = source.slice(importIndex, specifierStart);
|
||||
return /\bimport\s*\(\s*["']?$/.test(importPrefix) ? "dynamic-import" : "import";
|
||||
}
|
||||
|
||||
function resolveExtensionRoot(filePath) {
|
||||
const relativePath = normalizeRepoPath(repoRoot, filePath);
|
||||
const segments = relativePath.split("/");
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import ts from "typescript";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { BUNDLED_PLUGIN_PATH_PREFIX } from "./bundled-plugin-paths.mjs";
|
||||
import {
|
||||
collectTypeScriptInventory,
|
||||
collectModuleReferencesFromSource,
|
||||
createCachedAsync,
|
||||
formatGroupedInventoryHuman,
|
||||
normalizeRepoPath,
|
||||
resolveRepoSpecifier,
|
||||
visitModuleSpecifiers,
|
||||
writeLine,
|
||||
} from "./guard-inventory-utils.mjs";
|
||||
import {
|
||||
collectTypeScriptFilesFromRoots,
|
||||
resolveRepoRoot,
|
||||
resolveSourceRoots,
|
||||
toLine,
|
||||
} from "./ts-guard-utils.mjs";
|
||||
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
@@ -38,27 +36,29 @@ function classifyResolvedExtensionReason(kind, boundaryLabel) {
|
||||
return `${verb} bundled plugin file from ${boundaryLabel} boundary`;
|
||||
}
|
||||
|
||||
function scanImportBoundaryViolations(sourceFile, filePath, boundaryLabel, allowResolvedPath) {
|
||||
function scanImportBoundaryViolations(source, filePath, boundaryLabel, allowResolvedPath) {
|
||||
const entries = [];
|
||||
const relativeFile = normalizeRepoPath(repoRoot, filePath);
|
||||
|
||||
visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => {
|
||||
for (const reference of collectModuleReferencesFromSource(source)) {
|
||||
const kind = reference.kind;
|
||||
const specifier = reference.specifier;
|
||||
const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath);
|
||||
if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
if (allowResolvedPath?.(resolvedPath, { kind, specifier, file: relativeFile })) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
file: relativeFile,
|
||||
line: toLine(sourceFile, specifierNode),
|
||||
line: reference.line,
|
||||
kind,
|
||||
specifier,
|
||||
resolvedPath,
|
||||
reason: classifyResolvedExtensionReason(kind, boundaryLabel),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -72,22 +72,25 @@ export function createExtensionImportBoundaryChecker(params) {
|
||||
.toSorted((left, right) =>
|
||||
normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)),
|
||||
);
|
||||
return await collectTypeScriptInventory({
|
||||
ts,
|
||||
files,
|
||||
compareEntries,
|
||||
collectEntries(sourceFile, filePath) {
|
||||
const entriesByFile = await Promise.all(
|
||||
files.map(async (filePath) => {
|
||||
const source = await fs.readFile(filePath, "utf8");
|
||||
if (
|
||||
params.skipSourcesWithoutBundledPluginPrefix &&
|
||||
!source.includes(BUNDLED_PLUGIN_PATH_PREFIX)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return scanImportBoundaryViolations(
|
||||
sourceFile,
|
||||
source,
|
||||
filePath,
|
||||
params.boundaryLabel,
|
||||
params.allowResolvedPath,
|
||||
);
|
||||
},
|
||||
shouldParseSource: params.skipSourcesWithoutBundledPluginPrefix
|
||||
? (source) => source.includes(BUNDLED_PLUGIN_PATH_PREFIX)
|
||||
: undefined,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const inventory = entriesByFile.flat();
|
||||
return inventory.toSorted(compareEntries);
|
||||
});
|
||||
|
||||
async function main(argv = process.argv.slice(2), io) {
|
||||
|
||||
@@ -75,6 +75,122 @@ export function writeLine(stream, text) {
|
||||
stream.write(`${text}\n`);
|
||||
}
|
||||
|
||||
export function collectModuleReferencesFromSource(source) {
|
||||
const lineStarts = computeLineStarts(source);
|
||||
const isCodePosition = createCodePositionChecker(source);
|
||||
const references = [];
|
||||
const push = (kind, specifier, position, syntaxPosition) => {
|
||||
if (!isCodePosition(syntaxPosition)) {
|
||||
return;
|
||||
}
|
||||
references.push({
|
||||
kind,
|
||||
line: lineFromPosition(lineStarts, position),
|
||||
specifier,
|
||||
});
|
||||
};
|
||||
|
||||
for (const match of source.matchAll(/\bimport\s*\(\s*(["'])([^"']+)\1/g)) {
|
||||
push("dynamic-import", match[2], match.index + match[0].lastIndexOf(match[1]), match.index);
|
||||
}
|
||||
for (const match of source.matchAll(/^\s*import\s*(["'])([^"']+)\1/gm)) {
|
||||
push(
|
||||
"import",
|
||||
match[2],
|
||||
match.index + match[0].lastIndexOf(match[1]),
|
||||
match.index + match[0].indexOf("import"),
|
||||
);
|
||||
}
|
||||
for (const match of source.matchAll(
|
||||
/^\s*(import|export)\s+(?:type\s+)?[^;"']*?\bfrom\s*(["'])([^"']+)\2/gm,
|
||||
)) {
|
||||
push(
|
||||
match[1],
|
||||
match[3],
|
||||
match.index + match[0].lastIndexOf(match[2]),
|
||||
match.index + match[0].indexOf(match[1]),
|
||||
);
|
||||
}
|
||||
|
||||
return references.toSorted(
|
||||
(left, right) =>
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.specifier.localeCompare(right.specifier),
|
||||
);
|
||||
}
|
||||
|
||||
function createCodePositionChecker(source) {
|
||||
const codePositions = new Uint8Array(source.length);
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
const next = source[index + 1];
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
index += 2;
|
||||
while (index < source.length && source.charCodeAt(index) !== 10) {
|
||||
index += 1;
|
||||
}
|
||||
index -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
const quote = char;
|
||||
index += 1;
|
||||
while (index < source.length) {
|
||||
if (source[index] === "\\") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[index] === quote) {
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
codePositions[index] = 1;
|
||||
}
|
||||
|
||||
return (position) => codePositions[position] === 1;
|
||||
}
|
||||
|
||||
function computeLineStarts(source) {
|
||||
const lineStarts = [0];
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source.charCodeAt(index) === 10) {
|
||||
lineStarts.push(index + 1);
|
||||
}
|
||||
}
|
||||
return lineStarts;
|
||||
}
|
||||
|
||||
function lineFromPosition(lineStarts, position) {
|
||||
let low = 0;
|
||||
let high = lineStarts.length - 1;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (lineStarts[middle] <= position) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
return high + 1;
|
||||
}
|
||||
|
||||
export function createCachedAsync(factory) {
|
||||
let cachedPromise = null;
|
||||
return async function getCachedValue() {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("node pairing tokens", () => {
|
||||
await tempDirs.cleanup();
|
||||
});
|
||||
|
||||
test("reuses existing pending requests for the same node", async () => {
|
||||
test("reuses and refreshes pending requests", async () => {
|
||||
await withNodePairingDir(async (baseDir) => {
|
||||
const first = await requestNodePairing(
|
||||
{
|
||||
@@ -65,32 +65,28 @@ describe("node pairing tokens", () => {
|
||||
expect(first.created).toBe(true);
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.request.requestId).toBe(first.request.requestId);
|
||||
});
|
||||
});
|
||||
|
||||
test("refreshes pending requests with newer commands", async () => {
|
||||
await withNodePairingDir(async (baseDir) => {
|
||||
const first = await requestNodePairing(
|
||||
const commandFirst = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
nodeId: "node-2",
|
||||
platform: "darwin",
|
||||
commands: ["canvas.snapshot"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
const second = await requestNodePairing(
|
||||
const commandSecond = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
nodeId: "node-2",
|
||||
platform: "darwin",
|
||||
displayName: "Updated Node",
|
||||
commands: ["canvas.snapshot", "system.run"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
const third = await requestNodePairing(
|
||||
const commandThird = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
nodeId: "node-2",
|
||||
platform: "darwin",
|
||||
displayName: "Updated Node",
|
||||
commands: ["canvas.snapshot", "system.run", "system.which"],
|
||||
@@ -98,12 +94,36 @@ describe("node pairing tokens", () => {
|
||||
baseDir,
|
||||
);
|
||||
|
||||
expect(second.created).toBe(false);
|
||||
expect(second.request.requestId).toBe(first.request.requestId);
|
||||
expect(third.created).toBe(false);
|
||||
expect(third.request.requestId).toBe(second.request.requestId);
|
||||
expect(third.request.displayName).toBe("Updated Node");
|
||||
expect(third.request.commands).toEqual(["canvas.snapshot", "system.run", "system.which"]);
|
||||
expect(commandSecond.created).toBe(false);
|
||||
expect(commandSecond.request.requestId).toBe(commandFirst.request.requestId);
|
||||
expect(commandThird.created).toBe(false);
|
||||
expect(commandThird.request.requestId).toBe(commandSecond.request.requestId);
|
||||
expect(commandThird.request.displayName).toBe("Updated Node");
|
||||
expect(commandThird.request.commands).toEqual([
|
||||
"canvas.snapshot",
|
||||
"system.run",
|
||||
"system.which",
|
||||
]);
|
||||
|
||||
await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-3",
|
||||
platform: "darwin",
|
||||
commands: ["canvas.present"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(listNodePairing(baseDir)).resolves.toEqual({
|
||||
pending: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
nodeId: "node-3",
|
||||
commands: ["canvas.present"],
|
||||
requiredApproveScopes: ["operator.pairing", "operator.write"],
|
||||
}),
|
||||
]),
|
||||
paired: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,9 +150,9 @@ describe("node pairing tokens", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requires operator.admin to approve system.run node commands", async () => {
|
||||
test("requires the right scopes to approve node requests", async () => {
|
||||
await withNodePairingDir(async (baseDir) => {
|
||||
const request = await requestNodePairing(
|
||||
const systemRunRequest = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
platform: "darwin",
|
||||
@@ -143,7 +163,7 @@ describe("node pairing tokens", () => {
|
||||
|
||||
await expect(
|
||||
approveNodePairing(
|
||||
request.request.requestId,
|
||||
systemRunRequest.request.requestId,
|
||||
{ callerScopes: ["operator.pairing"] },
|
||||
baseDir,
|
||||
),
|
||||
@@ -152,62 +172,34 @@ describe("node pairing tokens", () => {
|
||||
missingScope: "operator.admin",
|
||||
});
|
||||
await expect(getPairedNode("node-1", baseDir)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("requires operator.pairing to approve commandless node requests", async () => {
|
||||
await withNodePairingDir(async (baseDir) => {
|
||||
const request = await requestNodePairing(
|
||||
const commandlessRequest = await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
nodeId: "node-2",
|
||||
platform: "darwin",
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(
|
||||
approveNodePairing(request.request.requestId, { callerScopes: [] }, baseDir),
|
||||
approveNodePairing(commandlessRequest.request.requestId, { callerScopes: [] }, baseDir),
|
||||
).resolves.toEqual({
|
||||
status: "forbidden",
|
||||
missingScope: "operator.pairing",
|
||||
});
|
||||
await expect(
|
||||
approveNodePairing(
|
||||
request.request.requestId,
|
||||
commandlessRequest.request.requestId,
|
||||
{ callerScopes: ["operator.pairing"] },
|
||||
baseDir,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
requestId: request.request.requestId,
|
||||
requestId: commandlessRequest.request.requestId,
|
||||
node: expect.objectContaining({
|
||||
nodeId: "node-1",
|
||||
nodeId: "node-2",
|
||||
commands: undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("lists pending requests with precomputed approval scopes", async () => {
|
||||
await withNodePairingDir(async (baseDir) => {
|
||||
await requestNodePairing(
|
||||
{
|
||||
nodeId: "node-1",
|
||||
platform: "darwin",
|
||||
commands: ["canvas.present"],
|
||||
},
|
||||
baseDir,
|
||||
);
|
||||
|
||||
await expect(listNodePairing(baseDir)).resolves.toEqual({
|
||||
pending: [
|
||||
expect.objectContaining({
|
||||
nodeId: "node-1",
|
||||
commands: ["canvas.present"],
|
||||
requiredApproveScopes: ["operator.pairing", "operator.write"],
|
||||
}),
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,18 +30,14 @@ describe("createPersistentDedupe", () => {
|
||||
expect(await second.checkAndRecord("m2", { namespace: "a" })).toBe(false);
|
||||
expect(await second.checkAndRecord("m3", { namespace: "a" })).toBe(true);
|
||||
expect(await second.checkAndRecord("m1", { namespace: "b" })).toBe(true);
|
||||
});
|
||||
|
||||
it("guards concurrent calls for the same key", async () => {
|
||||
const root = await createTempDir("openclaw-dedupe-");
|
||||
const dedupe = createDedupe(root, { ttlMs: 10_000 });
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
dedupe.checkAndRecord("race-key", { namespace: "feishu" }),
|
||||
dedupe.checkAndRecord("race-key", { namespace: "feishu" }),
|
||||
const raceDedupe = createDedupe(root, { ttlMs: 10_000 });
|
||||
const [raceFirst, raceSecond] = await Promise.all([
|
||||
raceDedupe.checkAndRecord("race-key", { namespace: "feishu" }),
|
||||
raceDedupe.checkAndRecord("race-key", { namespace: "feishu" }),
|
||||
]);
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(false);
|
||||
expect(raceFirst).toBe(true);
|
||||
expect(raceSecond).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to memory-only behavior on disk errors", async () => {
|
||||
@@ -56,43 +52,25 @@ describe("createPersistentDedupe", () => {
|
||||
expect(await dedupe.checkAndRecord("memory-only", { namespace: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "returns 0 when no disk file exists",
|
||||
setup: async (root: string) => createDedupe(root, { ttlMs: 10_000 }),
|
||||
namespace: "nonexistent",
|
||||
expectedLoaded: 0,
|
||||
verify: async () => undefined,
|
||||
},
|
||||
{
|
||||
name: "skips expired entries",
|
||||
setup: async (root: string) => {
|
||||
const writer = createDedupe(root, { ttlMs: 1000 });
|
||||
const oldNow = Date.now() - 2000;
|
||||
expect(await writer.checkAndRecord("old-msg", { namespace: "acct", now: oldNow })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await writer.checkAndRecord("new-msg", { namespace: "acct" })).toBe(true);
|
||||
return createDedupe(root, { ttlMs: 1000 });
|
||||
},
|
||||
namespace: "acct",
|
||||
expectedLoaded: 1,
|
||||
verify: async (reader: ReturnType<typeof createDedupe>) => {
|
||||
expect(await reader.checkAndRecord("old-msg", { namespace: "acct" })).toBe(true);
|
||||
expect(await reader.checkAndRecord("new-msg", { namespace: "acct" })).toBe(false);
|
||||
},
|
||||
},
|
||||
])("warmup $name", async ({ setup, namespace, expectedLoaded, verify }) => {
|
||||
it("warms empty namespaces and skips expired disk entries", async () => {
|
||||
const root = await createTempDir("openclaw-dedupe-");
|
||||
const reader = await setup(root);
|
||||
const loaded = await reader.warmup(namespace);
|
||||
expect(loaded).toBe(expectedLoaded);
|
||||
await verify(reader);
|
||||
const emptyReader = createDedupe(root, { ttlMs: 10_000 });
|
||||
expect(await emptyReader.warmup("nonexistent")).toBe(0);
|
||||
|
||||
const writer = createDedupe(root, { ttlMs: 1000 });
|
||||
const oldNow = Date.now() - 2000;
|
||||
expect(await writer.checkAndRecord("old-msg", { namespace: "acct", now: oldNow })).toBe(true);
|
||||
expect(await writer.checkAndRecord("new-msg", { namespace: "acct" })).toBe(true);
|
||||
|
||||
const reader = createDedupe(root, { ttlMs: 1000 });
|
||||
expect(await reader.warmup("acct")).toBe(1);
|
||||
expect(await reader.checkAndRecord("old-msg", { namespace: "acct" })).toBe(true);
|
||||
expect(await reader.checkAndRecord("new-msg", { namespace: "acct" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createClaimableDedupe", () => {
|
||||
it("mirrors concurrent in-flight duplicates and records on commit", async () => {
|
||||
it("mirrors in-flight duplicates, serializes races, and records on commit", async () => {
|
||||
const dedupe = createClaimableDedupe({
|
||||
ttlMs: 10_000,
|
||||
memoryMaxSize: 100,
|
||||
@@ -108,13 +86,6 @@ describe("createClaimableDedupe", () => {
|
||||
await expect(duplicate.pending).resolves.toBe(true);
|
||||
}
|
||||
await expect(dedupe.claim("line:evt-1")).resolves.toEqual({ kind: "duplicate" });
|
||||
});
|
||||
|
||||
it("serializes concurrent first-claim races onto one in-flight owner", async () => {
|
||||
const dedupe = createClaimableDedupe({
|
||||
ttlMs: 10_000,
|
||||
memoryMaxSize: 100,
|
||||
});
|
||||
|
||||
const claims = await Promise.all([dedupe.claim("line:race-1"), dedupe.claim("line:race-1")]);
|
||||
expect(claims.filter((claim) => claim.kind === "claimed")).toHaveLength(1);
|
||||
|
||||
@@ -1,38 +1,78 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { loadEnabledClaudeBundleCommands } from "./bundle-commands.js";
|
||||
import {
|
||||
createEnabledPluginEntries,
|
||||
createBundleMcpTempHarness,
|
||||
withBundleHomeEnv,
|
||||
writeBundleTextFiles,
|
||||
writeClaudeBundleManifest,
|
||||
} from "./bundle-mcp.test-support.js";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
|
||||
const tempHarness = createBundleMcpTempHarness();
|
||||
const mocks = vi.hoisted(() => ({
|
||||
plugins: [] as PluginManifestRecord[],
|
||||
}));
|
||||
|
||||
vi.mock("./manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry: () => ({ diagnostics: [], plugins: mocks.plugins }),
|
||||
}));
|
||||
|
||||
vi.mock("./config-state.js", () => ({
|
||||
hasExplicitPluginConfig: (plugins?: { entries?: Record<string, unknown> }) =>
|
||||
Boolean(plugins?.entries && Object.keys(plugins.entries).length > 0),
|
||||
normalizePluginsConfig: (plugins?: unknown) => plugins,
|
||||
resolveEffectivePluginActivationState: (params: {
|
||||
config?: { entries?: Record<string, { enabled?: boolean }> };
|
||||
id: string;
|
||||
}) => ({
|
||||
activated: params.config?.entries?.[params.id]?.enabled !== false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { loadEnabledClaudeBundleCommands } = await import("./bundle-commands.js");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await tempHarness.cleanup();
|
||||
mocks.plugins = [];
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createTempDir(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function resolveBundlePluginRoot(homeDir: string, pluginId: string) {
|
||||
return path.join(homeDir, ".openclaw", "extensions", pluginId);
|
||||
}
|
||||
|
||||
async function writeClaudeBundleCommandFixture(params: {
|
||||
homeDir: string;
|
||||
pluginId: string;
|
||||
commands: Array<{ relativePath: string; contents: string[] }>;
|
||||
}) {
|
||||
const pluginRoot = await writeClaudeBundleManifest({
|
||||
homeDir: params.homeDir,
|
||||
pluginId: params.pluginId,
|
||||
manifest: { name: params.pluginId },
|
||||
});
|
||||
await writeBundleTextFiles(
|
||||
pluginRoot,
|
||||
Object.fromEntries(
|
||||
params.commands.map((command) => [
|
||||
command.relativePath,
|
||||
[...command.contents, ""].join("\n"),
|
||||
]),
|
||||
),
|
||||
const pluginRoot = resolveBundlePluginRoot(params.homeDir, params.pluginId);
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify({ name: params.pluginId }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await Promise.all(
|
||||
params.commands.map(async (command) => {
|
||||
const filePath = path.join(pluginRoot, command.relativePath);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, [...command.contents, ""].join("\n"), "utf-8");
|
||||
}),
|
||||
);
|
||||
mocks.plugins = [
|
||||
{
|
||||
id: params.pluginId,
|
||||
format: "bundle",
|
||||
bundleFormat: "claude",
|
||||
bundleCapabilities: ["commands"],
|
||||
origin: "global",
|
||||
rootDir: pluginRoot,
|
||||
} as PluginManifestRecord,
|
||||
];
|
||||
}
|
||||
|
||||
function expectEnabledClaudeBundleCommands(
|
||||
@@ -51,65 +91,71 @@ function expectEnabledClaudeBundleCommands(
|
||||
|
||||
describe("loadEnabledClaudeBundleCommands", () => {
|
||||
it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => {
|
||||
await withBundleHomeEnv(
|
||||
tempHarness,
|
||||
"openclaw-bundle-commands",
|
||||
async ({ homeDir, workspaceDir }) => {
|
||||
await writeClaudeBundleCommandFixture({
|
||||
homeDir,
|
||||
const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
|
||||
try {
|
||||
const homeDir = await createTempDir("openclaw-bundle-commands-home-");
|
||||
const workspaceDir = await createTempDir("openclaw-bundle-commands-workspace-");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
await writeClaudeBundleCommandFixture({
|
||||
homeDir,
|
||||
pluginId: "compound-bundle",
|
||||
commands: [
|
||||
{
|
||||
relativePath: "commands/office-hours.md",
|
||||
contents: [
|
||||
"---",
|
||||
"description: Help with scoping and architecture",
|
||||
"---",
|
||||
"Give direct engineering advice.",
|
||||
],
|
||||
},
|
||||
{
|
||||
relativePath: "commands/workflows/review.md",
|
||||
contents: [
|
||||
"---",
|
||||
"name: workflows:review",
|
||||
"description: Run a structured review",
|
||||
"---",
|
||||
"Review the code. $ARGUMENTS",
|
||||
],
|
||||
},
|
||||
{
|
||||
relativePath: "commands/disabled.md",
|
||||
contents: ["---", "disable-model-invocation: true", "---", "Do not load me."],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commands = loadEnabledClaudeBundleCommands({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: { "compound-bundle": { enabled: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectEnabledClaudeBundleCommands(commands, [
|
||||
{
|
||||
pluginId: "compound-bundle",
|
||||
commands: [
|
||||
{
|
||||
relativePath: "commands/office-hours.md",
|
||||
contents: [
|
||||
"---",
|
||||
"description: Help with scoping and architecture",
|
||||
"---",
|
||||
"Give direct engineering advice.",
|
||||
],
|
||||
},
|
||||
{
|
||||
relativePath: "commands/workflows/review.md",
|
||||
contents: [
|
||||
"---",
|
||||
"name: workflows:review",
|
||||
"description: Run a structured review",
|
||||
"---",
|
||||
"Review the code. $ARGUMENTS",
|
||||
],
|
||||
},
|
||||
{
|
||||
relativePath: "commands/disabled.md",
|
||||
contents: ["---", "disable-model-invocation: true", "---", "Do not load me."],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commands = loadEnabledClaudeBundleCommands({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: createEnabledPluginEntries(["compound-bundle"]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectEnabledClaudeBundleCommands(commands, [
|
||||
{
|
||||
pluginId: "compound-bundle",
|
||||
rawName: "office-hours",
|
||||
description: "Help with scoping and architecture",
|
||||
promptTemplate: "Give direct engineering advice.",
|
||||
},
|
||||
{
|
||||
pluginId: "compound-bundle",
|
||||
rawName: "workflows:review",
|
||||
description: "Run a structured review",
|
||||
promptTemplate: "Review the code. $ARGUMENTS",
|
||||
},
|
||||
]);
|
||||
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
|
||||
},
|
||||
);
|
||||
rawName: "office-hours",
|
||||
description: "Help with scoping and architecture",
|
||||
promptTemplate: "Give direct engineering advice.",
|
||||
},
|
||||
{
|
||||
pluginId: "compound-bundle",
|
||||
rawName: "workflows:review",
|
||||
description: "Run a structured review",
|
||||
promptTemplate: "Review the code. $ARGUMENTS",
|
||||
},
|
||||
]);
|
||||
expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { main as extensionPluginSdkMain } from "../scripts/check-extension-plugin-sdk-boundary.mjs";
|
||||
import { main as sdkPackageMain } from "../scripts/check-sdk-package-extension-import-boundary.mjs";
|
||||
import { main as srcExtensionMain } from "../scripts/check-src-extension-import-boundary.mjs";
|
||||
import { collectModuleReferencesFromSource } from "../scripts/lib/guard-inventory-utils.mjs";
|
||||
import { createCapturedIo } from "./helpers/captured-io.js";
|
||||
|
||||
const srcJsonOutputPromise = getJsonOutput(srcExtensionMain, ["--json"]);
|
||||
@@ -21,6 +22,26 @@ const relativeOutsidePackageJsonOutputPromise = getJsonOutput(extensionPluginSdk
|
||||
|
||||
type CapturedIo = ReturnType<typeof createCapturedIo>["io"];
|
||||
|
||||
describe("fast module reference scanner", () => {
|
||||
it("collects code references without matching comments or strings", () => {
|
||||
expect(
|
||||
collectModuleReferencesFromSource(`
|
||||
// import "./commented";
|
||||
const text = 'import("./string")';
|
||||
import "./side-effect";
|
||||
import type { Example } from "./types";
|
||||
export { Example } from "./public";
|
||||
await import("./runtime");
|
||||
`),
|
||||
).toEqual([
|
||||
{ kind: "import", line: 4, specifier: "./side-effect" },
|
||||
{ kind: "import", line: 5, specifier: "./types" },
|
||||
{ kind: "export", line: 6, specifier: "./public" },
|
||||
{ kind: "dynamic-import", line: 7, specifier: "./runtime" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
async function getJsonOutput(
|
||||
main: (argv: string[], io: CapturedIo) => Promise<number>,
|
||||
argv: string[],
|
||||
|
||||
Reference in New Issue
Block a user