mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 05:56:18 +00:00
* feat(plugins): support manifest-backed browser assets * fix(plugins): validate asset roots and clarify module timeouts * fix(plugins): sandbox asset documents and support Turbo imports * docs(gateway): keep guidance within the merged size budget --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
194 lines
6.7 KiB
TypeScript
194 lines
6.7 KiB
TypeScript
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
|
|
|
|
import { fetchFrontendExtensions } from "@/core/extensions/api";
|
|
import { loadFrontendExtensions } from "@/core/extensions/registry";
|
|
|
|
const config = rs.hoisted(() => ({ backend: "" }));
|
|
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => config.backend }));
|
|
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
|
|
rs.mock("@/core/api/static-response", () => ({ staticApiResponse: rs.fn() }));
|
|
|
|
const entry = {
|
|
namespace: "community.bookmarks",
|
|
module: "bookmarks.v1",
|
|
entry: `/api/plugins/modules/bookmarks.v1/${"a".repeat(64)}.mjs`,
|
|
title: "Bookmarks",
|
|
description: "",
|
|
settings: { enabled: true },
|
|
};
|
|
const extension = { apiVersion: 1, module: entry.module };
|
|
const code = "export default {apiVersion: 1, module: 'bookmarks.v1'};";
|
|
|
|
beforeEach(() => {
|
|
config.backend = "";
|
|
rs.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
});
|
|
afterEach(() => {
|
|
rs.restoreAllMocks();
|
|
});
|
|
|
|
for (const backend of [
|
|
"",
|
|
"https://backend.example",
|
|
"https://backend.example/deerflow",
|
|
"/gateway",
|
|
]) {
|
|
test(`loads authenticated code with backend base ${backend || "same origin"}`, async () => {
|
|
config.backend = backend;
|
|
const request = rs
|
|
.spyOn(globalThis, "fetch")
|
|
.mockResolvedValue(new Response(code));
|
|
const revoke = rs.spyOn(URL, "revokeObjectURL");
|
|
const importer = rs.fn(async (url: string) => {
|
|
expect(url.startsWith("blob:")).toBe(true);
|
|
// Read the actual blob without using the spied HTTP fetch.
|
|
const { resolveObjectURL } = await import("node:buffer");
|
|
expect(await resolveObjectURL(url)?.text()).toBe(code);
|
|
expect(revoke).not.toHaveBeenCalled();
|
|
return { default: extension };
|
|
});
|
|
const result = await loadFrontendExtensions([entry], importer);
|
|
expect(result[0]?.extension).toEqual(extension);
|
|
expect(request).toHaveBeenCalledWith(
|
|
`${backend}${entry.entry}`,
|
|
expect.objectContaining({ credentials: "include", cache: "no-store" }),
|
|
);
|
|
expect(importer).toHaveBeenCalledTimes(1);
|
|
expect(revoke).toHaveBeenCalledWith(importer.mock.calls[0]![0]);
|
|
});
|
|
}
|
|
|
|
test("rejected HTTP responses never execute as modules", async () => {
|
|
const request = rs.spyOn(globalThis, "fetch");
|
|
const create = rs.spyOn(URL, "createObjectURL");
|
|
const importer = rs.fn();
|
|
for (const status of [403, 404, 500]) {
|
|
request.mockResolvedValue(new Response("not javascript", { status }));
|
|
const result = await loadFrontendExtensions([entry], importer);
|
|
expect(result[0]?.error).toBeTruthy();
|
|
expect(result[0]?.extension).toBeUndefined();
|
|
}
|
|
expect(create).not.toHaveBeenCalled();
|
|
expect(importer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("failed import releases its blob and does not prevent another plugin loading", async () => {
|
|
rs.spyOn(globalThis, "fetch").mockImplementation(
|
|
async () => new Response(code),
|
|
);
|
|
const revoke = rs.spyOn(URL, "revokeObjectURL");
|
|
const importer = rs
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("module failed"))
|
|
.mockResolvedValueOnce({ default: extension });
|
|
const result = await loadFrontendExtensions([entry, entry], importer);
|
|
expect(result[0]?.error).toBeTruthy();
|
|
expect(result[1]?.extension).toEqual(extension);
|
|
expect(revoke).toHaveBeenCalledTimes(2);
|
|
for (const [url] of importer.mock.calls)
|
|
expect(revoke).toHaveBeenCalledWith(url);
|
|
});
|
|
|
|
test("disabled, backend-only and invalid entries never fetch or import", async () => {
|
|
const request = rs.spyOn(globalThis, "fetch");
|
|
const importer = rs.fn();
|
|
await loadFrontendExtensions(
|
|
[
|
|
{ ...entry, settings: { enabled: false } },
|
|
{ ...entry, module: null, entry: null },
|
|
{ ...entry, entry: "https://untrusted.example/code.mjs" },
|
|
{ ...entry, entry: `${entry.entry}?redirect=elsewhere` },
|
|
],
|
|
importer,
|
|
);
|
|
expect(request).not.toHaveBeenCalled();
|
|
expect(importer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
for (const backend of ["", "https://backend.example/prefix", "/gateway"]) {
|
|
test(`packaged entries preserve resource URLs with base ${backend}`, async () => {
|
|
config.backend = backend;
|
|
const assets = {
|
|
...entry,
|
|
transport: "assets-v1" as const,
|
|
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/static/dist/index.mjs`,
|
|
};
|
|
const importer = rs.fn();
|
|
const assetImporter = rs.fn(async () => ({ default: extension }));
|
|
const result = await loadFrontendExtensions(
|
|
[assets],
|
|
importer,
|
|
assetImporter,
|
|
);
|
|
expect(result[0]?.extension).toEqual(extension);
|
|
expect(assetImporter).toHaveBeenCalledWith(backend + assets.entry);
|
|
expect(importer).not.toHaveBeenCalled();
|
|
assetImporter.mockClear();
|
|
for (const path of [
|
|
"../index.mjs",
|
|
"%2e%2e/index.mjs",
|
|
"index.mjs?x",
|
|
"index.css",
|
|
]) {
|
|
const invalid = {
|
|
...assets,
|
|
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/${path}`,
|
|
};
|
|
expect(
|
|
(await loadFrontendExtensions([invalid], importer, assetImporter))[0]
|
|
?.error,
|
|
).toBeTruthy();
|
|
}
|
|
expect(assetImporter).not.toHaveBeenCalled();
|
|
});
|
|
}
|
|
|
|
test("unknown transports and failing packaged plugins are isolated", async () => {
|
|
const importer = rs.fn();
|
|
const assetImporter = rs
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("missing chunk"))
|
|
.mockResolvedValueOnce({ default: extension });
|
|
const assets = {
|
|
...entry,
|
|
transport: "assets-v1" as const,
|
|
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
|
|
};
|
|
const unknown = { ...entry, transport: "future-v9" as "assets-v1" };
|
|
const result = await loadFrontendExtensions(
|
|
[unknown, assets, assets],
|
|
importer,
|
|
assetImporter,
|
|
);
|
|
expect(result.map((item) => !!item.extension)).toEqual([false, false, true]);
|
|
expect(importer).not.toHaveBeenCalled();
|
|
expect(assetImporter).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
test("discovery preserves transport negotiation through parsing and isolates newer transports", async () => {
|
|
const assets = {
|
|
...entry,
|
|
transport: "assets-v1",
|
|
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
|
|
};
|
|
rs.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify([assets, { ...assets, transport: "future-v9" }]),
|
|
),
|
|
);
|
|
const discovered = await fetchFrontendExtensions();
|
|
expect(discovered.map((item) => item.transport)).toEqual([
|
|
"assets-v1",
|
|
"future-v9",
|
|
]);
|
|
const assetImporter = rs.fn(async () => ({ default: extension }));
|
|
const loaded = await loadFrontendExtensions(
|
|
discovered,
|
|
rs.fn(),
|
|
assetImporter,
|
|
);
|
|
expect(loaded[0]?.extension).toEqual(extension);
|
|
expect(loaded[1]?.error).toBeTruthy();
|
|
expect(assetImporter).toHaveBeenCalledTimes(1);
|
|
});
|