diff --git a/test/helpers/extensions/jiti-runtime-api.ts b/test/helpers/extensions/jiti-runtime-api.ts index 11aa25358b0..fe59f3fcb12 100644 --- a/test/helpers/extensions/jiti-runtime-api.ts +++ b/test/helpers/extensions/jiti-runtime-api.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; +import ts from "typescript"; const JITI_EXTENSIONS = [ ".ts", @@ -16,28 +17,171 @@ const JITI_EXTENSIONS = [ ] as const; const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/"; +const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const; -function collectPluginSdkDistAliases(params: { +type SourceModuleRef = { + specifier: string; + typeOnly: boolean; +}; + +function listPluginSdkExportedSubpaths(root: string): string[] { + const packageJsonPath = path.join(root, "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + exports?: Record; + }; + return Object.keys(packageJson.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)); +} + +function resolvePluginSdkAliasTarget(root: string, subpath: string): string | null { + const distCandidate = path.join(root, "dist", "plugin-sdk", `${subpath}.js`); + if (existsSync(distCandidate)) { + return distCandidate; + } + + for (const ext of SOURCE_MODULE_EXTENSIONS) { + const srcCandidate = path.join(root, "src", "plugin-sdk", `${subpath}${ext}`); + if (existsSync(srcCandidate)) { + return srcCandidate; + } + } + + return null; +} + +function resolveLocalModulePath(filePath: string, specifier: string): string | null { + const basePath = path.resolve(path.dirname(filePath), specifier); + const candidates = new Set([basePath]); + + for (const ext of SOURCE_MODULE_EXTENSIONS) { + candidates.add(`${basePath}${ext}`); + } + + if (/\.[cm]?[jt]sx?$/u.test(basePath)) { + const withoutExt = basePath.replace(/\.[cm]?[jt]sx?$/u, ""); + for (const ext of SOURCE_MODULE_EXTENSIONS) { + candidates.add(`${withoutExt}${ext}`); + } + } + + for (const ext of SOURCE_MODULE_EXTENSIONS) { + candidates.add(path.join(basePath, `index${ext}`)); + } + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function collectSourceModuleRefs(filePath: string): SourceModuleRef[] { + const sourceText = readFileSync(filePath, "utf8"); + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true); + const refs: SourceModuleRef[] = []; + + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + const specifier = + statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (specifier) { + refs.push({ + specifier, + typeOnly: Boolean(statement.importClause?.isTypeOnly), + }); + } + continue; + } + + if (!ts.isExportDeclaration(statement)) { + continue; + } + + const specifier = + statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (!specifier) { + continue; + } + + const typeOnly = Boolean( + statement.isTypeOnly || + (statement.exportClause && + ts.isNamedExports(statement.exportClause) && + statement.exportClause.elements.length > 0 && + statement.exportClause.elements.every((element) => element.isTypeOnly)), + ); + + refs.push({ specifier, typeOnly }); + } + + return refs; +} + +function collectPluginSdkAliases(params: { modulePath: string; root: string; }): Record { - const sourceText = readFileSync(params.modulePath, "utf8"); - const specifiers = new Set(); + const realSpecifiers = new Set(); + const stubSpecifiers = new Set(); + const visitedFiles = new Set(); + const stubPath = path.join(params.root, "test", "helpers", "extensions", "plugin-sdk-stub.cjs"); - for (const match of sourceText.matchAll(/["'](openclaw\/plugin-sdk(?:\/[^"']+)?)["']/g)) { - const specifier = match[1]; - if (!specifier?.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) { - continue; + function visitModule(filePath: string, rootModule: boolean): void { + if (visitedFiles.has(filePath)) { + return; + } + visitedFiles.add(filePath); + + for (const ref of collectSourceModuleRefs(filePath)) { + if (ref.specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) { + if (rootModule && !ref.typeOnly) { + realSpecifiers.add(ref.specifier); + const subpath = ref.specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length); + const target = resolvePluginSdkAliasTarget(params.root, subpath); + if (target?.endsWith(".ts")) { + visitModule(target, false); + } + } else { + stubSpecifiers.add(ref.specifier); + } + continue; + } + + if (!ref.specifier.startsWith(".")) { + continue; + } + + const resolved = resolveLocalModulePath(filePath, ref.specifier); + if (resolved) { + visitModule(resolved, false); + } } - specifiers.add(specifier); } - return Object.fromEntries( - Array.from(specifiers, (specifier) => { + visitModule(params.modulePath, true); + + const aliasEntries = new Map(); + for (const specifier of listPluginSdkExportedSubpaths(params.root).map( + (subpath) => `${PLUGIN_SDK_SPECIFIER_PREFIX}${subpath}`, + )) { + if (realSpecifiers.has(specifier)) { const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length); - return [specifier, path.join(params.root, "dist", "plugin-sdk", `${subpath}.js`)]; - }), - ); + aliasEntries.set(specifier, resolvePluginSdkAliasTarget(params.root, subpath) ?? stubPath); + continue; + } + if (stubSpecifiers.has(specifier)) { + aliasEntries.set(specifier, stubPath); + } + } + + return Object.fromEntries(aliasEntries); } export function loadRuntimeApiExportTypesViaJiti(params: { @@ -47,7 +191,7 @@ export function loadRuntimeApiExportTypesViaJiti(params: { }): Record { const root = process.cwd(); const alias = { - ...collectPluginSdkDistAliases({ modulePath: params.modulePath, root }), + ...collectPluginSdkAliases({ modulePath: params.modulePath, root }), ...params.additionalAliases, }; diff --git a/test/helpers/extensions/plugin-sdk-stub.cjs b/test/helpers/extensions/plugin-sdk-stub.cjs new file mode 100644 index 00000000000..1b693f991a0 --- /dev/null +++ b/test/helpers/extensions/plugin-sdk-stub.cjs @@ -0,0 +1,65 @@ +"use strict"; + +let stub; + +stub = new Proxy( + function pluginSdkStub() { + return stub; + }, + { + apply() { + return stub; + }, + construct() { + return stub; + }, + get(_target, prop) { + if (prop === "__esModule") { + return true; + } + if (prop === "default") { + return stub; + } + if (prop === "then") { + return undefined; + } + if (prop === Symbol.toPrimitive) { + return () => ""; + } + if (prop === "toJSON") { + return () => undefined; + } + if (prop === "toString") { + return () => ""; + } + if (prop === "valueOf") { + return () => 0; + } + return stub; + }, + ownKeys() { + return []; + }, + getOwnPropertyDescriptor(_target, prop) { + if (prop === "__esModule") { + return { + configurable: true, + enumerable: false, + value: true, + writable: false, + }; + } + if (prop === "default") { + return { + configurable: true, + enumerable: false, + value: stub, + writable: false, + }; + } + return undefined; + }, + }, +); + +module.exports = stub;