mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
test: smoke packed bundled channel entries
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
@@ -167,6 +176,77 @@ function runPackDry(): PackResult[] {
|
||||
return JSON.parse(raw) as PackResult[];
|
||||
}
|
||||
|
||||
function runPack(packDestination: string): PackResult[] {
|
||||
const raw = execFileSync(
|
||||
"npm",
|
||||
["pack", "--json", "--ignore-scripts", "--pack-destination", packDestination],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 1024 * 1024 * 100,
|
||||
},
|
||||
);
|
||||
return JSON.parse(raw) as PackResult[];
|
||||
}
|
||||
|
||||
function resolvePackedTarballPath(packDestination: string, results: PackResult[]): string {
|
||||
const filenames = results
|
||||
.map((entry) => entry.filename)
|
||||
.filter((filename): filename is string => typeof filename === "string" && filename.length > 0);
|
||||
if (filenames.length !== 1) {
|
||||
throw new Error(
|
||||
`release-check: npm pack produced ${filenames.length} tarballs; expected exactly one.`,
|
||||
);
|
||||
}
|
||||
return resolve(packDestination, filenames[0]);
|
||||
}
|
||||
|
||||
function linkRootNodeModules(packageRoot: string): void {
|
||||
const rootNodeModules = resolve("node_modules");
|
||||
if (!existsSync(rootNodeModules)) {
|
||||
return;
|
||||
}
|
||||
symlinkSync(
|
||||
rootNodeModules,
|
||||
join(packageRoot, "node_modules"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
}
|
||||
|
||||
function runPackedBundledChannelEntrySmoke(): void {
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-"));
|
||||
try {
|
||||
const packDir = join(tmpRoot, "pack");
|
||||
const extractDir = join(tmpRoot, "extract");
|
||||
mkdirSync(packDir);
|
||||
mkdirSync(extractDir);
|
||||
|
||||
const packResults = runPack(packDir);
|
||||
const tarballPath = resolvePackedTarballPath(packDir, packResults);
|
||||
execFileSync("tar", ["-xzf", tarballPath, "-C", extractDir], { stdio: "inherit" });
|
||||
|
||||
const packageRoot = join(extractDir, "package");
|
||||
linkRootNodeModules(packageRoot);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
resolve("scripts/test-built-bundled-channel-entry-smoke.mjs"),
|
||||
"--package-root",
|
||||
packageRoot,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function collectMissingPackPaths(paths: Iterable<string>): string[] {
|
||||
const available = new Set(paths);
|
||||
return requiredPathGroups
|
||||
@@ -444,7 +524,9 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("release-check: npm pack contents look OK.");
|
||||
runPackedBundledChannelEntrySmoke();
|
||||
|
||||
console.log("release-check: npm pack contents and bundled channel entrypoints look OK.");
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
@@ -37,10 +38,98 @@ function installProcessWarningFilter() {
|
||||
|
||||
installProcessWarningFilter();
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.env.OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK ??= "1";
|
||||
|
||||
async function importBuiltModule(relativePath) {
|
||||
return import(pathToFileURL(path.join(repoRoot, relativePath)).href);
|
||||
function parseArgs(argv) {
|
||||
let packageRoot = process.env.OPENCLAW_BUNDLED_CHANNEL_SMOKE_ROOT;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--package-root") {
|
||||
packageRoot = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg?.startsWith("--package-root=")) {
|
||||
packageRoot = arg.slice("--package-root=".length);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
return {
|
||||
packageRoot: path.resolve(
|
||||
packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { packageRoot } = parseArgs(process.argv.slice(2));
|
||||
const distExtensionsRoot = path.join(packageRoot, "dist", "extensions");
|
||||
|
||||
async function importBuiltModule(absolutePath) {
|
||||
return import(pathToFileURL(absolutePath).href);
|
||||
}
|
||||
|
||||
function readJson(pathname) {
|
||||
return JSON.parse(fs.readFileSync(pathname, "utf8"));
|
||||
}
|
||||
|
||||
function extensionEntryToDistFilename(entry) {
|
||||
return entry.replace(/^\.\//u, "").replace(/\.[^.]+$/u, ".js");
|
||||
}
|
||||
|
||||
function collectBundledChannelEntryFiles() {
|
||||
const files = [];
|
||||
for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const extensionRoot = path.join(distExtensionsRoot, dirent.name);
|
||||
const packageJsonPath = path.join(extensionRoot, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
continue;
|
||||
}
|
||||
const packageJson = readJson(packageJsonPath);
|
||||
if (!packageJson.openclaw?.channel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionEntries =
|
||||
Array.isArray(packageJson.openclaw.extensions) && packageJson.openclaw.extensions.length > 0
|
||||
? packageJson.openclaw.extensions
|
||||
: ["./index.ts"];
|
||||
for (const entry of extensionEntries) {
|
||||
if (typeof entry !== "string" || entry.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
id: dirent.name,
|
||||
kind: "channel",
|
||||
path: path.join(extensionRoot, extensionEntryToDistFilename(entry)),
|
||||
});
|
||||
}
|
||||
|
||||
const setupEntry = packageJson.openclaw.setupEntry;
|
||||
if (typeof setupEntry === "string" && setupEntry.trim().length > 0) {
|
||||
files.push({
|
||||
id: dirent.name,
|
||||
kind: "setup",
|
||||
path: path.join(extensionRoot, extensionEntryToDistFilename(setupEntry)),
|
||||
});
|
||||
}
|
||||
|
||||
const channelEntryPath = path.join(extensionRoot, "channel-entry.js");
|
||||
if (fs.existsSync(channelEntryPath)) {
|
||||
files.push({
|
||||
id: dirent.name,
|
||||
kind: "channel",
|
||||
path: channelEntryPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files.toSorted((left, right) =>
|
||||
`${left.id}:${left.kind}:${left.path}`.localeCompare(`${right.id}:${right.kind}:${right.path}`),
|
||||
);
|
||||
}
|
||||
|
||||
function assertSecretContractShape(secrets, context) {
|
||||
@@ -56,38 +145,78 @@ function assertSecretContractShape(secrets, context) {
|
||||
);
|
||||
}
|
||||
|
||||
const telegramSetupEntry = (await importBuiltModule("dist/extensions/telegram/setup-entry.js"))
|
||||
.default;
|
||||
assert.equal(
|
||||
telegramSetupEntry.kind,
|
||||
"bundled-channel-setup-entry",
|
||||
"telegram setup entry kind mismatch",
|
||||
);
|
||||
const telegramSetupPlugin = telegramSetupEntry.loadSetupPlugin();
|
||||
assert.equal(telegramSetupPlugin?.id, "telegram", "telegram setup plugin failed to load");
|
||||
assertSecretContractShape(
|
||||
telegramSetupEntry.loadSetupSecrets?.(),
|
||||
"telegram setup entry packaged secrets",
|
||||
);
|
||||
function assertEntryFileExists(entry) {
|
||||
assert.ok(
|
||||
fs.existsSync(entry.path),
|
||||
`${entry.id} ${entry.kind} entry missing from packed dist: ${entry.path}`,
|
||||
);
|
||||
}
|
||||
|
||||
const telegramEntry = (await importBuiltModule("dist/extensions/telegram/index.js")).default;
|
||||
assert.equal(telegramEntry.kind, "bundled-channel-entry", "telegram entry kind mismatch");
|
||||
const telegramPlugin = telegramEntry.loadChannelPlugin();
|
||||
assert.equal(telegramPlugin?.id, "telegram", "telegram channel plugin failed to load");
|
||||
assertSecretContractShape(
|
||||
telegramEntry.loadChannelSecrets?.(),
|
||||
"telegram channel packaged secrets",
|
||||
);
|
||||
async function smokeChannelEntry(entryFile) {
|
||||
assertEntryFileExists(entryFile);
|
||||
const entry = (await importBuiltModule(entryFile.path)).default;
|
||||
assert.equal(entry.kind, "bundled-channel-entry", `${entryFile.id} channel entry kind mismatch`);
|
||||
assert.equal(
|
||||
typeof entry.loadChannelPlugin,
|
||||
"function",
|
||||
`${entryFile.id} channel entry missing loadChannelPlugin`,
|
||||
);
|
||||
const plugin = entry.loadChannelPlugin();
|
||||
assert.equal(plugin?.id, entryFile.id, `${entryFile.id} channel plugin failed to load`);
|
||||
if (entry.loadChannelSecrets) {
|
||||
assertSecretContractShape(
|
||||
entry.loadChannelSecrets(),
|
||||
`${entryFile.id} channel entry packaged secrets`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const slackSetupEntry = (await importBuiltModule("dist/extensions/slack/setup-entry.js")).default;
|
||||
assert.equal(
|
||||
slackSetupEntry.kind,
|
||||
"bundled-channel-setup-entry",
|
||||
"slack setup entry kind mismatch",
|
||||
);
|
||||
assertSecretContractShape(
|
||||
slackSetupEntry.loadSetupSecrets?.(),
|
||||
"slack setup entry packaged secrets",
|
||||
);
|
||||
async function smokeSetupEntry(entryFile) {
|
||||
assertEntryFileExists(entryFile);
|
||||
const entry = (await importBuiltModule(entryFile.path)).default;
|
||||
if (entry?.kind !== "bundled-channel-setup-entry") {
|
||||
return false;
|
||||
}
|
||||
assert.equal(
|
||||
entry.kind,
|
||||
"bundled-channel-setup-entry",
|
||||
`${entryFile.id} setup entry kind mismatch`,
|
||||
);
|
||||
assert.equal(
|
||||
typeof entry.loadSetupPlugin,
|
||||
"function",
|
||||
`${entryFile.id} setup entry missing loadSetupPlugin`,
|
||||
);
|
||||
const plugin = entry.loadSetupPlugin();
|
||||
assert.equal(plugin?.id, entryFile.id, `${entryFile.id} setup plugin failed to load`);
|
||||
if (entry.loadSetupSecrets) {
|
||||
assertSecretContractShape(
|
||||
entry.loadSetupSecrets(),
|
||||
`${entryFile.id} setup entry packaged secrets`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
process.stdout.write("[build-smoke] bundled channel entry smoke passed\n");
|
||||
const entryFiles = collectBundledChannelEntryFiles();
|
||||
let channelCount = 0;
|
||||
let setupCount = 0;
|
||||
let legacySetupCount = 0;
|
||||
|
||||
for (const entryFile of entryFiles) {
|
||||
if (entryFile.kind === "channel") {
|
||||
await smokeChannelEntry(entryFile);
|
||||
channelCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (await smokeSetupEntry(entryFile)) {
|
||||
setupCount += 1;
|
||||
} else {
|
||||
legacySetupCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(channelCount > 0, "no bundled channel entries found");
|
||||
process.stdout.write(
|
||||
`[build-smoke] bundled channel entry smoke passed packageRoot=${packageRoot} channel=${channelCount} setup=${setupCount} legacySetup=${legacySetupCount}\n`,
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ afterEach(() => {
|
||||
}
|
||||
vi.resetModules();
|
||||
vi.doUnmock("jiti");
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("loadBundledEntryExportSync", () => {
|
||||
@@ -84,4 +85,39 @@ describe("loadBundledEntryExportSync", () => {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("can disable source-tree fallback for dist bundled entry checks", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-"));
|
||||
tempDirs.push(tempRoot);
|
||||
|
||||
fs.writeFileSync(path.join(tempRoot, "package.json"), '{"name":"openclaw"}\n', "utf8");
|
||||
const pluginRoot = path.join(tempRoot, "dist", "extensions", "telegram");
|
||||
const sourceRoot = path.join(tempRoot, "extensions", "telegram", "src");
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
fs.mkdirSync(sourceRoot, { recursive: true });
|
||||
|
||||
const importerPath = path.join(pluginRoot, "index.js");
|
||||
fs.writeFileSync(importerPath, "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(sourceRoot, "secret-contract.ts"),
|
||||
"export const sentinel = 42;\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(
|
||||
loadBundledEntryExportSync<number>(pathToFileURL(importerPath).href, {
|
||||
specifier: "./src/secret-contract.js",
|
||||
exportName: "sentinel",
|
||||
}),
|
||||
).toBe(42);
|
||||
|
||||
vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", "1");
|
||||
|
||||
expect(() =>
|
||||
loadBundledEntryExportSync<number>(pathToFileURL(importerPath).href, {
|
||||
specifier: "./src/secret-contract.js",
|
||||
exportName: "sentinel",
|
||||
}),
|
||||
).toThrow(`resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,11 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
|
||||
const loadedModuleExports = new Map<string, unknown>();
|
||||
const disableBundledEntrySourceFallbackEnv = "OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK";
|
||||
|
||||
function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
return value !== undefined && !/^(?:0|false)$/iu.test(value.trim());
|
||||
}
|
||||
|
||||
function resolveSpecifierCandidates(modulePath: string): string[] {
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(modulePath));
|
||||
@@ -140,6 +145,9 @@ function resolveBundledEntryModuleCandidates(
|
||||
if (!importerPath.startsWith(distExtensionsRoot)) {
|
||||
return candidates;
|
||||
}
|
||||
if (isTruthyEnvFlag(process.env[disableBundledEntrySourceFallbackEnv])) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const pluginDirName = path.basename(importerDir);
|
||||
const sourcePluginRoot = path.join(packageRoot, "extensions", pluginDirName);
|
||||
|
||||
Reference in New Issue
Block a user