diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts index cc337c16fcf..ca2a5d1827a 100644 --- a/src/plugin-sdk/entrypoints.ts +++ b/src/plugin-sdk/entrypoints.ts @@ -5,10 +5,8 @@ export const pluginSdkEntrypoints = [...pluginSdkEntryList]; export const pluginSdkSubpaths = pluginSdkEntrypoints.filter((entry) => entry !== "index"); /** Map every SDK entrypoint name to its source file path inside the repo. */ -export function buildPluginSdkEntrySources() { - return Object.fromEntries( - pluginSdkEntrypoints.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`]), - ); +export function buildPluginSdkEntrySources(entries: readonly string[] = pluginSdkEntrypoints) { + return Object.fromEntries(entries.map((entry) => [entry, `src/plugin-sdk/${entry}.ts`])); } /** List the public package specifiers that should resolve to plugin SDK entrypoints. */ diff --git a/src/plugin-sdk/index.bundle.test.ts b/src/plugin-sdk/index.bundle.test.ts index 796108cb075..4de7517f533 100644 --- a/src/plugin-sdk/index.bundle.test.ts +++ b/src/plugin-sdk/index.bundle.test.ts @@ -12,58 +12,55 @@ const bundledRepresentativeEntrypoints = [ "matrix-runtime-heavy", "windows-spawn", ] as const; - -function buildBundledCoverageEntrySources() { - const allEntrySources = buildPluginSdkEntrySources(); - return Object.fromEntries( - bundledRepresentativeEntrypoints.map((entry) => [entry, allEntrySources[entry]]), - ); -} +const bundledCoverageEntrySources = buildPluginSdkEntrySources(bundledRepresentativeEntrypoints); describe("plugin-sdk bundled exports", () => { it("emits importable bundled subpath entries", { timeout: 120_000 }, async () => { - const bundleTempRoot = path.join(process.cwd(), ".tmp"); + const bundleTempRoot = path.join( + process.cwd(), + "node_modules", + ".cache", + "openclaw-plugin-sdk-build", + ); await fs.mkdir(bundleTempRoot, { recursive: true }); - const outDir = await fs.mkdtemp(path.join(bundleTempRoot, "openclaw-plugin-sdk-build-")); + const outDir = path.join(bundleTempRoot, "bundle"); + await fs.rm(outDir, { recursive: true, force: true }); + await fs.mkdir(outDir, { recursive: true }); - try { - const { build } = await import(tsdownModuleUrl); - await build({ - clean: false, - config: false, - dts: false, - // Full plugin-sdk coverage belongs to `pnpm build`, package contract - // guardrails, and `subpaths.test.ts`. This file only keeps the expensive - // bundler path honest across representative entrypoint families. - entry: buildBundledCoverageEntrySources(), - env: { NODE_ENV: "production" }, - fixedExtension: false, - logLevel: "error", - outDir, - platform: "node", - }); + const { build } = await import(tsdownModuleUrl); + await build({ + clean: false, + config: false, + dts: false, + // Full plugin-sdk coverage belongs to `pnpm build`, package contract + // guardrails, and `subpaths.test.ts`. This file only keeps the expensive + // bundler path honest across representative entrypoint families. + entry: bundledCoverageEntrySources, + env: { NODE_ENV: "production" }, + fixedExtension: false, + logLevel: "error", + outDir, + platform: "node", + }); - expect(pluginSdkEntrypoints.length).toBeGreaterThan(bundledRepresentativeEntrypoints.length); - await Promise.all( - bundledRepresentativeEntrypoints.map(async (entry) => { - await expect(fs.stat(path.join(outDir, `${entry}.js`))).resolves.toBeTruthy(); - }), - ); + expect(pluginSdkEntrypoints.length).toBeGreaterThan(bundledRepresentativeEntrypoints.length); + await Promise.all( + bundledRepresentativeEntrypoints.map(async (entry) => { + await expect(fs.stat(path.join(outDir, `${entry}.js`))).resolves.toBeTruthy(); + }), + ); - // Export list and package-specifier coverage already live in - // package-contract-guardrails.test.ts and subpaths.test.ts. Keep this file - // focused on the expensive part: can tsdown emit working bundle artifacts? - const importResults = await Promise.all( - bundledRepresentativeEntrypoints.map(async (entry) => [ - entry, - typeof (await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href)), - ]), - ); - expect(Object.fromEntries(importResults)).toEqual( - Object.fromEntries(bundledRepresentativeEntrypoints.map((entry) => [entry, "object"])), - ); - } finally { - await fs.rm(outDir, { recursive: true, force: true }); - } + // Export list and package-specifier coverage already live in + // package-contract-guardrails.test.ts and subpaths.test.ts. Keep this file + // focused on the expensive part: can tsdown emit working bundle artifacts? + const importResults = await Promise.all( + bundledRepresentativeEntrypoints.map(async (entry) => [ + entry, + typeof (await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href)), + ]), + ); + expect(Object.fromEntries(importResults)).toEqual( + Object.fromEntries(bundledRepresentativeEntrypoints.map((entry) => [entry, "object"])), + ); }); }); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 89eda599ac6..4f7048e3bca 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import type { BaseProbeResult as ContractBaseProbeResult, BaseTokenResolution as ContractBaseTokenResolution, @@ -46,11 +47,11 @@ import { pluginSdkSubpaths } from "./entrypoints.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const PLUGIN_SDK_DIR = resolve(ROOT_DIR, "plugin-sdk"); +const requireFromHere = createRequire(import.meta.url); const sourceCache = new Map(); const representativeRuntimeSmokeSubpaths = [ "channel-runtime", "conversation-runtime", - "core", "discord", "provider-auth", "provider-setup", @@ -58,7 +59,8 @@ const representativeRuntimeSmokeSubpaths = [ "webhook-ingress", ] as const; -const importPluginSdkSubpath = (specifier: string) => import(/* @vite-ignore */ specifier); +const importResolvedPluginSdkSubpath = async (specifier: string) => + import(pathToFileURL(requireFromHere.resolve(specifier)).href); function readPluginSdkSource(subpath: string): string { const file = resolve(PLUGIN_SDK_DIR, `${subpath}.ts`); @@ -100,18 +102,25 @@ function sourceMentionsIdentifier(source: string, name: string): boolean { function expectSourceMentions(subpath: string, names: readonly string[]) { const source = readPluginSdkSource(subpath); - for (const name of names) { - expect(sourceMentionsIdentifier(source, name), `${subpath} should mention ${name}`).toBe(true); - } + const missing = names.filter((name) => !sourceMentionsIdentifier(source, name)); + expect(missing, `${subpath} missing exports`).toEqual([]); } function expectSourceOmits(subpath: string, names: readonly string[]) { const source = readPluginSdkSource(subpath); - for (const name of names) { - expect(sourceMentionsIdentifier(source, name), `${subpath} should not mention ${name}`).toBe( - false, - ); - } + const present = names.filter((name) => sourceMentionsIdentifier(source, name)); + expect(present, `${subpath} leaked exports`).toEqual([]); +} + +function expectSourceContract( + subpath: string, + params: { mentions?: readonly string[]; omits?: readonly string[] }, +) { + const source = readPluginSdkSource(subpath); + const missing = (params.mentions ?? []).filter((name) => !sourceMentionsIdentifier(source, name)); + const present = (params.omits ?? []).filter((name) => sourceMentionsIdentifier(source, name)); + expect(missing, `${subpath} missing exports`).toEqual([]); + expect(present, `${subpath} leaked exports`).toEqual([]); } describe("plugin-sdk subpath exports", () => { @@ -154,16 +163,15 @@ describe("plugin-sdk subpath exports", () => { ]); }); - it("re-exports the canonical plugin entry helper from core", async () => { - const [coreSdk, pluginEntrySdk] = await Promise.all([ - importPluginSdkSubpath("openclaw/plugin-sdk/core"), - importPluginSdkSubpath("openclaw/plugin-sdk/plugin-entry"), - ]); - expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry); - }); - it("keeps generic helper subpaths aligned", () => { - expectSourceMentions("routing", ["buildAgentSessionKey", "resolveThreadSessionKeys"]); + expectSourceContract("routing", { + mentions: [ + "buildAgentSessionKey", + "resolveThreadSessionKeys", + "normalizeMessageChannel", + "resolveGatewayMessageChannel", + ], + }); expectSourceMentions("reply-payload", [ "buildMediaPayload", "deliverTextOrMediaReply", @@ -183,12 +191,14 @@ describe("plugin-sdk subpath exports", () => { "clearHistoryEntriesIfEnabled", "recordPendingHistoryEntryIfEnabled", ]); - expectSourceOmits("reply-runtime", [ - "buildPendingHistoryContextFromMap", - "clearHistoryEntriesIfEnabled", - "recordPendingHistoryEntryIfEnabled", - "DEFAULT_GROUP_HISTORY_LIMIT", - ]); + expectSourceContract("reply-runtime", { + omits: [ + "buildPendingHistoryContextFromMap", + "clearHistoryEntriesIfEnabled", + "recordPendingHistoryEntryIfEnabled", + "DEFAULT_GROUP_HISTORY_LIMIT", + ], + }); expectSourceMentions("account-helpers", ["createAccountListHelpers"]); expectSourceMentions("device-bootstrap", [ "approveDevicePairing", @@ -199,23 +209,23 @@ describe("plugin-sdk subpath exports", () => { "buildDmGroupAccountAllowlistAdapter", "createNestedAllowlistOverrideResolver", ]); - expectSourceMentions("allow-from", [ - "addAllowlistUserEntriesFromConfigEntry", - "buildAllowlistResolutionSummary", - "canonicalizeAllowlistWithResolvedIds", - "mapAllowlistResolutionInputs", - "mergeAllowlist", - "patchAllowlistUsersInConfigEntries", - "summarizeMapping", - ]); - expectSourceMentions("allow-from", [ - "compileAllowlist", - "firstDefined", - "formatAllowlistMatchMeta", - "isSenderIdAllowed", - "mergeDmAllowFromSources", - "resolveAllowlistMatchSimple", - ]); + expectSourceContract("allow-from", { + mentions: [ + "addAllowlistUserEntriesFromConfigEntry", + "buildAllowlistResolutionSummary", + "canonicalizeAllowlistWithResolvedIds", + "mapAllowlistResolutionInputs", + "mergeAllowlist", + "patchAllowlistUsersInConfigEntries", + "summarizeMapping", + "compileAllowlist", + "firstDefined", + "formatAllowlistMatchMeta", + "isSenderIdAllowed", + "mergeDmAllowFromSources", + "resolveAllowlistMatchSimple", + ], + }); expectSourceMentions("runtime", ["createLoggerBackedRuntime"]); expectSourceMentions("discord", [ "buildDiscordComponentMessage", @@ -223,7 +233,6 @@ describe("plugin-sdk subpath exports", () => { "registerBuiltDiscordComponentMessage", "resolveDiscordAccount", ]); - expectSourceMentions("routing", ["normalizeMessageChannel", "resolveGatewayMessageChannel"]); expectSourceMentions("conversation-runtime", [ "recordInboundSession", "recordInboundSessionMetaSafe", @@ -237,12 +246,6 @@ describe("plugin-sdk subpath exports", () => { ]); }); - it("exports infra runtime helpers from the dedicated subpath", async () => { - const infraRuntimeSdk = await importPluginSdkSubpath("openclaw/plugin-sdk/infra-runtime"); - expect(typeof infraRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); - expect(typeof infraRuntimeSdk.resolveOutboundSendDep).toBe("function"); - }); - it("exports channel runtime helpers from the dedicated subpath", () => { expectSourceOmits("channel-runtime", [ "applyChannelMatchMeta", @@ -366,26 +369,43 @@ describe("plugin-sdk subpath exports", () => { "shouldDebounceTextInbound", "toLocationContext", ]); - expectSourceOmits("reply-runtime", [ - "buildMentionRegexes", - "createInboundDebouncer", - "formatInboundEnvelope", - "formatInboundFromLabel", - "matchesMentionPatterns", - "matchesMentionWithExplicit", - "normalizeMentionText", - "resolveEnvelopeFormatOptions", - "resolveInboundDebounceMs", - ]); + expectSourceContract("reply-runtime", { + omits: [ + "buildMentionRegexes", + "createInboundDebouncer", + "formatInboundEnvelope", + "formatInboundFromLabel", + "matchesMentionPatterns", + "matchesMentionWithExplicit", + "normalizeMentionText", + "resolveEnvelopeFormatOptions", + "resolveInboundDebounceMs", + "hasControlCommand", + "buildCommandTextFromArgs", + "buildCommandsPaginationKeyboard", + "buildModelsProviderData", + "listNativeCommandSpecsForConfig", + "listSkillCommandsForAgents", + "normalizeCommandBody", + "resolveCommandAuthorization", + "resolveStoredModelOverride", + "shouldComputeCommandAuthorized", + "shouldHandleTextCommands", + ], + }); expectSourceMentions("channel-setup", [ "createOptionalChannelSetupSurface", "createTopLevelChannelDmPolicy", ]); - expectSourceMentions("channel-actions", [ - "createUnionActionGate", - "listTokenSourcedAccounts", - "resolveReactionMessageId", - ]); + expectSourceContract("channel-actions", { + mentions: [ + "createUnionActionGate", + "listTokenSourcedAccounts", + "resolveReactionMessageId", + "createMessageToolButtonsSchema", + "createMessageToolCardSchema", + ], + }); expectSourceMentions("channel-targets", [ "applyChannelMatchMeta", "buildChannelKeyCandidates", @@ -419,10 +439,6 @@ describe("plugin-sdk subpath exports", () => { "isRecord", "resolveEnabledConfiguredAccountId", ]); - expectSourceMentions("channel-actions", [ - "createMessageToolButtonsSchema", - "createMessageToolCardSchema", - ]); expectSourceMentions("command-auth", [ "buildCommandTextFromArgs", "buildCommandsPaginationKeyboard", @@ -440,19 +456,6 @@ describe("plugin-sdk subpath exports", () => { "shouldComputeCommandAuthorized", "shouldHandleTextCommands", ]); - expectSourceOmits("reply-runtime", [ - "hasControlCommand", - "buildCommandTextFromArgs", - "buildCommandsPaginationKeyboard", - "buildModelsProviderData", - "listNativeCommandSpecsForConfig", - "listSkillCommandsForAgents", - "normalizeCommandBody", - "resolveCommandAuthorization", - "resolveStoredModelOverride", - "shouldComputeCommandAuthorized", - "shouldHandleTextCommands", - ]); }); it("keeps channel contract types on the dedicated subpath", () => { @@ -470,39 +473,6 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); - it("exports channel lifecycle helpers from the dedicated subpath", async () => { - const channelLifecycleSdk = await importPluginSdkSubpath( - "openclaw/plugin-sdk/channel-lifecycle", - ); - expect(typeof channelLifecycleSdk.createDraftStreamLoop).toBe("function"); - expect(typeof channelLifecycleSdk.createFinalizableDraftLifecycle).toBe("function"); - expect(typeof channelLifecycleSdk.runPassiveAccountLifecycle).toBe("function"); - expect(typeof channelLifecycleSdk.createRunStateMachine).toBe("function"); - expect(typeof channelLifecycleSdk.createArmableStallWatchdog).toBe("function"); - }); - - it("exports channel pairing helpers from the dedicated subpath", async () => { - const channelPairingSdk = await importPluginSdkSubpath("openclaw/plugin-sdk/channel-pairing"); - expectSourceMentions("channel-pairing", [ - "createChannelPairingController", - "createChannelPairingChallengeIssuer", - "createLoggedPairingApprovalNotifier", - "createPairingPrefixStripper", - "createTextPairingAdapter", - ]); - expect("createScopedPairingAccess" in channelPairingSdk).toBe(false); - }); - - it("exports channel reply pipeline helpers from the dedicated subpath", async () => { - const channelReplyPipelineSdk = await importPluginSdkSubpath( - "openclaw/plugin-sdk/channel-reply-pipeline", - ); - expectSourceMentions("channel-reply-pipeline", ["createChannelReplyPipeline"]); - expect("createTypingCallbacks" in channelReplyPipelineSdk).toBe(false); - expect("createReplyPrefixContext" in channelReplyPipelineSdk).toBe(false); - expect("createReplyPrefixOptions" in channelReplyPipelineSdk).toBe(false); - }); - it("keeps source-only helper subpaths aligned", () => { expectSourceMentions("channel-send-result", [ "attachChannelToResult", @@ -551,17 +521,15 @@ describe("plugin-sdk subpath exports", () => { "toFormUrlEncoded", ]); expectSourceOmits("core", ["buildOauthProviderAuthResult"]); - expectSourceMentions("provider-models", [ - "applyOpenAIConfig", - "buildKilocodeModelDefinition", - "discoverHuggingfaceModels", - ]); - expectSourceOmits("provider-models", [ - "buildMinimaxModelDefinition", - "buildMoonshotProvider", - "QIANFAN_BASE_URL", - "resolveZaiBaseUrl", - ]); + expectSourceContract("provider-models", { + mentions: ["applyOpenAIConfig", "buildKilocodeModelDefinition", "discoverHuggingfaceModels"], + omits: [ + "buildMinimaxModelDefinition", + "buildMoonshotProvider", + "QIANFAN_BASE_URL", + "resolveZaiBaseUrl", + ], + }); expectSourceMentions("setup", [ "DEFAULT_ACCOUNT_ID", @@ -589,7 +557,6 @@ describe("plugin-sdk subpath exports", () => { "normalizeResolvedSecretInputString", "normalizeSecretInputString", ]); - expectSourceMentions("webhook-ingress", [ "registerPluginHttpRoute", "resolveWebhookPath", @@ -613,10 +580,55 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); }); - it("resolves representative curated public subpaths", async () => { + it("keeps runtime entry subpaths importable", async () => { + const [ + coreSdk, + pluginEntrySdk, + infraRuntimeSdk, + channelLifecycleSdk, + channelPairingSdk, + channelReplyPipelineSdk, + ...representativeModules + ] = await Promise.all([ + importResolvedPluginSdkSubpath("openclaw/plugin-sdk/core"), + importResolvedPluginSdkSubpath("openclaw/plugin-sdk/plugin-entry"), + importResolvedPluginSdkSubpath("openclaw/plugin-sdk/infra-runtime"), + importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-lifecycle"), + importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-pairing"), + importResolvedPluginSdkSubpath("openclaw/plugin-sdk/channel-reply-pipeline"), + ...representativeRuntimeSmokeSubpaths.map((id) => + importResolvedPluginSdkSubpath(`openclaw/plugin-sdk/${id}`), + ), + ]); + + expect(coreSdk.definePluginEntry).toBe(pluginEntrySdk.definePluginEntry); + + expect(typeof infraRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof infraRuntimeSdk.resolveOutboundSendDep).toBe("function"); + + expect(typeof channelLifecycleSdk.createDraftStreamLoop).toBe("function"); + expect(typeof channelLifecycleSdk.createFinalizableDraftLifecycle).toBe("function"); + expect(typeof channelLifecycleSdk.runPassiveAccountLifecycle).toBe("function"); + expect(typeof channelLifecycleSdk.createRunStateMachine).toBe("function"); + expect(typeof channelLifecycleSdk.createArmableStallWatchdog).toBe("function"); + + expectSourceMentions("channel-pairing", [ + "createChannelPairingController", + "createChannelPairingChallengeIssuer", + "createLoggedPairingApprovalNotifier", + "createPairingPrefixStripper", + "createTextPairingAdapter", + ]); + expect("createScopedPairingAccess" in channelPairingSdk).toBe(false); + + expectSourceMentions("channel-reply-pipeline", ["createChannelReplyPipeline"]); + expect("createTypingCallbacks" in channelReplyPipelineSdk).toBe(false); + expect("createReplyPrefixContext" in channelReplyPipelineSdk).toBe(false); + expect("createReplyPrefixOptions" in channelReplyPipelineSdk).toBe(false); + expect(pluginSdkSubpaths.length).toBeGreaterThan(representativeRuntimeSmokeSubpaths.length); - for (const id of representativeRuntimeSmokeSubpaths) { - const mod = await importPluginSdkSubpath(`openclaw/plugin-sdk/${id}`); + for (const [index, id] of representativeRuntimeSmokeSubpaths.entries()) { + const mod = representativeModules[index]; expect(typeof mod).toBe("object"); expect(mod, `subpath ${id} should resolve`).toBeTruthy(); } diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 9550bba7524..43279fbb871 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -840,25 +840,13 @@ describe("installPluginFromDir", () => { expectInstalledWithPluginId(res, extensionsDir, "@openclaw/test-plugin"); }); - it("rejects bare @ as an invalid scoped id", () => { - expect(() => resolvePluginInstallDir("@")).toThrow( - "invalid plugin name: scoped ids must use @scope/name format", - ); - }); + it("keeps scoped install-dir validation aligned", () => { + for (const invalidId of ["@", "@/name", "team/name"]) { + expect(() => resolvePluginInstallDir(invalidId)).toThrow( + "invalid plugin name: scoped ids must use @scope/name format", + ); + } - it("rejects empty scoped segments like @/name", () => { - expect(() => resolvePluginInstallDir("@/name")).toThrow( - "invalid plugin name: scoped ids must use @scope/name format", - ); - }); - - it("rejects two-segment ids without a scope prefix", () => { - expect(() => resolvePluginInstallDir("team/name")).toThrow( - "invalid plugin name: scoped ids must use @scope/name format", - ); - }); - - it("uses a unique hashed install dir for scoped ids", () => { const extensionsDir = path.join(makeTempDir(), "extensions"); const scopedTarget = resolvePluginInstallDir("@scope/name", extensionsDir); const hashedFlatId = safePathSegmentHashed("@scope/name"); diff --git a/test/extension-plugin-sdk-boundary.test.ts b/test/extension-plugin-sdk-boundary.test.ts index 7cf0fb59297..855389e567c 100644 --- a/test/extension-plugin-sdk-boundary.test.ts +++ b/test/extension-plugin-sdk-boundary.test.ts @@ -11,6 +11,9 @@ const pluginSdkInternalInventoryPromise = const relativeOutsidePackageInventoryPromise = collectExtensionPluginSdkBoundaryInventory( "relative-outside-package", ); +const srcOutsideJsonOutputPromise = getJsonOutput("src-outside-plugin-sdk"); +const pluginSdkInternalJsonOutputPromise = getJsonOutput("plugin-sdk-internal"); +const relativeOutsidePackageJsonOutputPromise = getJsonOutput("relative-outside-package"); async function getJsonOutput( mode: Parameters[0], @@ -71,7 +74,7 @@ describe("extension src outside plugin-sdk boundary inventory", () => { }); it("script json output is empty", async () => { - const result = await getJsonOutput("src-outside-plugin-sdk"); + const result = await srcOutsideJsonOutputPromise; expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); @@ -87,7 +90,7 @@ describe("extension plugin-sdk-internal boundary inventory", () => { }); it("script json output is empty", async () => { - const result = await getJsonOutput("plugin-sdk-internal"); + const result = await pluginSdkInternalJsonOutputPromise; expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); @@ -103,7 +106,7 @@ describe("extension relative-outside-package boundary inventory", () => { }); it("script json output is empty", async () => { - const result = await getJsonOutput("relative-outside-package"); + const result = await relativeOutsidePackageJsonOutputPromise; expect(result.exitCode).toBe(0); expect(result.stderr).toBe("");