From a3f2fbf5a2b151be65a2688910058db22f5d46ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Mar 2026 19:36:19 -0700 Subject: [PATCH] refactor: harden extension runtime-api seams --- extensions/line/index.test.ts | 404 +++++++++++--------- extensions/line/src/setup-core.ts | 2 +- extensions/line/src/setup-runtime-api.ts | 9 + extensions/line/src/setup-surface.ts | 14 +- extensions/matrix/index.test.ts | 43 +-- test/helpers/extensions/jiti-runtime-api.ts | 83 ++++ 6 files changed, 333 insertions(+), 222 deletions(-) create mode 100644 extensions/line/src/setup-runtime-api.ts create mode 100644 test/helpers/extensions/jiti-runtime-api.ts diff --git a/extensions/line/index.test.ts b/extensions/line/index.test.ts index 0393f4d92e9..211884c82a6 100644 --- a/extensions/line/index.test.ts +++ b/extensions/line/index.test.ts @@ -1,198 +1,222 @@ -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; +import { readFileSync } from "node:fs"; import path from "node:path"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; +import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts"; + +function normalizeModuleSpecifier(specifier: string): string | null { + if (specifier.startsWith("./src/")) { + return specifier; + } + if (specifier.startsWith("../../extensions/line/src/")) { + return `./src/${specifier.slice("../../extensions/line/src/".length)}`; + } + return null; +} + +function collectModuleExportNames(filePath: string): string[] { + const sourcePath = filePath.replace(/\.js$/, ".ts"); + const sourceText = readFileSync(sourcePath, "utf8"); + const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true); + const names = new Set(); + + for (const statement of sourceFile.statements) { + if ( + ts.isExportDeclaration(statement) && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) { + if (!element.isTypeOnly) { + names.add(element.name.text); + } + } + continue; + } + + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; + const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + if (!isExported) { + continue; + } + + if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + names.add(declaration.name.text); + } + } + continue; + } + + if ( + ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isEnumDeclaration(statement) + ) { + if (statement.name) { + names.add(statement.name.text); + } + } + } + + return Array.from(names).toSorted(); +} + +function collectRuntimeApiOverlapExports(params: { + lineRuntimePath: string; + runtimeApiPath: string; +}): string[] { + const runtimeApiSource = readFileSync(params.runtimeApiPath, "utf8"); + const runtimeApiFile = ts.createSourceFile( + params.runtimeApiPath, + runtimeApiSource, + ts.ScriptTarget.Latest, + true, + ); + const runtimeApiLocalModules = new Set(); + let pluginSdkLineRuntimeSeen = false; + + for (const statement of runtimeApiFile.statements) { + if (!ts.isExportDeclaration(statement)) { + continue; + } + const moduleSpecifier = + statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (!moduleSpecifier) { + continue; + } + if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") { + pluginSdkLineRuntimeSeen = true; + continue; + } + if (!pluginSdkLineRuntimeSeen) { + continue; + } + const normalized = normalizeModuleSpecifier(moduleSpecifier); + if (normalized) { + runtimeApiLocalModules.add(normalized); + } + } + + const lineRuntimeSource = readFileSync(params.lineRuntimePath, "utf8"); + const lineRuntimeFile = ts.createSourceFile( + params.lineRuntimePath, + lineRuntimeSource, + ts.ScriptTarget.Latest, + true, + ); + const overlapExports = new Set(); + + for (const statement of lineRuntimeFile.statements) { + if (!ts.isExportDeclaration(statement)) { + continue; + } + const moduleSpecifier = + statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + const normalized = moduleSpecifier ? normalizeModuleSpecifier(moduleSpecifier) : null; + if (!normalized || !runtimeApiLocalModules.has(normalized)) { + continue; + } + + if (!statement.exportClause) { + for (const name of collectModuleExportNames( + path.join(process.cwd(), "extensions", "line", normalized), + )) { + overlapExports.add(name); + } + continue; + } + + if (!ts.isNamedExports(statement.exportClause)) { + continue; + } + + for (const element of statement.exportClause.elements) { + if (!element.isTypeOnly) { + overlapExports.add(element.name.text); + } + } + } + + return Array.from(overlapExports).toSorted(); +} + +function collectRuntimeApiPreExports(runtimeApiPath: string): string[] { + const runtimeApiSource = readFileSync(runtimeApiPath, "utf8"); + const runtimeApiFile = ts.createSourceFile( + runtimeApiPath, + runtimeApiSource, + ts.ScriptTarget.Latest, + true, + ); + const preExports = new Set(); + + for (const statement of runtimeApiFile.statements) { + if (!ts.isExportDeclaration(statement)) { + continue; + } + const moduleSpecifier = + statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (!moduleSpecifier) { + continue; + } + if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") { + break; + } + const normalized = normalizeModuleSpecifier(moduleSpecifier); + if (!normalized || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) { + continue; + } + for (const element of statement.exportClause.elements) { + if (!element.isTypeOnly) { + preExports.add(element.name.text); + } + } + } + + return Array.from(preExports).toSorted(); +} describe("line runtime api", () => { it("loads through Jiti without duplicate export errors", () => { - const root = process.cwd(); - const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-line-jiti-")); - const runtimeApiPath = path.join(fixtureRoot, "runtime-api.ts"); - const pluginSdkRoot = path.join(fixtureRoot, "plugin-sdk"); + const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts"); - fs.mkdirSync(pluginSdkRoot, { recursive: true }); - - const writeFile = (relativePath: string, contents: string) => { - const filePath = path.join(fixtureRoot, relativePath); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, contents, "utf8"); - return filePath; - }; - - const botAccessPath = writeFile( - "src/bot-access.js", - `export const firstDefined = (...values) => values.find((value) => value !== undefined); -export const isSenderAllowed = () => true; -export const normalizeAllowFrom = (value) => value; -export const normalizeDmAllowFromWithStore = (value) => value; -`, - ); - const downloadPath = writeFile( - "src/download.js", - `export const downloadLineMedia = () => "downloaded"; -`, - ); - const probePath = writeFile( - "src/probe.js", - `export const probeLineBot = () => "probed"; -`, - ); - const templateMessagesPath = writeFile( - "src/template-messages.js", - `export const buildTemplateMessageFromPayload = () => ({ type: "template" }); -`, - ); - const sendPath = writeFile( - "src/send.js", - `export const createQuickReplyItems = () => []; -export const pushFlexMessage = () => "flex"; -export const pushLocationMessage = () => "location"; -export const pushMessageLine = () => "push"; -export const pushMessagesLine = () => "pushMany"; -export const pushTemplateMessage = () => "template"; -export const pushTextMessageWithQuickReplies = () => "quick"; -export const sendMessageLine = () => "send"; -`, - ); - - const writePluginSdkShim = (subpath: string, contents: string) => { - writeFile(path.join("plugin-sdk", `${subpath}.ts`), contents); - }; - - writePluginSdkShim( - "core", - `export const clearAccountEntryFields = () => ({}); -`, - ); - writePluginSdkShim( - "channel-config-schema", - `export const buildChannelConfigSchema = () => ({}); -`, - ); - writePluginSdkShim( - "reply-runtime", - `export {}; -`, - ); - writePluginSdkShim( - "testing", - `export {}; -`, - ); - writePluginSdkShim( - "channel-contract", - `export {}; -`, - ); - writePluginSdkShim( - "setup", - `export const DEFAULT_ACCOUNT_ID = "default"; -export const formatDocsLink = (href, fallback) => href ?? fallback; -export const setSetupChannelEnabled = () => {}; -export const splitSetupEntries = (entries) => entries; -`, - ); - writePluginSdkShim( - "status-helpers", - `export const buildComputedAccountStatusSnapshot = () => ({}); -export const buildTokenChannelStatusSummary = () => "ok"; -`, - ); - writePluginSdkShim( - "line-runtime", - `export { firstDefined, isSenderAllowed, normalizeAllowFrom, normalizeDmAllowFromWithStore } from ${JSON.stringify(botAccessPath)}; -export { downloadLineMedia } from ${JSON.stringify(downloadPath)}; -export { probeLineBot } from ${JSON.stringify(probePath)}; -export { buildTemplateMessageFromPayload } from ${JSON.stringify(templateMessagesPath)}; -export { - createQuickReplyItems, - pushFlexMessage, - pushLocationMessage, - pushMessageLine, - pushMessagesLine, - pushTemplateMessage, - pushTextMessageWithQuickReplies, - sendMessageLine, -} from ${JSON.stringify(sendPath)}; -`, - ); - - fs.writeFileSync( - runtimeApiPath, - `export { clearAccountEntryFields } from "openclaw/plugin-sdk/core"; -export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; -export { buildComputedAccountStatusSnapshot, buildTokenChannelStatusSummary } from "openclaw/plugin-sdk/status-helpers"; -export { DEFAULT_ACCOUNT_ID, formatDocsLink, setSetupChannelEnabled, splitSetupEntries } from "openclaw/plugin-sdk/setup"; -export { firstDefined, isSenderAllowed, normalizeAllowFrom, normalizeDmAllowFromWithStore } from ${JSON.stringify(botAccessPath)}; -export { downloadLineMedia } from ${JSON.stringify(downloadPath)}; -export { probeLineBot } from ${JSON.stringify(probePath)}; -export { buildTemplateMessageFromPayload } from ${JSON.stringify(templateMessagesPath)}; -export { - createQuickReplyItems, - pushFlexMessage, - pushLocationMessage, - pushMessageLine, - pushMessagesLine, - pushTemplateMessage, - pushTextMessageWithQuickReplies, - sendMessageLine, -} from ${JSON.stringify(sendPath)}; -export * from "openclaw/plugin-sdk/line-runtime"; -`, - "utf8", - ); - - const script = ` -import path from "node:path"; -import { createJiti } from "jiti"; - -const root = ${JSON.stringify(root)}; -const runtimeApiPath = ${JSON.stringify(runtimeApiPath)}; -const pluginSdkRoot = ${JSON.stringify(pluginSdkRoot)}; -const alias = Object.fromEntries([ - "core", - "channel-config-schema", - "reply-runtime", - "testing", - "channel-contract", - "setup", - "status-helpers", - "line-runtime", -].map((name) => ["openclaw/plugin-sdk/" + name, path.join(pluginSdkRoot, name + ".ts")])); -const jiti = createJiti(path.join(root, "openclaw.mjs"), { - interopDefault: true, - tryNative: false, - fsCache: false, - moduleCache: false, - extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - alias, -}); -const mod = jiti(runtimeApiPath); -console.log( - JSON.stringify({ - buildTemplateMessageFromPayload: typeof mod.buildTemplateMessageFromPayload, - downloadLineMedia: typeof mod.downloadLineMedia, - isSenderAllowed: typeof mod.isSenderAllowed, - probeLineBot: typeof mod.probeLineBot, - pushMessageLine: typeof mod.pushMessageLine, - }), -); -`; - try { - const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], { - cwd: root, - encoding: "utf-8", - }); - expect(JSON.parse(raw)).toEqual({ - buildTemplateMessageFromPayload: "function", - downloadLineMedia: "function", - isSenderAllowed: "function", - probeLineBot: "function", - pushMessageLine: "function", - }); - } finally { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); - } + expect( + loadRuntimeApiExportTypesViaJiti({ + modulePath: runtimeApiPath, + exportNames: [ + "buildTemplateMessageFromPayload", + "downloadLineMedia", + "isSenderAllowed", + "probeLineBot", + "pushMessageLine", + ], + }), + ).toEqual({ + buildTemplateMessageFromPayload: "function", + downloadLineMedia: "function", + isSenderAllowed: "function", + probeLineBot: "function", + pushMessageLine: "function", + }); }, 240_000); + + it("keeps the LINE pre-export block aligned with plugin-sdk/line-runtime overlap", () => { + const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts"); + const lineRuntimePath = path.join(process.cwd(), "src", "plugin-sdk", "line-runtime.ts"); + + expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual( + collectRuntimeApiOverlapExports({ + lineRuntimePath, + runtimeApiPath, + }), + ); + }); }); diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 7e894d2b87a..894f35867ab 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -5,7 +5,7 @@ import { normalizeAccountId, resolveLineAccount, type LineConfig, -} from "../runtime-api.js"; +} from "./setup-runtime-api.js"; const channel = "line" as const; diff --git a/extensions/line/src/setup-runtime-api.ts b/extensions/line/src/setup-runtime-api.ts new file mode 100644 index 00000000000..1ee56411c9e --- /dev/null +++ b/extensions/line/src/setup-runtime-api.ts @@ -0,0 +1,9 @@ +export { + DEFAULT_ACCOUNT_ID, + formatDocsLink, + setSetupChannelEnabled, + splitSetupEntries, +} from "openclaw/plugin-sdk/setup"; +export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +export { listLineAccountIds, normalizeAccountId, resolveLineAccount } from "./accounts.js"; +export type { LineConfig } from "./types.js"; diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts index aa53dc7f69e..360d5f1ac79 100644 --- a/extensions/line/src/setup-surface.ts +++ b/extensions/line/src/setup-surface.ts @@ -3,6 +3,12 @@ import { createStandardChannelSetupStatus, createTopLevelChannelDmPolicy, } from "openclaw/plugin-sdk/setup"; +import { + isLineConfigured, + listLineAccountIds, + parseLineAllowFromId, + patchLineAccountConfig, +} from "./setup-core.js"; import { DEFAULT_ACCOUNT_ID, formatDocsLink, @@ -11,13 +17,7 @@ import { splitSetupEntries, type ChannelSetupDmPolicy, type ChannelSetupWizard, -} from "../runtime-api.js"; -import { - isLineConfigured, - listLineAccountIds, - parseLineAllowFromId, - patchLineAccountConfig, -} from "./setup-core.js"; +} from "./setup-runtime-api.js"; const channel = "line" as const; diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index 41fbdb0a76d..21c32ef8e8b 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -1,10 +1,6 @@ import path from "node:path"; -import { createJiti } from "jiti"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - buildPluginLoaderJitiOptions, - resolvePluginSdkScopedAliasMap, -} from "../../src/plugins/sdk-alias.ts"; +import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts"; const setMatrixRuntimeMock = vi.hoisted(() => vi.fn()); const registerChannelMock = vi.hoisted(() => vi.fn()); @@ -22,16 +18,17 @@ describe("matrix plugin registration", () => { it("loads the matrix runtime api through Jiti", () => { const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts"); - const jiti = createJiti(import.meta.url, { - ...buildPluginLoaderJitiOptions( - resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }), - ), - tryNative: false, - }); - - expect(jiti(runtimeApiPath)).toMatchObject({ - requiresExplicitMatrixDefaultAccount: expect.any(Function), - resolveMatrixDefaultOrOnlyAccountId: expect.any(Function), + expect( + loadRuntimeApiExportTypesViaJiti({ + modulePath: runtimeApiPath, + exportNames: [ + "requiresExplicitMatrixDefaultAccount", + "resolveMatrixDefaultOrOnlyAccountId", + ], + }), + ).toEqual({ + requiresExplicitMatrixDefaultAccount: "function", + resolveMatrixDefaultOrOnlyAccountId: "function", }); }, 240_000); @@ -43,15 +40,13 @@ describe("matrix plugin registration", () => { "src", "runtime-api.ts", ); - const jiti = createJiti(import.meta.url, { - ...buildPluginLoaderJitiOptions( - resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }), - ), - tryNative: false, - }); - - expect(jiti(runtimeApiPath)).toMatchObject({ - resolveMatrixAccountStringValues: expect.any(Function), + expect( + loadRuntimeApiExportTypesViaJiti({ + modulePath: runtimeApiPath, + exportNames: ["resolveMatrixAccountStringValues"], + }), + ).toEqual({ + resolveMatrixAccountStringValues: "function", }); }, 240_000); diff --git a/test/helpers/extensions/jiti-runtime-api.ts b/test/helpers/extensions/jiti-runtime-api.ts new file mode 100644 index 00000000000..11aa25358b0 --- /dev/null +++ b/test/helpers/extensions/jiti-runtime-api.ts @@ -0,0 +1,83 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const JITI_EXTENSIONS = [ + ".ts", + ".tsx", + ".mts", + ".cts", + ".mtsx", + ".ctsx", + ".js", + ".mjs", + ".cjs", + ".json", +] as const; + +const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/"; + +function collectPluginSdkDistAliases(params: { + modulePath: string; + root: string; +}): Record { + const sourceText = readFileSync(params.modulePath, "utf8"); + const specifiers = new Set(); + + for (const match of sourceText.matchAll(/["'](openclaw\/plugin-sdk(?:\/[^"']+)?)["']/g)) { + const specifier = match[1]; + if (!specifier?.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) { + continue; + } + specifiers.add(specifier); + } + + return Object.fromEntries( + Array.from(specifiers, (specifier) => { + const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length); + return [specifier, path.join(params.root, "dist", "plugin-sdk", `${subpath}.js`)]; + }), + ); +} + +export function loadRuntimeApiExportTypesViaJiti(params: { + modulePath: string; + exportNames: readonly string[]; + additionalAliases?: Record; +}): Record { + const root = process.cwd(); + const alias = { + ...collectPluginSdkDistAliases({ modulePath: params.modulePath, root }), + ...params.additionalAliases, + }; + + const script = ` +import path from "node:path"; +import { createJiti } from "jiti"; + +const modulePath = ${JSON.stringify(params.modulePath)}; +const exportNames = ${JSON.stringify(params.exportNames)}; +const alias = ${JSON.stringify(alias)}; +const jiti = createJiti(path.join(${JSON.stringify(root)}, "openclaw.mjs"), { + interopDefault: true, + tryNative: false, + fsCache: false, + moduleCache: false, + extensions: ${JSON.stringify(JITI_EXTENSIONS)}, + alias, +}); +const mod = jiti(modulePath); +console.log( + JSON.stringify( + Object.fromEntries(exportNames.map((name) => [name, typeof mod[name]])), + ), +); +`; + + const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], { + cwd: root, + encoding: "utf-8", + }); + + return JSON.parse(raw) as Record; +}