feat: add portal testing support (#20)

This commit is contained in:
YANG QIA
2026-08-06 20:06:27 +08:00
committed by GitHub
parent 0250da707d
commit c6336c1819
62 changed files with 3375 additions and 2848 deletions
+13
View File
@@ -0,0 +1,13 @@
# Playwright always starts a dedicated Portal dev server on this port.
# NOCOBASE_E2E_PORT=4173
# Keep this aligned with the Portal path being tested.
# NOCOBASE_PORTAL_BASE=/x/demo/
# Point this at the real NocoBase backend used by the test.
# NOCOBASE_E2E_API_URL=http://127.0.0.1:13000/api
NOCOBASE_E2E_ACCOUNT=nocobase
NOCOBASE_E2E_PASSWORD=
NOCOBASE_E2E_AUTHENTICATOR=basic
# NOCOBASE_E2E_ROLE=admin
+17 -13
View File
@@ -257,31 +257,35 @@ jobs:
- name: Build
run: pnpm build
- name: Test SDK and integrations
run: |
pnpm --dir sdk test
node tests/auth-session-regression.mjs
node tests/error-boundary-regression.mjs
node registry/nocobase-acl/tests/acl-regression.mjs
node registry/nocobase-ai/tests/i18n-regression.mjs
node registry/nocobase-i18n/tests/i18n-regression.mjs
node --experimental-strip-types --test tests/file-upload-regression.test.ts
node registry/nocobase-mail/tests/mail-regression.mjs
node registry/nocobase-route-surfaces/tests/route-surfaces-regression.mjs
- name: Pack SDK
if: steps.release.outputs.release_sdk == 'true' || steps.release.outputs.verify_sdk == 'true'
run: |
mkdir -p "$RUNNER_TEMP/portal-sdk-local" "$RUNNER_TEMP/portal-sdk-local-unpacked"
pnpm --dir sdk pack --pack-destination "$RUNNER_TEMP/portal-sdk-local"
tar -xzf "$RUNNER_TEMP/portal-sdk-local/"*.tgz -C "$RUNNER_TEMP/portal-sdk-local-unpacked"
node sdk/tests/verify-packed-package.mjs "$RUNNER_TEMP/portal-sdk-local-unpacked/package"
node sdk/verify-packed-package.mjs "$RUNNER_TEMP/portal-sdk-local-unpacked/package"
- name: Pack template
if: steps.release.outputs.release_template == 'true'
run: |
mkdir -p "$RUNNER_TEMP/portal-template-local"
pnpm pack --pack-destination "$RUNNER_TEMP/portal-template-local"
tar -tzf "$RUNNER_TEMP/portal-template-local/"*.tgz > "$RUNNER_TEMP/portal-template-files.txt"
for required_file in \
package/.env.e2e.example \
package/vitest.config.ts \
package/tests/setup/vitest.ts \
package/playwright.config.ts \
package/e2e/login.spec.ts; do
if ! grep -Fxq "$required_file" "$RUNNER_TEMP/portal-template-files.txt"; then
echo "Packed template is missing $required_file."
exit 1
fi
done
if grep -Eq '^package/(coverage|playwright-report|test-results)/' "$RUNNER_TEMP/portal-template-files.txt"; then
echo "Packed template contains generated test artifacts."
exit 1
fi
- name: Verify existing SDK package
if: steps.release.outputs.verify_sdk == 'true'
+6
View File
@@ -16,10 +16,16 @@ dist-ssr
/src/extensions/
/registry.json
/public/r/
/coverage/
/playwright-report/
/test-results/
/.pnpm-store/
# Environment variables
.env
.env.local
.env.e2e
.env.e2e.local
# Editor directories and files
+5 -1
View File
@@ -6,6 +6,11 @@ logs/
.env
.env.*
!.env.example
!.env.e2e.example
coverage/
playwright-report/
test-results/
registry/
registry.config.json
@@ -13,6 +18,5 @@ registry.json
public/r/
scripts/registry.mjs
sdk/
tests/
.github/
!.gitignore
+7 -1
View File
@@ -4,7 +4,7 @@ This repository is a starter for building a NocoBase-powered application. Keep c
## Reuse existing extensions
Before writing new code, inspect `registry` in the Registry source repository or `src/extensions` in a published template for similar pages, hooks, components, and integration patterns. Reuse an existing implementation directly when it already fits the requirement, and extend or compose it when only a small adaptation is needed.
When the active milestone requires application source changes, search `registry` in the Registry source repository or `src/extensions` in a published template for similar pages, hooks, components, and integration patterns before writing that source. Inspect the closest matching implementation and only the direct dependencies needed to understand its contract. Expand the search when a specific unanswered question blocks implementation; do not recursively read Registry or extension directories to build a complete inventory. Reuse an existing implementation directly when it already fits the requirement, and extend or compose it when only a small adaptation is needed.
## Customize UI components through composition
@@ -16,6 +16,12 @@ Components copied from shadcn/ui are owned and maintained by this project; upstr
Portal production deployments serve the built `dist` output and do not install or execute the project's Node.js dependencies. Add every new package to `devDependencies`, including packages imported by application runtime source, because they are required only while installing, developing, checking, or building the Portal. Use the package manager's development-dependency option and do not add new entries to `dependencies`.
## Test at the correct boundary
Put frontend logic and component tests under `tests/`. Keep them independent of backend data: pass ordinary props to components and test local rendering and interaction without mocking NocoBase APIs, authentication, ACL, or data providers. If behavior requires real data, authentication, roles, permissions, routing integration, files, AI, or another server capability, test it under `e2e/` with Playwright against a real NocoBase test environment. Do not make application tests depend on bundled Registry example pages.
Run the relevant frontend tests while developing and update a focused E2E flow when a critical user workflow changes. Avoid tests that only assert that a component exists or duplicate implementation details.
## Define application routes once
Put application-owned business routes in `src/routes.tsx` with `defineAppRoutes`. A route with a `resource` entry contributes its Refine resource and navigation item, while the same definition generates its React Router route. Mark create, edit, and show children with `resourceAction` so their paths populate the same Refine resource instead of being repeated. Use `access.roles` for route-level role constraints; nested routes inherit parent constraints, and the runtime applies the complete chain to both menu visibility and direct URL access. Do not repeat those roles in `resource.meta.acl` or a manually written route guard.
+40 -12
View File
@@ -104,22 +104,50 @@ Customize `src/pages/login/index.tsx` to replace the whole login page, or use
`DefaultSignInPage` to keep dynamic authentication while overriding individual
authenticator UI.
## Source repository checks
## Testing
Frontend logic and component tests live under `tests/` and run with Vitest and
Testing Library. Registry-owned tests live beside each item under
`registry/<name>/tests`, while Portal SDK tests live under `sdk/tests` and run
through the SDK package. These tests exercise local behavior without mocking
NocoBase APIs, authentication, ACL, or data providers. Put behavior that needs
real server data under `e2e/` instead.
```bash
pnpm --dir sdk test
node tests/auth-session-regression.mjs
node tests/error-boundary-regression.mjs
node registry/nocobase-acl/tests/acl-regression.mjs
node registry/nocobase-ai/tests/i18n-regression.mjs
node registry/nocobase-i18n/tests/i18n-regression.mjs
node --experimental-strip-types --test tests/file-upload-regression.test.ts
node registry/nocobase-mail/tests/mail-regression.mjs
node registry/nocobase-route-surfaces/tests/route-surfaces-regression.mjs
pnpm test
pnpm test:watch
pnpm test:coverage
pnpm test:sdk
```
Registry source checks are maintainer commands for this source repository; the
published template contains materialized extensions instead of `registry/`.
Playwright E2E tests connect to a real NocoBase environment. The included smoke
test signs in through the actual Portal login page, verifies the server session,
and confirms that authentication survives a reload. It does not depend on any
Registry example page.
Copy `.env.e2e.example` to `.env.e2e` and provide a dedicated test account.
Playwright always starts an isolated Portal dev server at
`http://127.0.0.1:4173` (configurable with `NOCOBASE_E2E_PORT`) and stops it when
the run finishes. Set `NOCOBASE_E2E_API_URL` to the real NocoBase backend and
`NOCOBASE_PORTAL_BASE` to the Portal path under test. The default command never
reuses a manually started frontend.
```bash
pnpm test:e2e:install
pnpm test:e2e
pnpm test:e2e:ui
```
Reusable E2E helpers currently live in `e2e/support`. They provide environment
and basename resolution, NocoBase resource/action requests, API sign-in and
session checks, and browser session installation. Keep business-specific test
data and assertions in the application. These helpers can move into the Portal
SDK after their application-facing contract has stabilized.
The root `pnpm test` command includes both template and Registry tests. The
published template contains materialized extensions instead of `registry/`,
but their tests remain source-repository checks and are not copied into user
projects.
Default Template source upgrades are documented in the
[template migration guide](https://github.com/nocobase/portal-template-default/blob/main/MIGRATION.md).
SDK API and compatibility changes are documented separately in the
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
import {
loadPortalE2EEnvironment,
requirePortalE2ECredentials,
resolvePortalTestURL,
} from "./support";
const environment = loadPortalE2EEnvironment();
test("signs in and preserves the session after reload", async ({ page }) => {
const credentials = requirePortalE2ECredentials(environment);
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(resolvePortalTestURL(environment, "/login"));
await page
.getByLabel("Username or email", { exact: true })
.fill(credentials.account);
await page.getByLabel("Password", { exact: true }).fill(credentials.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect
.poll(() => new URL(page.url()).pathname)
.not.toMatch(/\/(?:login|signin)\/?$/);
await page.reload();
await expect
.poll(() => new URL(page.url()).pathname)
.not.toMatch(/\/(?:login|signin)\/?$/);
await expect(page.getByLabel("Username or email")).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
+192
View File
@@ -0,0 +1,192 @@
import type { APIRequestContext } from "@playwright/test";
import {
getNocoBaseErrorMessage,
NocoBaseHttpError,
} from "@nocobase/portal-sdk/client";
import {
resolvePortalApiActionURL,
type PortalE2ECredentials,
type PortalE2EEnvironment,
} from "./environment";
type QueryValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean>;
export type PortalE2ESession = {
token: string;
authenticator: string;
role?: string;
locale?: string;
user?: Record<string, unknown>;
};
export type PortalActionOptions = {
method?: "GET" | "POST" | "PUT" | "DELETE";
query?: Record<string, QueryValue>;
body?: unknown;
headers?: Record<string, string>;
session?: PortalE2ESession;
authenticator?: string;
unwrap?: "data" | "deep-data" | "none";
};
export { NocoBaseHttpError as PortalE2EApiError };
const getTimezone = () => {
const minutes = -new Date().getTimezoneOffset();
const sign = minutes >= 0 ? "+" : "-";
const absolute = Math.abs(minutes);
return `${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(
absolute % 60
).padStart(2, "0")}`;
};
const readPayload = async (text: string) => {
if (!text) return undefined;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
};
const unwrapPayload = <T>(
payload: unknown,
mode: PortalActionOptions["unwrap"]
): T => {
if (mode === "none" || !payload || typeof payload !== "object") {
return payload as T;
}
const data = (payload as { data?: unknown }).data;
if (mode === "deep-data" && data && typeof data === "object") {
return ((data as { data?: unknown }).data ?? data) as T;
}
return (data ?? payload) as T;
};
export async function portalAction<T>(
request: APIRequestContext,
environment: PortalE2EEnvironment,
resource: string,
action: string,
options: PortalActionOptions = {}
) {
const method =
options.method ??
(["get", "list"].includes(action) ? "GET" : "POST");
const url = new URL(resolvePortalApiActionURL(environment, resource, action));
Object.entries(options.query ?? {}).forEach(([key, value]) => {
if (value === undefined || value === null) return;
if (Array.isArray(value)) {
value.forEach((item) => url.searchParams.append(key, String(item)));
} else {
url.searchParams.set(key, String(value));
}
});
const authenticator =
options.authenticator ?? options.session?.authenticator;
const role = options.session?.role;
const locale = options.session?.locale ?? environment.locale;
const response = await request.fetch(url.toString(), {
method,
data: options.body,
headers: {
Accept: "application/json",
...(options.body === undefined
? {}
: { "Content-Type": "application/json" }),
...(authenticator ? { "X-Authenticator": authenticator } : {}),
...(role ? { "X-Role": role } : {}),
...(locale ? { "X-Locale": locale } : {}),
...(environment.portalName
? { "X-Portal": environment.portalName }
: {}),
...(options.session?.token
? { Authorization: `Bearer ${options.session.token}` }
: {}),
"X-With-ACL-Meta": "true",
"X-Timezone": getTimezone(),
...options.headers,
},
});
const payload = await readPayload(await response.text());
if (!response.ok()) {
throw new NocoBaseHttpError({
message: getNocoBaseErrorMessage(
payload,
`NocoBase request failed (${response.status()})`
),
status: response.status(),
payload,
requestId: response.headers()["x-request-id"],
});
}
const renewedToken = response.headers()["x-new-token"];
if (renewedToken && options.session) options.session.token = renewedToken;
return unwrapPayload<T>(payload, options.unwrap ?? "data");
}
export async function signInPortal(
request: APIRequestContext,
environment: PortalE2EEnvironment,
credentials: PortalE2ECredentials
): Promise<PortalE2ESession> {
const result = await portalAction<{
token?: string;
user?: Record<string, unknown>;
}>(request, environment, "auth", "signIn", {
method: "POST",
authenticator: credentials.authenticator,
body: {
account: credentials.account,
password: credentials.password,
},
});
if (!result?.token) {
throw new Error("NocoBase did not return an access token after sign-in.");
}
return {
token: result.token,
authenticator: credentials.authenticator,
role: credentials.role,
locale: environment.locale,
user: result.user,
};
}
export function checkPortalSession(
request: APIRequestContext,
environment: PortalE2EEnvironment,
session: PortalE2ESession
) {
return portalAction<Record<string, unknown>>(
request,
environment,
"auth",
"check",
{ session }
);
}
export function signOutPortal(
request: APIRequestContext,
environment: PortalE2EEnvironment,
session: PortalE2ESession
) {
return portalAction<unknown>(request, environment, "auth", "signOut", {
method: "POST",
session,
});
}
+134
View File
@@ -0,0 +1,134 @@
import {
normalizePortalBase,
resolveNocoBaseAppName,
resolveNocoBasePortalName,
} from "@nocobase/portal-sdk/runtime";
export type PortalE2EStorageType = "localStorage" | "sessionStorage";
export type PortalE2EEnvironment = {
origin: string;
port: number;
baseURL: string;
apiURL: string;
portalBase: string;
portalName?: string;
appName: string;
authenticator: string;
role?: string;
locale?: string;
storagePrefix: string;
storageType: PortalE2EStorageType;
shareToken: boolean;
};
export type PortalE2ECredentials = {
account: string;
password: string;
authenticator: string;
role?: string;
};
type EnvironmentSource = Record<string, string | undefined>;
const readValue = (source: EnvironmentSource, name: string) => {
const value = source[name];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
};
const resolvePort = (source: EnvironmentSource) => {
const configured = readValue(source, "NOCOBASE_E2E_PORT");
if (!configured) return 4173;
const port = Number(configured);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error("NOCOBASE_E2E_PORT must be a valid TCP port.");
}
return port;
};
const resolveApiURL = (value: string | undefined, baseURL: string) => {
if (!value) return new URL("/api", baseURL).toString().replace(/\/$/, "");
if (/^https?:\/\//i.test(value)) return value.replace(/\/$/, "");
return new URL(`/${value.replace(/^\/+|\/+$/g, "")}`, baseURL)
.toString()
.replace(/\/$/, "");
};
const parseBoolean = (value?: string) => /^true$/i.test(value ?? "");
export function loadPortalE2EEnvironment(
source: EnvironmentSource = process.env
): PortalE2EEnvironment {
const port = resolvePort(source);
const origin = `http://127.0.0.1:${port}`;
const portalBase = normalizePortalBase(
readValue(source, "NOCOBASE_PORTAL_BASE")
);
const baseURL = new URL(portalBase, `${origin}/`).toString();
const apiURL = resolveApiURL(
readValue(source, "NOCOBASE_E2E_API_URL") ??
readValue(source, "NOCOBASE_API_URL"),
baseURL
);
const storageTypeValue = readValue(source, "API_CLIENT_STORAGE_TYPE");
return {
origin,
port,
baseURL,
apiURL,
portalBase,
portalName:
readValue(source, "NOCOBASE_E2E_PORTAL") ??
resolveNocoBasePortalName(portalBase),
appName: resolveNocoBaseAppName(portalBase, apiURL),
authenticator:
readValue(source, "NOCOBASE_E2E_AUTHENTICATOR") ??
readValue(source, "NOCOBASE_AUTHENTICATOR") ??
"basic",
role: readValue(source, "NOCOBASE_E2E_ROLE"),
locale: readValue(source, "NOCOBASE_E2E_LOCALE"),
storagePrefix:
readValue(source, "API_CLIENT_STORAGE_PREFIX") ?? "NOCOBASE_",
storageType:
storageTypeValue === "sessionStorage" ? "sessionStorage" : "localStorage",
shareToken: parseBoolean(readValue(source, "API_CLIENT_SHARE_TOKEN")),
};
}
export function requirePortalE2ECredentials(
environment: PortalE2EEnvironment,
source: EnvironmentSource = process.env
): PortalE2ECredentials {
const account = readValue(source, "NOCOBASE_E2E_ACCOUNT");
const password = source.NOCOBASE_E2E_PASSWORD;
if (!account || typeof password !== "string" || !password.trim()) {
throw new Error(
"NOCOBASE_E2E_ACCOUNT and NOCOBASE_E2E_PASSWORD are required for the login E2E test. Copy .env.e2e.example to .env.e2e and provide test credentials."
);
}
return {
account,
password,
authenticator: environment.authenticator,
role: environment.role,
};
}
export function resolvePortalTestURL(
environment: Pick<PortalE2EEnvironment, "baseURL">,
path = "/"
) {
const relativePath = path.replace(/^\/+/, "");
return new URL(relativePath, environment.baseURL).toString();
}
export function resolvePortalApiActionURL(
environment: Pick<PortalE2EEnvironment, "apiURL">,
resource: string,
action: string
) {
return `${environment.apiURL.replace(/\/$/, "")}/${resource}:${action}`;
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./environment";
export * from "./session";
+110
View File
@@ -0,0 +1,110 @@
import type { Page } from "@playwright/test";
import {
resolveAuthSessionStorageKey,
type AuthSessionField,
} from "@nocobase/portal-sdk/client";
import type { PortalE2ESession } from "./api";
import type { PortalE2EEnvironment } from "./environment";
export function getPortalStorageKey(
environment: Pick<
PortalE2EEnvironment,
"appName" | "shareToken" | "storagePrefix"
>,
field: AuthSessionField
) {
return resolveAuthSessionStorageKey(environment, field);
}
const getSessionValues = (
environment: PortalE2EEnvironment,
session: PortalE2ESession
) => ({
[getPortalStorageKey(environment, "token")]: session.token,
[getPortalStorageKey(environment, "auth")]: session.authenticator,
[getPortalStorageKey(environment, "role")]: session.role,
[getPortalStorageKey(environment, "locale")]: session.locale,
});
export async function installPortalSession(
page: Page,
environment: PortalE2EEnvironment,
session: PortalE2ESession
) {
const payload = {
storageType: environment.storageType,
values: getSessionValues(environment, session),
};
const applySession = ({
storageType,
values,
}: {
storageType: "localStorage" | "sessionStorage";
values: Record<string, string | undefined>;
}) => {
const storage = window[storageType];
Object.entries(values).forEach(([key, value]) => {
if (value) storage.setItem(key, value);
else storage.removeItem(key);
});
};
await page.addInitScript(applySession, payload);
const currentURL = page.url();
if (
/^https?:/i.test(currentURL) &&
new URL(currentURL).origin === new URL(environment.baseURL).origin
) {
await page.evaluate(applySession, payload);
}
}
export async function readPortalSession(
page: Page,
environment: PortalE2EEnvironment
): Promise<PortalE2ESession | undefined> {
const keys = {
token: getPortalStorageKey(environment, "token"),
authenticator: getPortalStorageKey(environment, "auth"),
role: getPortalStorageKey(environment, "role"),
locale: getPortalStorageKey(environment, "locale"),
};
const values = await page.evaluate(
({ storageType, sessionKeys }) => {
const storage = window[storageType];
return {
token: storage.getItem(sessionKeys.token) ?? undefined,
authenticator:
storage.getItem(sessionKeys.authenticator) ?? undefined,
role: storage.getItem(sessionKeys.role) ?? undefined,
locale: storage.getItem(sessionKeys.locale) ?? undefined,
};
},
{ storageType: environment.storageType, sessionKeys: keys }
);
if (!values.token) return undefined;
return {
token: values.token,
authenticator: values.authenticator ?? environment.authenticator,
role: values.role,
locale: values.locale,
};
}
export async function savePortalStorageState(
page: Page,
environment: PortalE2EEnvironment,
session: PortalE2ESession,
path: string
) {
if (environment.storageType === "sessionStorage") {
throw new Error(
"Playwright storageState cannot persist sessionStorage. Use installPortalSession for this Portal."
);
}
await installPortalSession(page, environment, session);
await page.goto(environment.baseURL);
await page.context().storageState({ path });
}
+19 -2
View File
@@ -7,7 +7,6 @@
"defaultTemplateVersion": "3.0.5"
},
"devDependencies": {
"@nocobase/portal-sdk": "workspace:^",
"@ai-sdk/react": "2.0.0",
"@base-ui/react": "^1.6.0",
"@dicebear/core": "^9.4.3",
@@ -15,6 +14,8 @@
"@fontsource-variable/geist": "^5.3.0",
"@hookform/resolvers": "^5.0.1",
"@nocobase/ai-employee-avatars": "^1.0.2",
"@nocobase/portal-sdk": "workspace:^",
"@playwright/test": "^1.62.1",
"@refinedev/cli": "^2.16.48",
"@refinedev/core": "^5.0.8",
"@refinedev/devtools": "^2.0.1",
@@ -28,6 +29,9 @@
"@tailwindcss/vite": "^4.1.8",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-table": "^8.2.6",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@tiptap/extension-link": "^3.29.0",
"@tiptap/extension-placeholder": "^3.29.0",
"@tiptap/extension-text-style": "3.29.0",
@@ -40,6 +44,7 @@
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/coverage-v8": "^4.1.10",
"ai": "5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -57,15 +62,16 @@
"globals": "^16.0.0",
"ignore": "^7.0.6",
"input-otp": "^1.4.2",
"jsdom": "^30.0.1",
"lucide-react": "^0.487.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.3",
"react": "19.1.0",
"react-day-picker": "10.0.1",
"react-dom": "19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.57.0",
"react-markdown": "^10.1.0",
"react-error-boundary": "^6.0.0",
"react-resizable-panels": "^4.12.2",
"react-router": "^7.0.2",
"recharts": "^3.8.0",
@@ -78,6 +84,7 @@
"typescript": "^5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vitest": "^4.1.10",
"zod": "^3.25.76"
},
"publishConfig": {
@@ -91,6 +98,16 @@
"dev": "vite --host 0.0.0.0",
"build": "tsc && refine build",
"build:html": "node ./scripts/build-html.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:sdk": "pnpm --dir sdk test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:install": "playwright install chromium",
"test:all": "pnpm typecheck && pnpm test && pnpm test:sdk && pnpm build && pnpm test:e2e",
"start": "refine start",
"refine": "refine",
"sdk:check": "pnpm exec portal-sdk check"
+46
View File
@@ -0,0 +1,46 @@
import { devices, defineConfig } from "@playwright/test";
import { loadEnv } from "vite";
import { loadPortalE2EEnvironment } from "./e2e/support/environment";
const fileEnvironment = loadEnv("e2e", process.cwd(), "");
Object.entries(fileEnvironment).forEach(([key, value]) => {
if (process.env[key] === undefined) process.env[key] = value;
});
const environment = loadPortalE2EEnvironment();
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.spec.ts",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [["line"], ["html", { open: "never" }]]
: "list",
outputDir: "./test-results",
use: {
baseURL: environment.baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: {
command: `pnpm exec vite --host 127.0.0.1 --port ${environment.port} --strictPort --mode e2e`,
url: environment.baseURL,
reuseExistingServer: false,
timeout: 120_000,
env: {
NOCOBASE_API_URL: environment.apiURL,
NOCOBASE_PORTAL_BASE: environment.portalBase,
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+851
View File
File diff suppressed because it is too large Load Diff
@@ -1,355 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const {
evaluateAccess,
getEffectiveRoles,
getPermissionsForDataSource,
resolveActionPermission,
} = await server.ssrLoadModule("/sdk/src/acl/evaluator.ts");
const { resolveAclDataSourceKey } = await server.ssrLoadModule(
"/sdk/src/acl/data-source.ts"
);
const { filterMenuItemsByAcl, findFirstAccessibleRoute } =
await server.ssrLoadModule("/sdk/src/acl/menu.ts");
const { buildRouteResources, defineAppRoutes } = await server.ssrLoadModule(
"/sdk/src/routing/index.ts"
);
const {
clearRecordPermissions,
getRecordActionPermission,
updateRecordPermissions,
} = await server.ssrLoadModule("/sdk/src/acl/record-permissions.ts");
const { RoleSwitcher } = await server.ssrLoadModule(
"@/extensions/nocobase-acl/components/role-switcher.tsx"
);
const { getRoleOptions, resolveRoleTitle } = await server.ssrLoadModule(
"@/extensions/nocobase-acl/components/role-options.ts"
);
const permissions = {
currentRole: "editor",
roles: ["editor", "auditor"],
roleMode: "allow-use-union",
resources: ["blog_posts"],
actionAlias: {
list: "view",
get: "view",
},
actions: {
"blog_posts:view": { fields: ["id", "title"] },
"blog_posts:update": { fields: ["title"] },
},
strategy: {
actions: ["view:all"],
},
snippets: ["pm.*"],
};
assert.deepEqual(
resolveActionPermission({
permissions,
resource: "blog_posts",
action: "list",
}),
{ fields: ["id", "title"] }
);
assert.deepEqual(getEffectiveRoles(permissions), ["editor", "auditor"]);
assert.equal(
evaluateAccess(permissions, {
roles: {
anyOf: ["editor"],
allOf: ["editor", "auditor"],
noneOf: ["anonymous"],
},
}),
true
);
assert.equal(
evaluateAccess(permissions, {
roles: { noneOf: ["editor"] },
}),
false
);
assert.equal(
evaluateAccess(permissions, {
roles: { anyOf: ["editor"] },
resource: "blog_posts",
action: "create",
}),
false
);
assert.equal(
evaluateAccess(
{ ...permissions, allowAll: true },
{ roles: { anyOf: ["missing-role"] } }
),
true
);
const externalPermissions = {
...permissions,
allowAll: true,
actionAlias: { list: "view" },
dataSources: {
analytics: {
allowAll: false,
resources: ["orders"],
actionAlias: { list: "read" },
actions: { "orders:read": {} },
},
},
};
assert.deepEqual(
getPermissionsForDataSource(externalPermissions, "analytics"),
{
...externalPermissions,
allowAll: false,
resources: ["orders"],
actionAlias: { list: "read" },
actions: { "orders:read": {} },
snippets: externalPermissions.snippets,
}
);
assert.equal(
evaluateAccess(externalPermissions, {
resource: "orders",
action: "list",
dataSourceKey: "analytics",
}),
true
);
assert.equal(
resolveAclDataSourceKey({
acl: {
type: "collection",
dataSourceKey: "analytics",
},
}),
"analytics"
);
assert.equal(
evaluateAccess(permissions, {
resource: "settings",
action: "list",
resourceItem: {
name: "settings",
meta: { acl: { type: "snippet", name: "pm.acl" } },
},
}),
true
);
assert.equal(
evaluateAccess(
{
...permissions,
snippets: ["pm", "pm.*", "!pm.data-source-manager*"],
},
{
resource: "settings",
action: "list",
resourceItem: {
name: "settings",
meta: {
acl: { type: "snippet", name: "pm.data-source-manager" },
},
},
}
),
false
);
assert.equal(
evaluateAccess(permissions, {
resource: "blog_posts",
action: "edit",
field: "title",
}),
true
);
assert.equal(
evaluateAccess(permissions, {
resource: "blog_posts",
action: "edit",
field: "status",
}),
false
);
clearRecordPermissions();
updateRecordPermissions({
resource: "blog_posts",
recordIds: [1, 2],
allowedActions: {
view: [1, 2],
update: [1],
destroy: [],
},
});
assert.equal(
getRecordActionPermission({
resource: "blog_posts",
action: "update",
id: 2,
}),
false
);
assert.equal(
evaluateAccess(permissions, {
resource: "blog_posts",
action: "edit",
id: 1,
}),
true
);
assert.equal(
evaluateAccess(permissions, {
resource: "blog_posts",
action: "edit",
id: 2,
}),
false
);
const filteredMenu = filterMenuItemsByAcl(
[
{
key: "restricted-parent",
name: "restricted-parent",
route: "/restricted-parent",
children: [
{
key: "public-child",
name: "public-child",
route: "/public-child",
meta: { acl: { type: "authenticated" } },
children: [],
},
],
},
],
{ ...permissions, strategy: { actions: [] } }
);
assert.equal(filteredMenu[0].route, undefined);
assert.equal(findFirstAccessibleRoute(filteredMenu), "/public-child");
const [restrictedResource] = buildRouteResources(
defineAppRoutes([
{
name: "administration",
path: "/administration",
access: { roles: { anyOf: ["admin"] } },
children: [
{
name: "reports",
path: "reports",
resource: { meta: { label: "Reports" } },
access: { roles: { anyOf: ["auditor"] } },
children: [
{
name: "reports.create",
path: "create",
resourceAction: "create",
},
],
},
],
},
])
);
assert.equal(restrictedResource.list, "/administration/reports");
assert.equal(restrictedResource.create, "/administration/reports/create");
assert.deepEqual(restrictedResource.meta.acl, { type: "authenticated" });
assert.deepEqual(restrictedResource.meta.routeAccess, [
{ roles: { anyOf: ["admin"] } },
{ roles: { anyOf: ["auditor"] } },
]);
assert.throws(
() =>
buildRouteResources([
{
name: "orphan-create",
path: "/create",
resourceAction: "create",
},
]),
/declares resourceAction without a parent resource/
);
assert.throws(
() =>
buildRouteResources([
{
name: "duplicate-actions",
path: "/duplicate-actions",
resource: {},
children: [
{
name: "first-create",
path: "create",
resourceAction: "create",
},
{
name: "second-create",
path: "new",
resourceAction: "create",
},
],
},
]),
/declares multiple create routes/
);
const restrictedItem = {
key: "reports",
name: "reports",
route: restrictedResource.list,
meta: restrictedResource.meta,
children: [],
};
assert.equal(
filterMenuItemsByAcl([restrictedItem], {
roles: ["admin", "auditor"],
}).length,
1
);
assert.equal(
filterMenuItemsByAcl([restrictedItem], { roles: ["admin"] }).length,
0
);
assert.equal(
filterMenuItemsByAcl([restrictedItem], { roles: ["member"] }).length,
0
);
assert.deepEqual(
getRoleOptions({
roles: [
{ name: "admin", title: "Administrator" },
{ name: "member", title: "Member" },
],
roleMode: "allow-use-union",
allowAnonymous: true,
}),
[
{ name: "__union__", title: "Full permissions" },
{ name: "admin", title: "Administrator" },
{ name: "member", title: "Member" },
{ name: "anonymous", title: "Anonymous" },
]
);
assert.equal(
resolveRoleTitle({ name: "admin", title: '{{t("Admin")}}' }),
"Admin"
);
assert.equal(typeof RoleSwitcher, "function");
console.log("NocoBase ACL regression tests passed");
} finally {
await server.close();
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { getRoleOptions, resolveRoleTitle } from "../components/role-options";
describe("ACL role options", () => {
it("builds selectable role options for union and anonymous modes", () => {
expect(
getRoleOptions({
roles: [
{ name: "admin", title: "Administrator" },
{ name: "member", title: "Member" },
],
roleMode: "allow-use-union",
allowAnonymous: true,
})
).toEqual([
{ name: "__union__", title: "Full permissions" },
{ name: "admin", title: "Administrator" },
{ name: "member", title: "Member" },
{ name: "anonymous", title: "Anonymous" },
]);
expect(resolveRoleTitle({ name: "admin", title: '{{t("Admin")}}' })).toBe(
"Admin"
);
});
});
@@ -1,70 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const {
buildBusinessReportMarkdown,
renderBusinessReportMarkdownToHtml,
splitBusinessReportMarkdown,
} = await server.ssrLoadModule(
"/registry/nocobase-ai/components/tools/business-report-utils.tsx"
);
const markdown = buildBusinessReportMarkdown({
title: "Quarterly report",
summary: "A complete report summary.",
markdown: `## KPI overview
| Metric | Value |
| --- | ---: |
| Revenue | 1286 |
{{chart:1}}
## Trend analysis
The trend continued after the first chart.
{{chart:2}}
## Recommendations
Keep the final section in the exported HTML.`,
charts: [
{ title: "Trend", options: { series: [{ type: "line", data: [1, 2] }] } },
{ title: "Mix", options: { series: [{ type: "pie", data: [1, 2] }] } },
],
});
const parts = splitBusinessReportMarkdown(markdown);
assert.equal(parts.length, 5);
assert.equal(parts[0].type, "markdown");
assert.match(parts[0].content, /Quarterly report/);
assert.match(parts[0].content, /KPI overview/);
assert.equal(parts[1].type, "chart");
assert.match(parts[2].content, /Trend analysis/);
assert.equal(parts[3].type, "chart");
assert.match(parts[4].content, /Recommendations/);
const firstSectionHtml = await renderBusinessReportMarkdownToHtml(
parts[0].content
);
assert.match(firstSectionHtml, /<h1>Quarterly report<\/h1>/);
assert.match(firstSectionHtml, /<h2>KPI overview<\/h2>/);
assert.match(firstSectionHtml, /<table>/);
assert.match(firstSectionHtml, /Revenue/);
console.log("AI business report regression tests passed");
} finally {
await server.close();
}
// React's browser prerender runtime keeps an internal MessagePort alive in
// Node even after the assertion and Vite server have completed.
process.exit(0);
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
buildBusinessReportMarkdown,
renderBusinessReportMarkdownToHtml,
splitBusinessReportMarkdown,
} from "../components/tools/business-report-utils";
describe("AI business reports", () => {
it("preserves markdown sections around embedded charts", async () => {
const markdown = buildBusinessReportMarkdown({
title: "Quarterly report",
summary: "A complete report summary.",
markdown: `## KPI overview
| Metric | Value |
| --- | ---: |
| Revenue | 1286 |
{{chart:1}}
## Recommendations
Keep the final section in the exported HTML.`,
charts: [
{
title: "Trend",
options: { series: [{ type: "line", data: [1, 2] }] },
},
],
});
const parts = splitBusinessReportMarkdown(markdown);
expect(parts.map((part) => part.type)).toEqual([
"markdown",
"chart",
"markdown",
]);
const first = parts[0];
const last = parts[2];
if (first.type !== "markdown" || last.type !== "markdown") {
throw new Error("Expected markdown around the embedded chart");
}
expect(first.content).toMatch(/Quarterly report/);
expect(last.content).toMatch(/Recommendations/);
const html = await renderBusinessReportMarkdownToHtml(first.content);
expect(html).toMatch(/<h1>Quarterly report<\/h1>/);
expect(html).toMatch(/<table>/);
expect(html).toMatch(/Revenue/);
});
});
@@ -1,129 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const { AIFormRegistry, createFormFillerInvoker } =
await server.ssrLoadModule(
"/registry/nocobase-ai/providers/form-registry.tsx"
);
const registry = new AIFormRegistry();
const applied = [];
const unregister = registry.register({
id: "lead-form",
title: "Lead form",
fields: [
{ name: "company", type: "string" },
{
name: "priority",
type: "string",
enum: ["normal", "high"],
},
{ name: "owner", type: "string", readonly: true },
],
getValues: () => ({}),
setValues: (values) => applied.push(values),
});
const invoke = createFormFillerInvoker(registry);
const allowedContext = { allowedFormIds: ["lead-form"] };
assert.deepEqual(
await invoke(
{
form: "lead-form",
data: { company: "Acme", priority: "high" },
},
allowedContext
),
{
status: "success",
content:
'Filled "Lead form". Please review the values and submit the form manually.',
appliedFields: ["company", "priority"],
skippedFields: [],
}
);
assert.deepEqual(applied, [{ company: "Acme", priority: "high" }]);
assert.deepEqual(
await invoke(
{
form: "lead-form",
data: { company: 42, owner: "Ada", unknown: true },
},
allowedContext
),
{
status: "error",
content: 'No valid editable fields were provided for "Lead form".',
appliedFields: [],
skippedFields: [
{
name: "company",
reason: "invalid",
message: "Expected a string.",
},
{
name: "owner",
reason: "readonly",
message: "This field is read-only.",
},
{
name: "unknown",
reason: "undeclared",
message: "This field is not declared by the target form.",
},
],
}
);
assert.throws(
() =>
registry.register({
id: "lead-form",
title: "Duplicate lead form",
fields: [],
getValues: () => ({}),
setValues: () => undefined,
}),
/already registered/
);
unregister();
assert.deepEqual(
await invoke(
{ form: "lead-form", data: { company: "Acme" } },
allowedContext
),
{
status: "error",
content: 'The target form "lead-form" is not available on this page.',
appliedFields: [],
skippedFields: [],
}
);
assert.deepEqual(
await invoke(
{ form: "lead-form", data: { company: "Acme" } },
{ allowedFormIds: [] }
),
{
status: "error",
content:
'The target form "lead-form" is not available in this conversation context.',
appliedFields: [],
skippedFields: [],
}
);
console.log("AI Form filler regression tests passed");
} finally {
await server.close();
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { AIFormRegistry, createFormFillerInvoker } from "../providers/form-registry";
const invocationContext = {
sessionId: "session-1",
messageId: "message-1",
toolCallId: "tool-call-1",
toolName: "formFiller",
allowedFormIds: ["lead-form"],
};
describe("AI form filler", () => {
it("fills only declared editable fields available in the context", async () => {
const registry = new AIFormRegistry();
const applied: Array<Record<string, unknown>> = [];
registry.register({
id: "lead-form",
title: "Lead form",
fields: [
{ name: "company", type: "string" },
{ name: "priority", type: "string", enum: ["normal", "high"] },
{ name: "owner", type: "string", readonly: true },
],
getValues: () => ({}),
setValues: (values) => {
applied.push(values);
},
});
const invoke = createFormFillerInvoker(registry);
const context = invocationContext;
await expect(
invoke(
{ form: "lead-form", data: { company: "Acme", priority: "high" } },
context
)
).resolves.toMatchObject({
status: "success",
appliedFields: ["company", "priority"],
skippedFields: [],
});
expect(applied).toEqual([{ company: "Acme", priority: "high" }]);
await expect(
invoke(
{ form: "lead-form", data: { company: 42, owner: "Ada" } },
context
)
).resolves.toMatchObject({
status: "error",
skippedFields: [
expect.objectContaining({ name: "company", reason: "invalid" }),
expect.objectContaining({ name: "owner", reason: "readonly" }),
],
});
});
it("rejects duplicate registrations and inaccessible forms", async () => {
const registry = new AIFormRegistry();
const unregister = registry.register({
id: "lead-form",
title: "Lead form",
fields: [],
getValues: () => ({}),
setValues: () => undefined,
});
expect(() =>
registry.register({
id: "lead-form",
title: "Duplicate",
fields: [],
getValues: () => ({}),
setValues: () => undefined,
})
).toThrow(/already registered/);
unregister();
await expect(
createFormFillerInvoker(registry)(
{ form: "lead-form", data: {} },
invocationContext
)
).resolves.toMatchObject({ status: "error" });
});
});
@@ -1,128 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const { AIFrontendToolRegistry, createFrontendToolInvokers } =
await server.ssrLoadModule(
"/registry/nocobase-ai/providers/frontend-tool-registry.tsx"
);
const registry = new AIFrontendToolRegistry();
const calls = [];
const unregister = registry.register("quote-card", {
name: "update_quote_discount",
description: "Update the quote discount preview.",
permission: "ASK",
inputSchema: {
type: "object",
properties: { discountPercent: { type: "number" } },
},
execute: (args) => {
calls.push(args);
return { updated: true };
},
});
const [manifest] = registry.list("quote-card");
assert.deepEqual(manifest, {
id: "quote-card:update_quote_discount",
blockUid: "quote-card",
name: "update_quote_discount",
title: undefined,
description: "Update the quote discount preview.",
permission: "ASK",
inputSchema: {
type: "object",
properties: { discountPercent: { type: "number" } },
},
});
const invokers = createFrontendToolInvokers(registry);
const allowedContext = { allowedFrontendToolIds: [manifest.id] };
assert.deepEqual(
await invokers.loadFrontendTool({ toolId: manifest.id }, allowedContext),
manifest
);
assert.deepEqual(
await invokers.executeFrontendTool(
{ toolId: manifest.id, args: { discountPercent: 12 } },
allowedContext
),
{ updated: true }
);
assert.deepEqual(calls, [{ discountPercent: 12 }]);
assert.throws(
() =>
registry.register("quote-card", {
name: "update_quote_discount",
description: "Duplicate Tool",
execute: () => undefined,
}),
/already registered/
);
assert.throws(
() =>
registry.register("quote-card", {
name: "invalid_schema",
description: "Invalid schema",
inputSchema: [],
execute: () => undefined,
}),
/inputSchema must be an object/
);
const unregisterInvalidResult = registry.register("quote-card", {
name: "invalid_result",
description: "Return an invalid result.",
execute: () => ({ value: 1n }),
});
assert.deepEqual(
await invokers.executeFrontendTool(
{ toolId: "quote-card:invalid_result", args: {} },
{
allowedFrontendToolIds: ["quote-card:invalid_result"],
}
),
{
status: "error",
content:
'Frontend Tool "quote-card:invalid_result" returned a non-serializable result',
}
);
unregisterInvalidResult();
unregister();
assert.deepEqual(
await invokers.executeFrontendTool(
{ toolId: manifest.id, args: {} },
allowedContext
),
{
status: "error",
content:
'Frontend Tool "quote-card:update_quote_discount" is unavailable',
}
);
assert.deepEqual(
await invokers.executeFrontendTool(
{ toolId: manifest.id, args: {} },
{ allowedFrontendToolIds: [] }
),
{
status: "error",
content:
'Frontend Tool "quote-card:update_quote_discount" is not available in this conversation context',
}
);
console.log("AI frontend Tool regression tests passed");
} finally {
await server.close();
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
AIFrontendToolRegistry,
createFrontendToolInvokers,
} from "../providers/frontend-tool-registry";
const invocationContext = {
sessionId: "session-1",
messageId: "message-1",
toolCallId: "tool-call-1",
toolName: "executeFrontendTool",
allowedFrontendToolIds: [] as string[],
};
describe("AI frontend tools", () => {
it("publishes and invokes only tools allowed by page context", async () => {
const registry = new AIFrontendToolRegistry();
const calls: unknown[] = [];
const unregister = registry.register("quote-card", {
name: "update_quote_discount",
description: "Update the quote discount preview.",
permission: "ASK",
inputSchema: {
type: "object",
properties: { discountPercent: { type: "number" } },
},
execute: (args) => {
calls.push(args);
return { updated: true };
},
});
const [manifest] = registry.list("quote-card");
const invokers = createFrontendToolInvokers(registry);
const context = {
...invocationContext,
allowedFrontendToolIds: [manifest.id],
};
await expect(
invokers.loadFrontendTool({ toolId: manifest.id }, context)
).resolves.toEqual(manifest);
await expect(
invokers.executeFrontendTool(
{ toolId: manifest.id, args: { discountPercent: 12 } },
context
)
).resolves.toEqual({ updated: true });
expect(calls).toEqual([{ discountPercent: 12 }]);
unregister();
await expect(
invokers.executeFrontendTool({ toolId: manifest.id, args: {} }, context)
).resolves.toMatchObject({ status: "error" });
});
it("rejects duplicate tools and non-serializable results", async () => {
const registry = new AIFrontendToolRegistry();
registry.register("quote-card", {
name: "update_quote_discount",
description: "Update discount",
execute: () => undefined,
});
expect(() =>
registry.register("quote-card", {
name: "update_quote_discount",
description: "Duplicate",
execute: () => undefined,
})
).toThrow(/already registered/);
registry.register("quote-card", {
name: "invalid_result",
description: "Invalid result",
execute: () => ({ value: 1n }),
});
await expect(
createFrontendToolInvokers(registry).executeFrontendTool(
{ toolId: "quote-card:invalid_result", args: {} },
{
...invocationContext,
allowedFrontendToolIds: ["quote-card:invalid_result"],
}
)
).resolves.toMatchObject({ status: "error" });
});
});
@@ -1,55 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const { portalI18nReady } = await server.ssrLoadModule(
"/src/providers/i18n/runtime.ts"
);
await portalI18nReady;
await server.ssrLoadModule("/registry/nocobase-ai/locales/index.ts");
const { i18n, translate } = await server.ssrLoadModule(
"@nocobase/portal-sdk/i18n"
);
await i18n.changeLanguage("en-US");
assert.equal(
translate("chat.conversations", { ns: "nocobase-ai" }),
"Conversations"
);
assert.equal(
translate("tool.status.approvalRequired", { ns: "nocobase-ai" }),
"Approval required"
);
await i18n.changeLanguage("zh-CN");
assert.equal(
translate("navigation.chat", { ns: "nocobase-ai" }),
"聊天窗口"
);
assert.equal(
translate("chat.composer.send", { ns: "nocobase-ai" }),
"发送消息"
);
assert.equal(
translate("pageElement.pick", { ns: "nocobase-ai" }),
"选择一个页面元素"
);
assert.equal(
translate("tool.status.approvalRequired", { ns: "nocobase-ai" }),
"需要确认"
);
assert.equal(
translate("demo.context.title", { ns: "nocobase-ai" }),
"页面上下文和前端工具"
);
console.log("NocoBase AI i18n regression tests passed");
} finally {
await server.close();
}
+30
View File
@@ -0,0 +1,30 @@
import { beforeAll, describe, expect, it } from "vitest";
import { i18n, translate } from "@nocobase/portal-sdk/i18n";
import { portalI18nReady } from "@/providers/i18n/runtime";
import "../locales";
describe("NocoBase AI translations", () => {
beforeAll(() => portalI18nReady);
it("provides the core English and Chinese interaction copy", async () => {
await i18n.changeLanguage("en-US");
expect(translate("chat.conversations", { ns: "nocobase-ai" })).toBe(
"Conversations"
);
expect(
translate("tool.status.approvalRequired", { ns: "nocobase-ai" })
).toBe("Approval required");
await i18n.changeLanguage("zh-CN");
expect(translate("navigation.chat", { ns: "nocobase-ai" })).toBe(
"聊天窗口"
);
expect(translate("chat.composer.send", { ns: "nocobase-ai" })).toBe(
"发送消息"
);
expect(
translate("pageElement.pick", { ns: "nocobase-ai" })
).toBe("选择一个页面元素");
});
});
@@ -1,48 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const {
createAIPageContextReference,
getAIWorkContextRequiredTools,
mergeAIRequiredTools,
} = await server.ssrLoadModule(
"/registry/nocobase-ai/providers/page-context.tsx"
);
const formContext = createAIPageContextReference({
id: "lead-form",
title: "Lead form",
kind: "form",
});
assert.deepEqual(formContext, {
type: "page-element",
id: "lead-form",
title: "Lead form",
kind: "form",
});
assert.deepEqual(getAIWorkContextRequiredTools([formContext]), [
"formFiller",
]);
assert.deepEqual(
mergeAIRequiredTools(
{ skills: ["lead-review"], tools: ["inspect-record"] },
["formFiller", "inspect-record"]
),
{
skills: ["lead-review"],
tools: ["inspect-record", "formFiller"],
}
);
assert.equal(mergeAIRequiredTools(undefined, []), undefined);
console.log("AI page context regression tests passed");
} finally {
await server.close();
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
createAIPageContextReference,
getAIWorkContextRequiredTools,
mergeAIRequiredTools,
} from "../providers/page-context";
describe("AI page context", () => {
it("derives the tools required by contextual form references", () => {
const formContext = createAIPageContextReference({
id: "lead-form",
title: "Lead form",
kind: "form",
});
expect(formContext).toEqual({
type: "page-element",
id: "lead-form",
title: "Lead form",
kind: "form",
});
expect(getAIWorkContextRequiredTools([formContext])).toEqual([
"formFiller",
]);
expect(
mergeAIRequiredTools(
{ skills: ["lead-review"], tools: ["inspect-record"] },
["formFiller", "inspect-record"]
)
).toEqual({
skills: ["lead-review"],
tools: ["inspect-record", "formFiller"],
});
});
});
@@ -1,207 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const encodeSSE = (events) => {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")
)
);
controller.close();
},
});
};
const collectStream = async (stream) => {
const result = [];
for await (const chunk of stream) result.push(chunk);
return result;
};
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const { StreamCoalescer } = await server.ssrLoadModule(
"/registry/nocobase-ai/providers/stream-coalescer.ts"
);
const { NocoBaseChatTransport } = await server.ssrLoadModule(
"/registry/nocobase-ai/providers/chat-transport.ts"
);
const { SubAgentStreamAccumulator } = await server.ssrLoadModule(
"/registry/nocobase-ai/providers/sub-agent-stream.ts"
);
const { NocoBaseClient } = await server.ssrLoadModule(
"@nocobase/portal-sdk/client"
);
{
const client = new NocoBaseClient("http://localhost:13001/api");
assert.equal(
client.resolveUrl("/files/main/main/aiFiles/5.png?preview=1"),
"http://localhost:13001/files/main/main/aiFiles/5.png?preview=1"
);
assert.equal(
client.resolveUrl("https://cdn.example.com/file.png"),
"https://cdn.example.com/file.png"
);
}
{
const flushed = [];
const coalescer = new StreamCoalescer({
interval: 20,
maxSize: 100,
getSize: (value) => value.length,
merge: (current, incoming) => current + incoming,
onFlush: (key, value) => flushed.push([key, value]),
});
coalescer.push("message", "hello ");
coalescer.push("message", "world");
await wait(35);
assert.deepEqual(flushed, [["message", "hello world"]]);
}
{
const seedMessage = {
id: "assistant-existing",
role: "assistant",
parts: [
{
type: "data-subAgent",
id: "sub-1",
data: {
sessionId: "sub-1",
username: "viz",
status: "pending",
messages: [
{
id: "sub-message",
role: "assistant",
parts: [{ type: "text", text: "saved ", state: "done" }],
},
],
},
},
],
};
const accumulator = new SubAgentStreamAccumulator(seedMessage);
const [chunk] = accumulator.process({
type: "content",
body: "tail",
from: "sub-agent",
sessionId: "sub-1",
username: "viz",
});
assert.equal(chunk.data.messages.length, 1);
assert.equal(chunk.data.messages[0].parts[0].text, "saved tail");
}
{
const toolCallId = "report-tool";
const events = [
...Array.from({ length: 100 }, (_, index) => ({
type: "tool_call_chunks",
from: "main-agent",
sessionId: "conversation-1",
body: [
index === 0
? {
id: toolCallId,
name: "businessReportGenerator",
args: "{",
}
: { id: toolCallId, args: "x" },
],
})),
{ type: "stream_end", from: "main-agent", sessionId: "conversation-1" },
];
const service = {
resumeConversationStream: async () => encodeSSE(events),
};
const transport = new NocoBaseChatTransport({
service,
getContext: () => ({
sessionId: "conversation-1",
employee: { username: "atlas", nickname: "Atlas" },
model: { value: "test", label: "Test" },
}),
});
transport.prepareConversationResume([
{ id: "assistant-existing", role: "assistant", parts: [] },
]);
const chunks = await collectStream(await transport.reconnectToStream());
const start = chunks.find((chunk) => chunk.type === "start");
const toolDeltas = chunks.filter(
(chunk) => chunk.type === "tool-input-delta"
);
assert.equal(start.messageId, undefined);
assert.ok(toolDeltas.length < 10, `received ${toolDeltas.length} deltas`);
assert.equal(
toolDeltas.reduce((size, chunk) => size + chunk.inputTextDelta.length, 0),
100
);
}
{
let request;
const service = {
sendMessagesStream: async (input) => {
request = input;
return encodeSSE([
{ type: "content", body: "ok", from: "main-agent" },
{ type: "stream_end", from: "main-agent" },
]);
},
};
const transport = new NocoBaseChatTransport({
service,
getContext: () => ({
sessionId: "conversation-context",
employee: { username: "mira", nickname: "Mira" },
model: { value: "test", label: "Test" },
}),
});
await collectStream(
await transport.sendMessages({
messages: [
{
id: "user-context",
role: "user",
metadata: {
workContext: [
{
type: "page-element",
id: "customer-form",
title: "Customer form",
content: { values: { name: "Northwind" } },
},
],
},
parts: [{ type: "text", text: "Review this form" }],
},
],
})
);
assert.deepEqual(request.messages[0].workContext, [
{
type: "page-element",
id: "customer-form",
title: "Customer form",
content: { values: { name: "Northwind" } },
},
]);
}
console.log("AI stream regression tests passed");
} finally {
await server.close();
}
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { NocoBaseClient } from "@nocobase/portal-sdk/client";
import { NocoBaseChatTransport } from "../providers/chat-transport";
import { StreamCoalescer } from "../providers/stream-coalescer";
import { SubAgentStreamAccumulator } from "../providers/sub-agent-stream";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const encodeSSE = (events: unknown[]) => {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")
)
);
controller.close();
},
});
};
const collectStream = async (stream: ReadableStream) => {
const result: unknown[] = [];
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
result.push(value);
}
return result as Array<Record<string, any>>;
};
describe("AI streaming", () => {
it("resolves server assets and coalesces small text chunks", async () => {
const client = new NocoBaseClient("http://localhost:13001/api");
expect(client.resolveUrl("/files/main/main/aiFiles/5.png?preview=1")).toBe(
"http://localhost:13001/files/main/main/aiFiles/5.png?preview=1"
);
const flushed: Array<[string, string]> = [];
const coalescer = new StreamCoalescer<string, string>({
interval: 20,
maxSize: 100,
getSize: (value) => value.length,
merge: (current, incoming) => current + incoming,
onFlush: (key, value) => flushed.push([key, value]),
});
coalescer.push("message", "hello ");
coalescer.push("message", "world");
await wait(35);
expect(flushed).toEqual([["message", "hello world"]]);
});
it("continues persisted sub-agent output", () => {
const accumulator = new SubAgentStreamAccumulator({
id: "assistant-existing",
role: "assistant",
parts: [
{
type: "data-subAgent",
id: "sub-1",
data: {
sessionId: "sub-1",
username: "viz",
status: "pending",
messages: [
{
id: "sub-message",
role: "assistant",
parts: [{ type: "text", text: "saved ", state: "done" }],
},
],
},
},
],
} as any);
const [chunk] = accumulator.process({
type: "content",
body: "tail",
from: "sub-agent",
sessionId: "sub-1",
username: "viz",
});
if (chunk.type !== "data-subAgent") {
throw new Error("Expected a sub-agent data chunk");
}
const textPart = chunk.data.messages[0].parts[0];
if (textPart.type !== "text") {
throw new Error("Expected sub-agent text output");
}
expect(textPart.text).toBe("saved tail");
});
it("passes page context from user messages to the service", async () => {
let request: any;
const service = {
sendMessagesStream: async (input: unknown) => {
request = input;
return encodeSSE([
{ type: "content", body: "ok", from: "main-agent" },
{ type: "stream_end", from: "main-agent" },
]);
},
};
const transport = new NocoBaseChatTransport({
service: service as any,
getContext: () => ({
sessionId: "conversation-context",
employee: { username: "mira", nickname: "Mira" },
model: { value: "test", label: "Test" },
}),
});
await collectStream(
await transport.sendMessages({
messages: [
{
id: "user-context",
role: "user",
metadata: {
workContext: [
{
type: "page-element",
id: "customer-form",
title: "Customer form",
},
],
},
parts: [{ type: "text", text: "Review this form" }],
},
],
} as any)
);
expect(request.messages[0].workContext).toEqual([
{
type: "page-element",
id: "customer-form",
title: "Customer form",
},
]);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
formatPortalErrorDiagnostic,
normalizePortalError,
redactPortalErrorText,
} from "../error-diagnostics";
describe("Portal error diagnostics", () => {
it("normalizes render errors", () => {
expect(normalizePortalError("render failed")).toEqual({
name: "Error",
message: "render failed",
});
});
it("redacts credentials and URL details from copyable diagnostics", () => {
const redacted = redactPortalErrorText(
"Authorization: Bearer secret-token\nCookie: session=cookie-secret; role=admin\nhttps://example.test/api?token=query-secret&view=all"
);
expect(redacted).not.toMatch(
/secret-token|cookie-secret|role=admin|query-secret|view=all/
);
expect(redacted).toMatch(/\[REDACTED\]/);
const error = new Error(
"Unable to render https://example.test/chart?access_token=message-secret"
);
error.stack = `${error.name}: ${error.message}\n at Chart (chart.tsx:10:2)`;
const diagnostic = formatPortalErrorDiagnostic(error, {
occurredAt: "2026-08-01T12:00:00.000Z",
route: "/users?token=route-secret#active",
templateName: "Default Template",
templateVersion: "2.0.0",
componentStack: "\n at Chart (chart.tsx:10:2)",
});
expect(diagnostic).toMatch(/Portal runtime error/);
expect(diagnostic).toMatch(/Route: \/users/);
expect(diagnostic).toMatch(/Template: Default Template 2\.0\.0/);
expect(diagnostic).toMatch(/JavaScript stack:/);
expect(diagnostic).toMatch(/React component stack:/);
expect(diagnostic).not.toMatch(
/message-secret|route-secret|access_token=|\?token=/
);
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
allowsMultipleFiles,
serializeFileFieldValue,
} from "../form-value";
import { isNocoBaseManagedFileUrl } from "../file-url-policy";
import {
getAvailableFileCount,
resolveFileUploadMode,
} from "../upload-strategy";
import {
matchesFileRules,
resolveMaxFileSize,
validateFileForField,
} from "../validation";
import type {
FileFieldDescriptor,
FileStorageInfo,
NocoBaseFileRecord,
} from "../types";
const multiDescriptor: FileFieldDescriptor = {
sourceCollection: "documents",
fieldName: "files",
fileCollection: "files",
relation: "belongsToMany",
};
const singleDescriptor: FileFieldDescriptor = {
...multiDescriptor,
fieldName: "cover",
relation: "belongsTo",
};
const localStorage: FileStorageInfo = { id: 1, name: "local", type: "local" };
describe("NocoBase file upload", () => {
it("selects upload strategies from storage capabilities", () => {
expect(resolveFileUploadMode(localStorage)).toBe("multipart");
expect(
resolveFileUploadMode({ ...localStorage, type: "s3-compatible" })
).toBe("direct");
expect(
resolveFileUploadMode({
...localStorage,
type: "s3-compatible",
clientUpload: false,
})
).toBe("multipart");
expect(resolveFileUploadMode(localStorage, "direct")).toBe("direct");
expect(getAvailableFileCount(4, 1, 1, 5)).toBe(2);
});
it("applies preview policy only to NocoBase-managed URLs", () => {
const browserUrl = "https://portal.example.com/users";
const apiUrl = "https://api.example.com/api";
expect(
isNocoBaseManagedFileUrl("/storage/files/report.pdf", apiUrl, browserUrl)
).toBe(true);
expect(
isNocoBaseManagedFileUrl(
"https://bucket.example.com/report.pdf?X-Amz-Signature=abc",
apiUrl,
browserUrl
)
).toBe(false);
});
it("serializes values according to the field relation", () => {
const first: NocoBaseFileRecord = { id: 1, filename: "first.txt" };
const second: NocoBaseFileRecord = { id: 2, filename: "second.txt" };
expect(allowsMultipleFiles(multiDescriptor)).toBe(true);
expect(allowsMultipleFiles(singleDescriptor)).toBe(false);
expect(serializeFileFieldValue(multiDescriptor, [first, second])).toEqual([
{ id: 1 },
{ id: 2 },
]);
expect(serializeFileFieldValue(singleDescriptor, [first, second])).toEqual({
id: 1,
});
});
it("uses the strictest field and storage validation rules", () => {
const image = new File([new Uint8Array(8)], "avatar.png", {
type: "image/png",
});
const text = new File([new Uint8Array(8)], "notes.txt", {
type: "text/plain",
});
const messages = {
fileSizeExceeded: (size: number) => `too large: ${size}`,
fieldMimeTypeRejected: "wrong field type",
};
expect(matchesFileRules(image, ["image/*", ".pdf"])).toBe(true);
expect(matchesFileRules(text, ["image/*", ".pdf"])).toBe(false);
expect(
resolveMaxFileSize(
{ ...singleDescriptor, maxSize: 20 },
{ ...localStorage, rules: { size: 10 } }
)
).toBe(10);
expect(
validateFileForField(
text,
{ ...singleDescriptor, accept: "image/*" },
messages
)
).toEqual({
valid: false,
code: "mimetype",
message: "wrong field type",
});
});
});
@@ -1,54 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const { portalI18nReady } = await server.ssrLoadModule(
"/src/providers/i18n/runtime.ts"
);
await portalI18nReady;
await server.ssrLoadModule("/src/locales/index.ts");
const {
applySystemLocale,
getCurrentLocale,
i18n,
registerTranslationResources,
resolveTranslatableText,
translate,
} = await server.ssrLoadModule("@nocobase/portal-sdk/i18n");
await i18n.changeLanguage("en-US");
assert.equal(translate("buttons.create", { ns: "starter" }, "Create"), "Create");
await i18n.changeLanguage("zh-CN");
assert.equal(translate("buttons.create", { ns: "starter" }, "Create"), "新建");
assert.equal(resolveTranslatableText('{{t("Admin")}}'), "管理员");
assert.equal(
resolveTranslatableText("Full permissions", { ns: "starter" }),
"全部权限"
);
registerTranslationResources("example-feature", {
"en-US": { title: "Example" },
"zh-CN": { title: "示例" },
});
assert.equal(
translate("title", { ns: "example-feature" }, "Example"),
"示例"
);
await applySystemLocale({
appLang: "en-US",
enabledLanguages: ["en-US", "zh-CN"],
});
assert.equal(getCurrentLocale(), "en-US");
console.log("NocoBase i18n regression tests passed");
} finally {
await server.close();
}
+45
View File
@@ -0,0 +1,45 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
applySystemLocale,
getCurrentLocale,
i18n,
registerTranslationResources,
resolveTranslatableText,
translate,
} from "@nocobase/portal-sdk/i18n";
import { portalI18nReady } from "@/providers/i18n/runtime";
import "@/locales";
describe("Portal i18n integration", () => {
beforeAll(() => portalI18nReady);
it("resolves template and extension translations", async () => {
await i18n.changeLanguage("en-US");
expect(translate("buttons.create", { ns: "starter" }, "Create")).toBe(
"Create"
);
await i18n.changeLanguage("zh-CN");
expect(translate("buttons.create", { ns: "starter" }, "Create")).toBe(
"新建"
);
expect(resolveTranslatableText('{{t("Admin")}}')).toBe("管理员");
registerTranslationResources("example-feature", {
"en-US": { title: "Example" },
"zh-CN": { title: "示例" },
});
expect(
translate("title", { ns: "example-feature" }, "Example")
).toBe("示例");
});
it("applies the locale selected by System Settings", async () => {
await applySystemLocale({
appLang: "en-US",
enabledLanguages: ["en-US", "zh-CN"],
});
expect(getCurrentLocale()).toBe("en-US");
});
});
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { nocobaseClient } from "@nocobase/portal-sdk/client";
import { mailApi } from "../components/mail-api";
afterEach(() => vi.restoreAllMocks());
describe("Mail API adapter", () => {
it("maps mutations to the server actions expected by the plugin", async () => {
const action = vi
.spyOn(nocobaseClient, "action")
.mockResolvedValue({} as never);
await mailApi.cancelScheduled(7);
await mailApi.destroyMessages([7, 8]);
await mailApi.cancelMassMessage(9);
await mailApi.resendMassMessage(9);
expect(action.mock.calls.map(([resource, name]) => [resource, name])).toEqual(
[
["mailMessages", "cancelTimelySend"],
["mailMessages", "destroy"],
["mailMassMessages", "cancel"],
["mailMassMessages", "resend"],
]
);
expect(action.mock.calls[0][2]?.query).toEqual({ id: 7 });
expect(action.mock.calls[1][2]?.query).toEqual({ filterByTk: [7, 8] });
});
it("loads all pages of bulk messages", async () => {
const action = vi.spyOn(nocobaseClient, "action").mockImplementation(
async (_resource, _name, options) => {
const page = Number(options?.query?.page ?? 1);
return {
data:
page === 1
? Array.from({ length: 100 }, (_, index) => ({ id: index + 1 }))
: [{ id: 101 }],
meta: { count: 101 },
} as never;
}
);
const result = await mailApi.listMassMessages(null);
expect(result.count).toBe(101);
expect(result.rows).toHaveLength(101);
expect(action).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
buildComposeInitial,
canReplyAll,
} from "../components/use-mail-compose";
import {
serializeReplyQuote,
splitReplyQuote,
} from "../components/mail-reply-quote";
import { MailBoxType, type MailMessage } from "../components/types";
const sourceMessage: MailMessage = {
id: 1,
email: "[email protected]",
identityEmail: "[email protected]",
mailId: "message-1",
rawId: "raw-1",
boxType: MailBoxType.IN,
isRead: false,
isDraft: false,
from: "[email protected]",
to: "[email protected], [email protected]",
toUsers: [
{ address: "[email protected]" },
{ address: "[email protected]" },
],
cc: "[email protected]",
ccUsers: [{ address: "[email protected]" }],
bcc: "",
subject: "Question",
date: "2026-07-27T10:00:00.000Z",
bodyText: "Hello",
bodyHtml: "<p>Hello</p>",
attachments: [],
};
describe("Mail compose", () => {
it("builds a reply-all without mixing quoted HTML into the editor", () => {
const reply = buildComposeInitial(sourceMessage, "replyAll", [
"[email protected]",
"[email protected]",
]);
expect(reply).toMatchObject({
accountEmail: "[email protected]",
identityEmail: "[email protected]",
to: "[email protected], [email protected]",
cc: "[email protected]",
body: "",
replyBody: "<p>Hello</p>",
});
expect(reply.reference).toMatchObject({
from: "[email protected]",
subject: "Question",
html: "<p>Hello</p>",
});
expect(
canReplyAll(sourceMessage, ["[email protected]", "[email protected]"])
).toBe(true);
const serialized = serializeReplyQuote(
reply.body ?? "",
reply.replyBody ?? ""
);
expect(serialized).toMatch(/data-role="reply-quote"/);
expect(serialized).toMatch(/<blockquote type="cite"/);
expect(splitReplyQuote(`<p>Thanks<\/p>${serialized}`)).toEqual({
body: "<p>Thanks</p>",
replyBody: "<p>Hello</p>",
});
});
it("keeps original formatted HTML in reply references", () => {
const bodyHtml =
'<div><strong>Formatted sender</strong><p style="color:red">Original content</p></div>';
const reply = buildComposeInitial(
{ ...sourceMessage, bodyHtml, bodyText: "Formatted sender Original content" },
"reply"
);
expect(reply.replyBody).toBe(bodyHtml);
expect(reply.reference?.html).toBe(bodyHtml);
});
it("rejects provider-backed drafts from the local draft workflow", () => {
expect(() =>
buildComposeInitial(
{
...sourceMessage,
id: 8,
isDraft: true,
mailId: "provider-draft",
rawId: "raw-draft",
},
"draft"
)
).toThrow(/read-only/);
});
});
@@ -1,51 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const { buildComposeInitial } = await server.ssrLoadModule(
"/registry/nocobase-mail/components/use-mail-compose.tsx"
);
const composeSource = await readFile(
new URL("../components/mail-compose.tsx", import.meta.url),
"utf8"
);
const originalHtml =
'<div><strong>Formatted sender</strong><p style="color:red">Original content</p></div>';
const reply = buildComposeInitial(
{
id: 1,
mailId: "message-1",
email: "[email protected]",
from: "[email protected]",
to: "[email protected]",
subject: "Formatted message",
date: "2026-07-30T10:00:00.000Z",
bodyText: "Formatted sender Original content",
bodyHtml: originalHtml,
attachments: [],
},
"reply"
);
assert.equal(reply.replyBody, originalHtml);
assert.equal(
reply.reference?.html,
originalHtml,
"keeps the original HTML available to the quoted-message preview"
);
assert.match(
composeSource,
/srcDoc=\{contentHtml\}/,
"renders the quoted-message preview as isolated HTML instead of plain text"
);
console.log("mail quote HTML regression checks passed");
} finally {
await server.close();
}
@@ -1,704 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const mailTableSource = await readFile(
new URL("../components/mail-table.tsx", import.meta.url),
"utf8"
);
const mailFiltersSource = await readFile(
new URL("../components/mail-filters.tsx", import.meta.url),
"utf8"
);
const mailDetailSource = await readFile(
new URL("../components/mail-detail.tsx", import.meta.url),
"utf8"
);
const mailInboxSource = await readFile(
new URL("../components/mail-inbox.tsx", import.meta.url),
"utf8"
);
const mailUnreadSource = await readFile(
new URL("../components/mail-unread.tsx", import.meta.url),
"utf8"
);
const mailPagesSource = await readFile(
new URL("../mail-pages.tsx", import.meta.url),
"utf8"
);
assert.match(
mailTableSource,
/<DropdownMenuContent[^>]*>\s*<DropdownMenuGroup>[\s\S]*?<DropdownMenuLabel>Toggle columns<\/DropdownMenuLabel>/,
"keeps the Base UI columns label inside a menu group"
);
assert.match(
mailTableSource,
/unread &&[\s\S]*?\? "font-bold"/,
"renders unread mail cells with a clearly bold font weight"
);
assert.match(
mailFiltersSource,
/label="Todo"[\s\S]*?icon=\{value\.isTodo \? CircleCheckBig : Circle\}/,
"presents the backend todo flag as a todo filter"
);
assert.doesNotMatch(
mailFiltersSource,
/Starred|icon=\{Star\}/,
"does not present todo messages as starred mail"
);
assert.match(
mailDetailSource,
/aria-pressed=\{Boolean\(message\.isTodo\)\}[\s\S]*?"Add to todo"[\s\S]*?message\.isTodo \? \(/,
"renders the message todo control as an accessible toggle"
);
assert.match(
mailDetailSource,
/title="Mark as unread"[\s\S]*?onMarkUnread\?\.\(message\)/,
"offers a mark-as-unread action in message detail"
);
assert.match(
mailInboxSource,
/handleDetailMarkUnread[\s\S]*?collectMessageMailIds\(message\)[\s\S]*?mailApi\.setRead\(mailIds, false\)[\s\S]*?markMessageUnread/,
"marks the full open conversation unread and updates local state"
);
assert.match(
mailDetailSource,
/\.gmail_quote[\s\S]*?blockquote\[type=[^\]]*cite[^\]]*\][\s\S]*?#divNeteaseMailCard[\s\S]*?#foxmail_quote[\s\S]*?#divRplyFwdMsg[\s\S]*?#yahoo_quoted/,
"recognizes quoted messages from the providers supported by the previous mail detail"
);
assert.match(
mailDetailSource,
/topLevelNodes\[0\][\s\S]*?mail-quote is-collapsed[\s\S]*?Replied message/,
"collapses only the first top-level quoted message behind a reply toggle"
);
assert.match(
mailInboxSource,
/isMessageUnread\(message\)[\s\S]*?collectMessageMailIds\(detail\)[\s\S]*?mailApi\.setRead\(mailIds, true\)/,
"marks the complete conversation as read when its detail is opened"
);
assert.match(
mailInboxSource,
/m\.id === message\.id[\s\S]*?markMessageRead\(m\)/,
"clears both the message and conversation unread state in the list"
);
assert.match(
mailPagesSource,
/params\.set\("todo", "1"\)/,
"uses todo semantics in newly generated filter URLs"
);
assert.match(
mailPagesSource,
/searchParams\.get\("starred"\) === "1"/,
"keeps legacy starred filter URLs working"
);
assert.match(
mailUnreadSource,
/if \(!pollingEnabled\) return;[\s\S]*?refresh\(\);/,
"keeps the unread provider idle until an indicator subscribes"
);
assert.match(
mailUnreadSource,
/export function MailUnreadIndicator[\s\S]*?const \{ count, subscribe \} = useMailUnread\(\);[\s\S]*?useEffect\(\(\) => subscribe\(\), \[subscribe\]\);/,
"starts unread polling from the mounted indicator"
);
const { getMailSenderCandidates, resolveMailSender } =
await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-senders.ts"
);
const { buildComposeInitial, canReplyAll } = await server.ssrLoadModule(
"/registry/nocobase-mail/components/use-mail-compose.tsx"
);
const {
collectMessageMailIds,
isMessageUnread,
markMessageRead,
markMessageUnread,
} = await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-inbox.tsx"
);
const { mailApi } = await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-api.ts"
);
const { nocobaseClient } = await server.ssrLoadModule(
"@nocobase/portal-sdk/client"
);
const { default: mailExtension } = await server.ssrLoadModule(
"/registry/nocobase-mail/extension.tsx"
);
const { MailUnreadProvider } = await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-unread.tsx"
);
const { createMailUnreadPollingSubscription } = await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-unread-subscription.ts"
);
const { appendRecipient, currentToken, mergeRecipients } =
await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-recipient-input.tsx"
);
const { createDebouncedDraftSaver } = await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-draft-autosave.ts"
);
const {
collectInlineContentIds,
filterInlineAttachments,
replaceInlineImageSources,
} = await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-inline-images.ts"
);
const { serializeReplyQuote, splitReplyQuote } = await server.ssrLoadModule(
"/registry/nocobase-mail/components/mail-reply-quote.ts"
);
assert.equal(currentToken("[email protected], bo"), "bo");
assert.equal(
appendRecipient("[email protected], bo", "[email protected]"),
"[email protected], [email protected]",
"replaces the active search token when a recipient is selected"
);
assert.equal(
appendRecipient("[email protected], ", "[email protected]"),
"[email protected], [email protected]",
"appends after a completed manual recipient"
);
assert.equal(
appendRecipient("[email protected], ali", "[email protected]"),
"[email protected]",
"deduplicates selected recipients case-insensitively"
);
assert.equal(
mergeRecipients(
"[email protected]",
"[email protected]; [email protected], [email protected]"
),
"[email protected], [email protected], [email protected]",
"merges manually entered To/Cc recipients and removes duplicates"
);
const autoSaved = [];
const draftSaver = createDebouncedDraftSaver(
async (payload) => autoSaved.push(payload),
5
);
draftSaver.schedule({ subject: "First version" });
draftSaver.schedule({ subject: "Latest version" });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(
autoSaved,
[{ subject: "Latest version" }],
"auto-save debounces edits and persists only the latest draft snapshot"
);
draftSaver.cancel();
const inlineHtml = [
'<p><img src="cid:<Hero.Image@Mail>"></p>',
'<img src="/api/mail:messageContentPreview?messageId=7&amp;contentId=logo%40mail">',
].join("");
assert.deepEqual(collectInlineContentIds(inlineHtml), [
"hero.image@mail",
"logo@mail",
]);
assert.equal(
replaceInlineImageSources(
inlineHtml,
new Map([
["hero.image@mail", "blob:hero"],
["logo@mail", "blob:logo"],
])
),
'<p><img src="blob:hero"></p><img src="blob:logo">'
);
assert.deepEqual(
filterInlineAttachments(
[
{
attachmentId: "inline",
filename: "hero.png",
mimeType: "image/png",
contentId: "<Hero.Image@Mail>",
},
{
attachmentId: "file",
filename: "report.pdf",
mimeType: "application/pdf",
},
],
inlineHtml
).map((attachment) => attachment.attachmentId),
["file"],
"inline images are not duplicated in the downloadable attachment list"
);
assert.equal(
mailExtension.resources,
undefined,
"does not add Mail examples to the application navigation"
);
assert.equal(
mailExtension.Provider,
MailUnreadProvider,
"keeps the unread provider available to indicators anywhere in the application"
);
assert.ok(mailExtension.dev?.routes, "provides Mail routes under /dev");
const mailRootRoute = mailExtension.dev?.routes?.find(
(route) => route.name === "development.mail"
);
assert.equal(
mailRootRoute?.element,
undefined,
"does not couple unread polling to the Mail development route"
);
const pollingTransitions = [];
const subscribeToPolling = createMailUnreadPollingSubscription((active) =>
pollingTransitions.push(active)
);
const unsubscribeFirst = subscribeToPolling();
const unsubscribeSecond = subscribeToPolling();
unsubscribeFirst();
unsubscribeSecond();
unsubscribeSecond();
assert.deepEqual(
pollingTransitions,
[true, false],
"shares one polling lifecycle across indicators and ignores duplicate cleanup"
);
const mailResources = mailExtension.dev?.resources ?? [];
const mailResource = mailResources.find(
(resource) => resource.name === "mail"
);
assert.equal(
mailResource?.meta?.label,
"Mail manager",
"uses the configured Mail menu label"
);
const scenarioResources = mailResources.filter((resource) =>
resource.name.startsWith("mail-scenario-")
);
assert.equal(
scenarioResources.length,
5,
"includes the standalone unread-indicator scenario"
);
assert.ok(
scenarioResources.every((resource) => resource.meta?.parent === "mail"),
"nests every scenario directly under Mail"
);
assert.equal(
mailResources.some((resource) => resource.name === "mail-compose"),
false,
"removes the old standalone Compose menu entry"
);
assert.equal(
mailResources.some((resource) => resource.name === "mail-scenarios"),
false,
"removes the intermediate Mail scenarios menu group"
);
const bulkResource = mailResources.find(
(resource) => resource.name === "mail-bulk"
);
assert.equal(bulkResource?.list, "mail/bulk");
assert.equal(bulkResource?.meta?.parent, "mail");
assert.equal(bulkResource?.meta?.label, "Bulk mail");
assert.equal(
mailResources.at(-1)?.name,
"mail-bulk",
"keeps Bulk mail as the final item in the Mail menu"
);
assert.deepEqual(
scenarioResources.map((resource) => resource.list),
[
"mail/workspace",
"mail/personal",
"mail/unread",
"mail/compose",
"mail/filtered",
]
);
assert.equal(
mailResources.some(
(resource) => resource.name === "mail-scenario-compose-anywhere"
),
false,
"removes the standalone Send to anyone menu entry"
);
const candidates = getMailSenderCandidates([
{
id: 1,
type: "gmail",
email: "[email protected]",
userId: 1,
settingId: 1,
identities: [
{ id: 11, accountId: 1, userId: 1, email: "[email protected]" },
{ id: 12, accountId: 1, userId: 1, email: "[email protected]" },
],
},
{
id: 2,
type: "smtp",
email: "[email protected]",
userId: 1,
settingId: 2,
identities: [
{ id: 21, accountId: 2, userId: 1, email: "[email protected]" },
],
},
]);
assert.equal(
candidates.length,
4,
"deduplicates identities within an account"
);
assert.equal(new Set(candidates.map((candidate) => candidate.key)).size, 4);
assert.deepEqual(
resolveMailSender(candidates, {
from: "[email protected]",
accountEmail: "[email protected]",
}),
candidates[3],
"uses the owning account to disambiguate the same alias"
);
assert.equal(
resolveMailSender(candidates, { from: "[email protected]" })?.accountId,
1,
"supports legacy compose values that only contain from"
);
const sourceMessage = {
id: 1,
email: "[email protected]",
identityEmail: "[email protected]",
mailId: "message-1",
rawId: "raw-1",
boxType: "in",
isRead: false,
isDraft: false,
from: "[email protected]",
to: "[email protected], [email protected]",
toUsers: [
{ address: "[email protected]" },
{ address: "[email protected]" },
],
cc: "[email protected]",
ccUsers: [{ address: "[email protected]" }],
subject: "Question",
date: "2026-07-27T10:00:00.000Z",
bodyText: "Hello",
bodyHtml: "<p>Hello</p>",
attachments: [],
};
assert.equal(
isMessageUnread({
...sourceMessage,
isRead: true,
relatedMessagesIsRead: false,
}),
true,
"treats a conversation with unread related messages as unread"
);
assert.deepEqual(
collectMessageMailIds({
...sourceMessage,
relatedMessageIds: ["message-2"],
children: [
{ ...sourceMessage, id: 2, mailId: "message-2" },
{ ...sourceMessage, id: 3, mailId: "message-3" },
],
}),
["message-1", "message-2", "message-3"],
"marks every provider message in the opened conversation"
);
assert.deepEqual(
markMessageRead({
...sourceMessage,
relatedMessagesIsRead: false,
}),
{
...sourceMessage,
isRead: true,
relatedMessagesIsRead: true,
},
"clears the aggregate unread flag together with the current message"
);
assert.deepEqual(
markMessageUnread(sourceMessage),
{
...sourceMessage,
isRead: false,
relatedMessagesIsRead: false,
},
"sets both the message and conversation unread state"
);
const reply = buildComposeInitial(sourceMessage, "replyAll", [
"[email protected]",
"[email protected]",
]);
assert.equal(reply.accountEmail, "[email protected]");
assert.equal(reply.identityEmail, "[email protected]");
assert.equal(reply.to, "[email protected], [email protected]");
assert.equal(reply.cc, "[email protected]");
assert.equal(reply.reference?.from, "[email protected]");
assert.equal(reply.reference?.subject, "Question");
assert.equal(reply.reference?.preview, "Hello");
assert.equal(
reply.body,
"",
"keeps the editable reply body separate from the quoted message"
);
assert.equal(reply.replyBody, "<p>Hello</p>");
const serializedReply = serializeReplyQuote(reply.body, reply.replyBody);
assert.match(
serializedReply,
/<div class="nocobase-quote nb-mail-quote" data-role="reply-quote">/,
"keeps the stable reply-quote marker while preserving the Portal style hook"
);
assert.match(
serializedReply,
/<blockquote type="cite"[^>]*><p>Hello<\/p><\/blockquote>/,
"uses semantic cited content for mail-client compatible replies"
);
assert.deepEqual(splitReplyQuote(`<p>Thanks</p>${serializedReply}`), {
body: "<p>Thanks</p>",
replyBody: "<p>Hello</p>",
});
assert.equal(
canReplyAll(sourceMessage, ["[email protected]", "[email protected]"]),
true,
"shows Reply all when another recipient participates in the message"
);
assert.equal(
canReplyAll(
{
...sourceMessage,
to: "[email protected]",
toUsers: [{ address: "[email protected]" }],
cc: "",
ccUsers: [],
},
["[email protected]", "[email protected]"]
),
false,
"hides Reply all when replying would only target the sender"
);
const forward = buildComposeInitial(sourceMessage, "forward");
assert.equal(forward.reference?.from, "[email protected]");
assert.match(forward.body || "", /Forwarded message/);
const rawAddressReply = buildComposeInitial(
{ ...sourceMessage, toUsers: undefined, ccUsers: undefined },
"replyAll",
["[email protected]"]
);
assert.equal(
rawAddressReply.to,
"[email protected], [email protected]"
);
assert.equal(rawAddressReply.cc, "[email protected]");
const draft = buildComposeInitial(
{
id: 7,
email: "[email protected]",
identityEmail: "[email protected]",
mailId: "",
rawId: "",
boxType: "draft",
isRead: true,
isDraft: true,
from: "[email protected]",
to: "[email protected]",
cc: "",
bcc: "",
subject: "Draft subject",
date: "2026-07-27T10:00:00.000Z",
bodyText: "",
bodyHtml: "<p>Draft body</p>",
attachments: [
{
filename: "stored-file",
mimeType: "text/plain",
attachmentId: "draft.txt",
originalname: "draft.txt",
path: "/tmp/stored-file",
size: 12,
encoding: "7bit",
mimetype: "text/plain",
},
],
},
"draft"
);
assert.equal(draft.id, 7);
assert.equal(draft.isDraft, true);
assert.equal(draft.attachments?.[0].path, "/tmp/stored-file");
assert.equal(draft.attachments?.[0].filename, "draft.txt");
assert.equal(draft.attachments?.[0].mimeType, "text/plain");
assert.throws(
() =>
buildComposeInitial(
{
...draft,
id: 8,
isDraft: true,
mailId: "provider-draft",
rawId: "raw-draft",
},
"draft"
),
/read-only/,
"provider-backed drafts must never enter the local draft mutation workflow"
);
const calls = [];
const originalAction = nocobaseClient.action.bind(nocobaseClient);
nocobaseClient.action = async (resource, action, options) => {
calls.push({ resource, action, options });
if (resource === "mailMassMessages" && action === "list") {
const page = options.query.page;
return {
data:
page === 1
? Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
status: "pending",
message: {},
to: `user${index + 1}@example.com`,
}))
: [
{
id: 101,
status: "pending",
message: {},
to: "[email protected]",
},
],
meta: { count: 101 },
};
}
if (resource === "mail" && action === "messageAttachmentUpload") {
return {
originalname: "draft.txt",
filename: "stored-file",
path: "/tmp/stored-file",
size: 12,
encoding: "7bit",
mimetype: "text/plain",
};
}
if (resource === "mail" && action === "oauth2url") {
if (!options.query?.type) throw new Error("cannot find type");
return { url: "https://mail.example.test/authorize" };
}
if (resource === "mailMessages" && action === "get") {
return { id: 42, isDraft: true, subject: "Recovered draft" };
}
if (resource === "mail" && action === "accountIdentitiesSync") {
return [
{ id: 1, email: "[email protected]", isPrimary: true },
{ id: 2, email: "[email protected]", isPrimary: false },
];
}
return {};
};
try {
await mailApi.cancelScheduled(7);
await mailApi.destroyMessages([7, 8]);
await mailApi.cancelMassMessage(9);
await mailApi.resendMassMessage(9);
const massList = await mailApi.listMassMessages(null);
const uploaded = await mailApi.uploadAttachment(new Blob(["draft"]));
assert.deepEqual(
calls.slice(0, 4).map(({ resource, action }) => [resource, action]),
[
["mailMessages", "cancelTimelySend"],
["mailMessages", "destroy"],
["mailMassMessages", "cancel"],
["mailMassMessages", "resend"],
]
);
assert.deepEqual(calls[0].options.query, { id: 7 });
assert.deepEqual(calls[1].options.query, { filterByTk: [7, 8] });
assert.equal(massList.count, 101);
assert.equal(massList.rows.length, 101, "loads every bulk task page");
assert.equal(
calls.filter(
({ resource, action }) =>
resource === "mailMassMessages" && action === "list"
).length,
2
);
assert.equal(uploaded.filename, "draft.txt");
assert.equal(uploaded.mimeType, "text/plain");
assert.ok(calls.at(-1).options.body instanceof FormData);
const oauthUrl = await mailApi.getOAuthUrl("google", {
email: "[email protected]",
reauthorize: true,
});
await mailApi.deleteAccount("[email protected]");
await mailApi.resyncAccount("[email protected]");
const recoveredDraft = await mailApi.findDraft({
accountEmail: "[email protected]",
identityEmail: "[email protected]",
to: ["[email protected]"],
});
const identities = await mailApi.syncAliases("[email protected]");
assert.equal(oauthUrl, "https://mail.example.test/authorize");
assert.equal(recoveredDraft?.id, 42);
assert.deepEqual(
identities.map((identity) => identity.email),
["[email protected]", "[email protected]"]
);
assert.deepEqual(
calls
.slice(-5)
.map(({ resource, action, options }) => [
resource,
action,
options.query ?? options.body,
]),
[
[
"mail",
"oauth2url",
{ type: "google", email: "[email protected]", reauthorize: true },
],
["mail", "deleteMailAccount", { email: "[email protected]" }],
["mail", "messagesResync", { email: "[email protected]" }],
[
"mailMessages",
"get",
{
filter: JSON.stringify({
isDraft: true,
from: "[email protected]",
email: "[email protected]",
to: ["[email protected]"],
}),
appends: "labels,note",
},
],
["mail", "accountIdentitiesSync", { email: "[email protected]" }],
]
);
} finally {
nocobaseClient.action = originalAction;
}
console.log("NocoBase mail regression tests passed");
} finally {
await server.close();
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { createDebouncedDraftSaver } from "../components/mail-draft-autosave";
import {
collectInlineContentIds,
filterInlineAttachments,
replaceInlineImageSources,
} from "../components/mail-inline-images";
import {
collectMessageMailIds,
isMessageUnread,
markMessageRead,
markMessageUnread,
} from "../components/mail-inbox";
import {
appendRecipient,
currentToken,
mergeRecipients,
} from "../components/mail-recipient-input";
import { createMailUnreadPollingSubscription } from "../components/mail-unread-subscription";
describe("Mail interaction utilities", () => {
it("normalizes recipient input", () => {
expect(currentToken("[email protected], bo")).toBe("bo");
expect(
appendRecipient("[email protected], bo", "[email protected]")
).toBe("[email protected], [email protected]");
expect(
mergeRecipients(
"[email protected]",
"[email protected]; [email protected], [email protected]"
)
).toBe("[email protected], [email protected], [email protected]");
});
it("debounces draft saves", async () => {
const saved: Array<Record<string, unknown>> = [];
const saver = createDebouncedDraftSaver(
async (payload: Record<string, unknown>) => {
saved.push(payload);
},
5
);
saver.schedule({ subject: "First version" });
saver.schedule({ subject: "Latest version" });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(saved).toEqual([{ subject: "Latest version" }]);
saver.cancel();
});
it("replaces inline image sources and hides inline attachments", () => {
const html = [
'<p><img src="cid:<Hero.Image@Mail>"></p>',
'<img src="/api/mail:messageContentPreview?messageId=7&amp;contentId=logo%40mail">',
].join("");
expect(collectInlineContentIds(html)).toEqual([
"hero.image@mail",
"logo@mail",
]);
expect(
replaceInlineImageSources(
html,
new Map([
["hero.image@mail", "blob:hero"],
["logo@mail", "blob:logo"],
])
)
).toBe('<p><img src="blob:hero"></p><img src="blob:logo">');
expect(
filterInlineAttachments(
[
{
attachmentId: "inline",
filename: "hero.png",
mimeType: "image/png",
contentId: "<Hero.Image@Mail>",
},
{
attachmentId: "file",
filename: "report.pdf",
mimeType: "application/pdf",
},
],
html
).map((attachment) => attachment.attachmentId)
).toEqual(["file"]);
});
it("updates unread state for complete conversations", () => {
const message = {
id: 1,
mailId: "message-1",
isRead: true,
relatedMessagesIsRead: false,
};
expect(isMessageUnread(message as any)).toBe(true);
expect(
collectMessageMailIds({
...message,
relatedMessageIds: ["message-2"],
children: [{ ...message, id: 2, mailId: "message-3" }],
} as any)
).toEqual(["message-1", "message-2", "message-3"]);
expect(markMessageRead(message as any)).toMatchObject({
isRead: true,
relatedMessagesIsRead: true,
});
expect(markMessageUnread(message as any)).toMatchObject({
isRead: false,
relatedMessagesIsRead: false,
});
});
it("shares one unread polling lifecycle across subscribers", () => {
const transitions: boolean[] = [];
const subscribe = createMailUnreadPollingSubscription((active) =>
transitions.push(active)
);
const first = subscribe();
const second = subscribe();
first();
second();
second();
expect(transitions).toEqual([true, false]);
});
});
@@ -1,94 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const {
buildRouteLocationHref,
createRouteSurfaceNavigationState,
resolveRouteSurfaceCloseTo,
} = await server.ssrLoadModule("@nocobase/portal-sdk/routing");
const location = {
pathname: "/customers",
search: "?status=renewal&page=2",
hash: "#northwind",
state: { activeTab: "mine" },
};
assert.equal(
buildRouteLocationHref(location),
"/customers?status=renewal&page=2#northwind"
);
assert.deepEqual(createRouteSurfaceNavigationState(location), {
activeTab: "mine",
routeSurfaceReturnTo: "/customers?status=renewal&page=2#northwind",
});
assert.equal(
resolveRouteSurfaceCloseTo(
{ routeSurfaceReturnTo: "/customers?page=2" },
{ pathname: "/customers" }
),
"/customers?page=2"
);
assert.equal(
resolveRouteSurfaceCloseTo(undefined, {
pathname: "/customers/show/42",
search: "?tab=activity",
}),
"/customers/show/42?tab=activity"
);
const extensionSource = await readFile(
new URL("../extension.tsx", import.meta.url),
"utf8"
);
const demoSource = await readFile(
new URL("../demo/index.tsx", import.meta.url),
"utf8"
);
const guideSource = await readFile(
new URL("../demo/resource-action-guide.tsx", import.meta.url),
"utf8"
);
assert.match(
extensionSource,
/path: "edit\/:id"/,
"keeps list-level edit routes record-addressable"
);
assert.match(
demoSource,
/openChild\("edit\/42"\)/,
"opens list-level edit with the selected record id"
);
assert.match(
guideSource,
/path: "create",\s*resourceAction: "create"/,
"registers the canonical create route so the resource outlet is mounted"
);
assert.match(
guideSource,
/path: "edit\/:id",\s*resourceAction: "edit"/,
"registers the canonical edit route"
);
assert.match(
guideSource,
/path: "show\/:id",\s*resourceAction: "show"/,
"registers the canonical show route"
);
assert.match(
guideSource,
/path: "edit",\s*\/\/ Contextual duplicate:[\s\S]*?lazy: \(\) => import\("\.\/customers\/show-edit-route"\)/,
"keeps the detail-owned editor contextual instead of registering a duplicate resource action"
);
console.log("NocoBase route surfaces regression tests passed");
} finally {
await server.close();
}
+3 -2
View File
@@ -122,7 +122,7 @@
"preinstall": "node ./scripts/check-template-compatibility.mjs",
"prepack": "pnpm build",
"pretest": "pnpm build",
"test": "node --test ./tests/*.test.mjs"
"test": "vitest run"
},
"dependencies": {
"ignore": "^7.0.6",
@@ -145,6 +145,7 @@
"@types/node": "^20.0.0",
"@types/react": "^19.1.0",
"@types/semver": "^7.7.0",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^4.1.10"
}
}
+24 -8
View File
@@ -15,7 +15,7 @@ export type AuthSessionListener = (
value?: string
) => void;
type AuthSessionOptions = {
export type AuthSessionOptions = {
appName?: string;
storagePrefix?: string;
storageType?: AuthStorageType;
@@ -23,6 +23,11 @@ type AuthSessionOptions = {
storage?: StorageLike;
};
export type AuthSessionStorageKeyOptions = Pick<
AuthSessionOptions,
"appName" | "shareToken" | "storagePrefix"
>;
const DEFAULT_STORAGE_PREFIX = "NOCOBASE_";
const DEFAULT_STORAGE_TYPE: AuthStorageType = "localStorage";
const memoryStorage = new Map<string, string>();
@@ -67,6 +72,23 @@ const getCookie = (name: string) => {
}
};
export const resolveAuthSessionStorageKey = (
{
appName = "main",
shareToken = false,
storagePrefix = DEFAULT_STORAGE_PREFIX,
}: AuthSessionStorageKeyOptions,
field: AuthSessionField
) => {
const sharedSubAppToken =
field === "token" && appName !== "main" && shareToken;
const appPrefix =
appName === "main" || sharedSubAppToken
? storagePrefix
: `${storagePrefix}${appName.toUpperCase()}_`;
return `${appPrefix}${field}`.toUpperCase();
};
export class AuthSession {
readonly appName: string;
readonly storagePrefix: string;
@@ -99,13 +121,7 @@ export class AuthSession {
}
getStorageKey(field: AuthSessionField) {
const isSharedSubAppToken =
field === "token" && this.appName !== "main" && this.shareToken;
const appPrefix =
this.appName === "main" || isSharedSubAppToken
? this.storagePrefix
: `${this.storagePrefix}${this.appName.toUpperCase()}_`;
return `${appPrefix}${field}`.toUpperCase();
return resolveAuthSessionStorageKey(this, field);
}
get(field: AuthSessionField) {
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import {
clearRecordPermissions,
evaluateAccess,
filterMenuItemsByAcl,
findFirstAccessibleRoute,
getEffectiveRoles,
getPermissionsForDataSource,
getRecordActionPermission,
resolveAclDataSourceKey,
resolveActionPermission,
updateRecordPermissions,
} from "../src/acl/index.ts";
import {
buildRouteResources,
defineAppRoutes,
} from "../src/routing/index.ts";
const permissions = {
currentRole: "editor",
roles: ["editor", "auditor"],
roleMode: "allow-use-union",
resources: ["blog_posts"],
actionAlias: { list: "view", get: "view" },
actions: {
"blog_posts:view": { fields: ["id", "title"] },
"blog_posts:update": { fields: ["title"] },
},
strategy: { actions: ["view:all"] },
snippets: ["pm.*"],
};
describe("Portal ACL", () => {
it("combines roles with resource, action, and field permissions", () => {
expect(getEffectiveRoles(permissions)).toEqual(["editor", "auditor"]);
expect(
resolveActionPermission({
permissions,
resource: "blog_posts",
action: "list",
})
).toEqual({ fields: ["id", "title"] });
expect(
evaluateAccess(permissions, {
roles: {
anyOf: ["editor"],
allOf: ["editor", "auditor"],
noneOf: ["anonymous"],
},
resource: "blog_posts",
action: "edit",
field: "title",
})
).toBe(true);
expect(
evaluateAccess(permissions, {
resource: "blog_posts",
action: "edit",
field: "status",
})
).toBe(false);
});
it("selects permissions for external data sources", () => {
const externalPermissions = {
...permissions,
allowAll: true,
dataSources: {
analytics: {
allowAll: false,
resources: ["orders"],
actionAlias: { list: "read" },
actions: { "orders:read": {} },
},
},
};
expect(
getPermissionsForDataSource(externalPermissions, "analytics")
).toMatchObject({
allowAll: false,
resources: ["orders"],
actionAlias: { list: "read" },
});
expect(
evaluateAccess(externalPermissions, {
resource: "orders",
action: "list",
dataSourceKey: "analytics",
})
).toBe(true);
expect(
resolveAclDataSourceKey({
acl: { type: "collection", dataSourceKey: "analytics" },
})
).toBe("analytics");
});
it("applies record permissions to actions", () => {
clearRecordPermissions();
updateRecordPermissions({
resource: "blog_posts",
recordIds: [1, 2],
allowedActions: { view: [1, 2], update: [1], destroy: [] },
});
expect(
getRecordActionPermission({
resource: "blog_posts",
action: "update",
id: 2,
})
).toBe(false);
expect(
evaluateAccess(permissions, {
resource: "blog_posts",
action: "edit",
id: 1,
})
).toBe(true);
expect(
evaluateAccess(permissions, {
resource: "blog_posts",
action: "edit",
id: 2,
})
).toBe(false);
clearRecordPermissions();
});
it("keeps accessible children when a parent menu is denied", () => {
const filtered = filterMenuItemsByAcl(
[
{
key: "restricted-parent",
name: "restricted-parent",
route: "/restricted-parent",
children: [
{
key: "public-child",
name: "public-child",
route: "/public-child",
meta: { acl: { type: "authenticated" as const } },
children: [],
},
],
},
],
{ ...permissions, strategy: { actions: [] } }
);
expect(filtered[0].route).toBeUndefined();
expect(findFirstAccessibleRoute(filtered)).toBe("/public-child");
});
it("inherits route roles and rejects duplicate resource actions", () => {
const [resource] = buildRouteResources(
defineAppRoutes([
{
name: "administration",
path: "/administration",
access: { roles: { anyOf: ["admin"] } },
children: [
{
name: "reports",
path: "reports",
resource: { meta: { label: "Reports" } },
access: { roles: { anyOf: ["auditor"] } },
children: [
{
name: "reports.create",
path: "create",
resourceAction: "create",
},
],
},
],
},
])
);
expect(resource.list).toBe("/administration/reports");
expect(resource.create).toBe("/administration/reports/create");
expect(resource.meta?.routeAccess).toEqual([
{ roles: { anyOf: ["admin"] } },
{ roles: { anyOf: ["auditor"] } },
]);
expect(() =>
buildRouteResources([
{
name: "duplicate-actions",
path: "/duplicate-actions",
resource: {},
children: [
{ name: "first-create", path: "create", resourceAction: "create" },
{ name: "second-create", path: "new", resourceAction: "create" },
],
},
])
).toThrow(/declares multiple create routes/);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it } from "vitest";
import { AuthSession, NocoBaseClient } from "../src/client/index.ts";
import {
resolveNocoBaseAppName,
resolveNocoBasePortalName,
resolveNocoBaseSettingsUrl,
resolvePortalUrl,
} from "../src/runtime/index.ts";
const createStorage = () => {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
};
};
const originalWindow = globalThis.window;
afterEach(() => {
if (originalWindow === undefined) {
Reflect.deleteProperty(globalThis, "window");
} else {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
});
}
});
describe("Portal authentication session", () => {
it("resolves main applications, sub-applications, and Portal names", () => {
expect(
resolveNocoBaseAppName(
"/nocobase/x/apps/crm/customer/",
"/nocobase/api/__app/crm"
)
).toBe("crm");
expect(
resolveNocoBaseAppName("/x/customer/", "/api/__app/analytics")
).toBe("analytics");
expect(resolveNocoBaseAppName("/x/customer/", "/api")).toBe("main");
expect(resolveNocoBasePortalName("/x/hub/")).toBe("hub");
expect(
resolveNocoBasePortalName("/nocobase/x/apps/demo6/hub/")
).toBe("hub");
expect(resolveNocoBasePortalName("/")).toBeUndefined();
});
it("builds runtime URLs and request headers from the active Portal", () => {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
location: { origin: "http://localhost:14000" },
NOCOBASE_API_URL: "/api",
NOCOBASE_PORTAL_BASE: "/x/demo/",
},
});
expect(resolvePortalUrl("/users?tab=active#list")).toBe(
"/x/demo/users?tab=active#list"
);
expect(resolveNocoBaseSettingsUrl()).toBe(
"http://localhost:14000/settings"
);
expect(new NocoBaseClient("/api").getHeaders()["X-Portal"]).toBe("demo");
window.NOCOBASE_API_URL = "/api/__app/crm";
window.NOCOBASE_PORTAL_BASE = "/x/apps/crm/customer/";
expect(resolveNocoBaseSettingsUrl()).toBe(
"http://localhost:14000/settings/apps/crm"
);
expect(new NocoBaseClient("/api").getHeaders()["X-Portal"]).toBe(
"customer"
);
});
it("uses the SDK storage convention for main and sub-app sessions", () => {
const main = new AuthSession({ appName: "main", storage: createStorage() });
expect(main.getStorageKey("token")).toBe("NOCOBASE_TOKEN");
expect(main.getStorageKey("auth")).toBe("NOCOBASE_AUTH");
expect(main.getStorageKey("role")).toBe("NOCOBASE_ROLE");
const subApp = new AuthSession({
appName: "crm",
storage: createStorage(),
});
expect(subApp.getStorageKey("token")).toBe("NOCOBASE_CRM_TOKEN");
expect(subApp.getStorageKey("auth")).toBe("NOCOBASE_CRM_AUTH");
expect(subApp.getStorageKey("role")).toBe("NOCOBASE_CRM_ROLE");
const sharedToken = new AuthSession({
appName: "crm",
shareToken: true,
storage: createStorage(),
});
expect(sharedToken.getStorageKey("token")).toBe("NOCOBASE_TOKEN");
expect(sharedToken.getStorageKey("auth")).toBe("NOCOBASE_CRM_AUTH");
});
});
@@ -1,22 +1,21 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { expect, it } from "vitest";
import semver from "semver";
import { portalSdkCompatibilityPlugin } from "../dist/vite/index.js";
test("the SDK 2 range includes Template 3 and excludes adjacent generations", () => {
it("the SDK 2 range includes Template 3 and excludes adjacent generations", () => {
const range = ">=3.0.0 <4.0.0";
assert.equal(semver.satisfies("2.9.0", range), false);
assert.equal(semver.satisfies("3.0.0", range), true);
assert.equal(semver.satisfies("3.9.0", range), true);
assert.equal(semver.satisfies("4.0.0", range), false);
expect(semver.satisfies("2.9.0", range)).toBe(false);
expect(semver.satisfies("3.0.0", range)).toBe(true);
expect(semver.satisfies("3.9.0", range)).toBe(true);
expect(semver.satisfies("4.0.0", range)).toBe(false);
});
test("the Vite plugin reports an invalid base template version", () => {
it("the Vite plugin reports an invalid base template version", () => {
const projectRoot = fs.mkdtempSync(
path.join(os.tmpdir(), "portal-sdk-vite-compat-")
);
@@ -32,8 +31,7 @@ test("the Vite plugin reports an invalid base template version", () => {
);
const plugin = portalSdkCompatibilityPlugin({ root: projectRoot });
assert.throws(
() => plugin.configResolved(),
expect(() => plugin.configResolved()).toThrow(
/Invalid nocobase\.defaultTemplateVersion: not-semver/
);
} finally {
@@ -1,10 +1,9 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { expect, it } from "vitest";
import semver from "semver";
@@ -59,42 +58,38 @@ const runChecker = (packageJson) => {
return result;
};
test("accepts a derived template with a compatible base version", () => {
it("accepts a derived template with a compatible base version", () => {
const result = runChecker({
name: "@example/custom-portal",
version: "8.4.0",
nocobase: { defaultTemplateVersion: compatibleBaseVersion },
});
assert.equal(result.status, 0, result.stderr);
assert.ok(
result.stdout.includes(
`supports Default Template ${compatibleBaseVersion}`
)
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain(
`supports Default Template ${compatibleBaseVersion}`
);
});
test("rejects an incompatible base template version with an actionable error", () => {
it("rejects an incompatible base template version with an actionable error", () => {
const result = runChecker({
name: "@example/custom-portal",
version: "8.4.0",
nocobase: { defaultTemplateVersion: incompatibleBaseVersion },
});
assert.equal(result.status, 1);
assert.match(result.stderr, /Incompatible NocoBase Portal SDK/);
assert.ok(
result.stderr.includes(
`Current Default Template: ${incompatibleBaseVersion}`
)
expect(result.status).toBe(1);
expect(result.stderr).toMatch(/Incompatible NocoBase Portal SDK/);
expect(result.stderr).toContain(
`Current Default Template: ${incompatibleBaseVersion}`
);
assert.match(result.stderr, /Supported Default Template range/);
expect(result.stderr).toMatch(/Supported Default Template range/);
});
test("rejects projects that do not preserve their base template version", () => {
it("rejects projects that do not preserve their base template version", () => {
const result = runChecker({
name: "@example/custom-portal",
version: "8.4.0",
});
assert.equal(result.status, 1);
assert.match(result.stderr, /Unable to determine/);
assert.match(result.stderr, /nocobase\.defaultTemplateVersion/);
expect(result.status).toBe(1);
expect(result.stderr).toMatch(/Unable to determine/);
expect(result.stderr).toMatch(/nocobase\.defaultTemplateVersion/);
});
-141
View File
@@ -1,141 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearAcl,
getAclState,
loadAcl,
switchRole,
} from "../dist/acl/index.js";
import {
nocobaseClient,
NocoBaseHttpError,
} from "../dist/client/index.js";
const deniedData = {
portalName: "sales",
role: "admin",
roles: ["admin"],
roleMode: "allow-use-union",
allowAnonymous: false,
};
test("ACL state retains only a structured Portal access denial", async () => {
const originalRequest = nocobaseClient.request;
const previousRole = nocobaseClient.getRole();
nocobaseClient.setRole("admin");
nocobaseClient.request = async () => {
throw new NocoBaseHttpError({
status: 403,
message: "You do not have access to this Portal",
payload: {
errors: [
{
code: "PORTAL_ACCESS_DENIED",
message: "You do not have access to this Portal",
},
],
data: deniedData,
},
});
};
clearAcl();
try {
await loadAcl();
assert.deepEqual(getAclState(), {
status: "error",
error: getAclState().error,
portalAccessDenied: deniedData,
});
} finally {
clearAcl();
nocobaseClient.request = originalRequest;
nocobaseClient.setRole(previousRole ?? null);
}
});
test("ordinary forbidden errors do not enter the Portal denial state", async () => {
const originalRequest = nocobaseClient.request;
nocobaseClient.request = async () => {
throw new NocoBaseHttpError({
status: 403,
message: "Forbidden",
payload: {
errors: [{ code: "OTHER_FORBIDDEN", message: "Forbidden" }],
data: deniedData,
},
});
};
clearAcl();
try {
await loadAcl();
const state = getAclState();
assert.equal(state.status, "error");
assert.equal(state.portalAccessDenied, undefined);
} finally {
clearAcl();
nocobaseClient.request = originalRequest;
}
});
test("switching roles hands off to a full reload without preloading Portal ACL", async () => {
const originalAction = nocobaseClient.action;
const originalRequest = nocobaseClient.request;
const previousRole = nocobaseClient.getRole();
const actions = [];
let aclRequestCount = 0;
nocobaseClient.setRole("admin");
nocobaseClient.action = async (resource, action) => {
actions.push(`${resource}:${action}`);
return {};
};
nocobaseClient.request = async () => {
aclRequestCount += 1;
return {};
};
try {
await switchRole("member");
assert.deepEqual(actions, ["users:setDefaultRole", "auth:syncCookies"]);
assert.equal(aclRequestCount, 0);
assert.equal(nocobaseClient.getRole(), "member");
assert.deepEqual(getAclState(), { status: "idle" });
} finally {
clearAcl();
nocobaseClient.action = originalAction;
nocobaseClient.request = originalRequest;
nocobaseClient.setRole(previousRole ?? null);
}
});
for (const failedAction of ["users:setDefaultRole", "auth:syncCookies"]) {
test(`switching roles restores the previous role when ${failedAction} fails`, async () => {
const originalAction = nocobaseClient.action;
const previousRole = nocobaseClient.getRole();
const expectedError = new Error(`${failedAction} failed`);
const actions = [];
nocobaseClient.setRole("admin");
nocobaseClient.action = async (resource, action) => {
const actionName = `${resource}:${action}`;
actions.push(actionName);
if (actionName === failedAction) throw expectedError;
return {};
};
try {
await assert.rejects(switchRole("member"), expectedError);
assert.equal(nocobaseClient.getRole(), "admin");
assert.equal(actions[0], "users:setDefaultRole");
assert.ok(
failedAction === "users:setDefaultRole" ||
actions.filter((action) => action === "auth:syncCookies").length === 2
);
} finally {
clearAcl();
nocobaseClient.action = originalAction;
nocobaseClient.setRole(previousRole ?? null);
}
});
}
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearAcl,
getAclState,
loadAcl,
switchRole,
} from "../src/acl/index.ts";
import { getPortalAccessDeniedData } from "../src/acl/portal-access.ts";
import {
nocobaseClient,
NocoBaseHttpError,
} from "../src/client/index.ts";
const deniedData = {
portalName: "sales",
role: "admin",
roles: ["admin"],
roleMode: "allow-use-union" as const,
allowAnonymous: false,
};
const portalDeniedError = (overrides: {
status?: number;
code?: string;
data?: unknown;
} = {}) =>
new NocoBaseHttpError({
status: overrides.status ?? 403,
message: "You do not have access to this Portal",
payload: {
errors: [
{
code: overrides.code ?? "PORTAL_ACCESS_DENIED",
message: "You do not have access to this Portal",
},
],
data: overrides.data ?? deniedData,
},
});
afterEach(() => {
clearAcl();
nocobaseClient.setRole(null);
vi.restoreAllMocks();
});
describe("Portal access denial", () => {
it("retains only a structured Portal access denial in ACL state", async () => {
nocobaseClient.setRole("admin");
vi.spyOn(nocobaseClient, "request").mockRejectedValue(
portalDeniedError()
);
await loadAcl();
expect(getAclState()).toEqual({
status: "error",
error: getAclState().status === "error" ? getAclState().error : undefined,
portalAccessDenied: deniedData,
});
});
it("does not treat an ordinary forbidden response as Portal denial", async () => {
vi.spyOn(nocobaseClient, "request").mockRejectedValue(
portalDeniedError({ code: "OTHER_FORBIDDEN" })
);
await loadAcl();
expect(getAclState()).toMatchObject({ status: "error" });
expect(
getAclState().status === "error"
? getAclState().portalAccessDenied
: undefined
).toBeUndefined();
});
it("validates the structured denial payload", () => {
expect(getPortalAccessDeniedData(portalDeniedError())).toEqual(deniedData);
expect(
getPortalAccessDeniedData(
portalDeniedError({ data: { ...deniedData, role: "" } })
)
).toEqual({ ...deniedData, role: undefined });
expect(
getPortalAccessDeniedData(portalDeniedError({ code: "OTHER_FORBIDDEN" }))
).toBeUndefined();
expect(
getPortalAccessDeniedData(
portalDeniedError({ data: { ...deniedData, roles: ["admin", 1] } })
)
).toBeUndefined();
expect(
getPortalAccessDeniedData(portalDeniedError({ status: 500 }))
).toBeUndefined();
});
});
describe("switchRole", () => {
it("syncs the role and leaves ACL loading to the next page load", async () => {
const actions: string[] = [];
let aclRequestCount = 0;
nocobaseClient.setRole("admin");
vi.spyOn(nocobaseClient, "action").mockImplementation(
async (resource, action) => {
actions.push(`${resource}:${action}`);
return {};
}
);
vi.spyOn(nocobaseClient, "request").mockImplementation(async () => {
aclRequestCount += 1;
return {};
});
await switchRole("member");
expect(actions).toEqual(["users:setDefaultRole", "auth:syncCookies"]);
expect(aclRequestCount).toBe(0);
expect(nocobaseClient.getRole()).toBe("member");
expect(getAclState()).toEqual({ status: "idle" });
});
it.each(["users:setDefaultRole", "auth:syncCookies"])(
"restores the previous role when %s fails",
async (failedAction) => {
const actions: string[] = [];
const expectedError = new Error(`${failedAction} failed`);
nocobaseClient.setRole("admin");
vi.spyOn(nocobaseClient, "action").mockImplementation(
async (resource, action) => {
const actionName = `${resource}:${action}`;
actions.push(actionName);
if (actionName === failedAction) throw expectedError;
return {};
}
);
await expect(switchRole("member")).rejects.toBe(expectedError);
expect(nocobaseClient.getRole()).toBe("admin");
expect(actions[0]).toBe("users:setDefaultRole");
if (failedAction === "auth:syncCookies") {
expect(actions.filter((action) => action === failedAction)).toHaveLength(
2
);
}
}
);
});
@@ -1,5 +1,4 @@
import assert from "node:assert/strict";
import test from "node:test";
import { expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { evaluateAccess } from "../dist/acl/index.js";
@@ -18,14 +17,13 @@ import {
} from "../dist/i18n/index.js";
import { loadSystemSettings } from "../dist/system-settings/index.js";
test("published ACL and routing entry points are executable in Node", () => {
assert.equal(
it("published ACL and routing entry points are executable in Node", () => {
expect(
evaluateAccess(
{ allowAll: true, roles: [] },
{ resource: "users", action: "list" }
),
true
);
)
).toBe(true);
const routes = defineAppRoutes([
{
@@ -38,8 +36,8 @@ test("published ACL and routing entry points are executable in Node", () => {
},
]);
const resources = buildRouteResources(routes);
assert.equal(resources[0].list, "/users");
assert.equal(resources[0].create, "/users/create");
expect(resources[0].list).toBe("/users");
expect(resources[0].create).toBe("/users/create");
const location = {
pathname: "/customers",
@@ -47,24 +45,22 @@ test("published ACL and routing entry points are executable in Node", () => {
hash: "#renewal",
state: { activeTab: "mine" },
};
assert.equal(
buildRouteLocationHref(location),
expect(buildRouteLocationHref(location)).toBe(
"/customers?page=2#renewal"
);
assert.deepEqual(createRouteSurfaceNavigationState(location), {
expect(createRouteSurfaceNavigationState(location)).toEqual({
activeTab: "mine",
routeSurfaceReturnTo: "/customers?page=2#renewal",
});
assert.equal(
expect(
resolveRouteSurfaceCloseTo(
{ routeSurfaceReturnTo: "/customers?page=2" },
{ pathname: "/customers" }
),
"/customers?page=2"
);
)
).toBe("/customers?page=2");
});
test("published i18n and System Settings entry points are executable", async () => {
it("published i18n and System Settings entry points are executable", async () => {
registerTranslationResources("test", {
"en-US": { greeting: "Hello" },
});
@@ -74,11 +70,11 @@ test("published i18n and System Settings entry points are executable", async ()
initOptions: { defaultNS: "test", initImmediate: true },
});
assert.equal(translate("greeting", { ns: "test" }), "Hello");
assert.equal(typeof loadSystemSettings, "function");
expect(translate("greeting", { ns: "test" })).toBe("Hello");
expect(loadSystemSettings).toBeTypeOf("function");
});
test("application routes defer lazy page modules until they render", () => {
it("application routes defer lazy page modules until they render", () => {
let loadCount = 0;
const lazyRoute = async () => {
loadCount += 1;
@@ -100,17 +96,15 @@ test("application routes defer lazy page modules until they render", () => {
}
);
assert.equal(renderToStaticMarkup(routeElement.props.element), "Denied");
assert.equal(loadCount, 0);
assert.equal(
routeElement.props.element.props.children.props.fallback,
expect(renderToStaticMarkup(routeElement.props.element)).toBe("Denied");
expect(loadCount).toBe(0);
expect(routeElement.props.element.props.children.props.fallback).toBe(
"Loading reports"
);
});
test("application routes reject ambiguous eager and lazy content", () => {
assert.throws(
() =>
it("application routes reject ambiguous eager and lazy content", () => {
expect(() =>
renderAppRoutes([
{
name: "invalid",
@@ -118,7 +112,6 @@ test("application routes reject ambiguous eager and lazy content", () => {
element: "eager",
lazy: async () => ({ default: () => null }),
},
]),
/cannot declare both element and lazy/
);
])
).toThrow(/cannot declare both element and lazy/);
});
@@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { expect, it } from "vitest";
import { authProvider } from "../dist/auth/index.js";
import { authProvider } from "../src/auth/index.ts";
import {
authSession,
getNocoBaseErrorDetail,
@@ -11,8 +10,8 @@ import {
NocoBaseWebSocketClient,
normalizeNocoBaseRuntimeError,
resolveNocoBaseWebSocketUrl,
} from "../dist/client/index.js";
import { portalRuntimeStore } from "../dist/runtime/index.js";
} from "../src/client/index.ts";
import { portalRuntimeStore } from "../src/runtime/index.ts";
const jsonResponse = (status, payload) =>
new Response(JSON.stringify(payload), {
@@ -23,39 +22,42 @@ const jsonResponse = (status, payload) =>
},
});
test("runtime errors preserve server codes and authentication state", async () => {
it("runtime errors preserve server codes and authentication state", async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
try {
assert.equal(
getNocoBaseErrorDetail({ error: { code: "APP_COMMANDING" } })?.code,
"APP_COMMANDING"
);
assert.equal(
getNocoBaseErrorDetail({ errors: [{ code: "ROLE_NOT_FOUND_ERR" }] })?.code,
"ROLE_NOT_FOUND_ERR"
);
assert.equal(
isNocoBaseLifecycleError({ code: "APP_PREPARING" }),
true
);
assert.equal(isNocoBaseServiceError({ status: 504 }), true);
assert.equal(isNocoBaseServiceError({ status: 401 }), false);
assert.deepEqual(
expect(
getNocoBaseErrorDetail({ error: { code: "APP_COMMANDING" } })?.code
).toBe("APP_COMMANDING");
expect(
getNocoBaseErrorDetail({ errors: [{ code: "ROLE_NOT_FOUND_ERR" }] })?.code
).toBe("ROLE_NOT_FOUND_ERR");
expect(isNocoBaseLifecycleError({ code: "APP_PREPARING" })).toBe(true);
expect(isNocoBaseServiceError({ status: 504 })).toBe(true);
expect(isNocoBaseServiceError({ status: 401 })).toBe(false);
expect(
normalizeNocoBaseRuntimeError(
{ code: "APP_STOPPED", status: 503, message: "stopped" },
"websocket"
),
{
)
).toEqual({
code: "APP_STOPPED",
status: 503,
message: "stopped",
payload: { code: "APP_STOPPED", status: 503, message: "stopped" },
source: "websocket",
}
);
});
authSession.clearAuthentication();
authSession.set("token", "role-token");
globalThis.window = {
location: { origin: "https://example.com" },
NOCOBASE_API_URL: "https://example.com/api",
} as Window & typeof globalThis;
globalThis.fetch = async () =>
jsonResponse(200, { data: { token: "role-token" } });
await expect(
authProvider.login({ account: "tester", password: "secret" })
).resolves.toEqual({ success: true, redirectTo: "/" });
authSession.set("role", "deleted-role");
const roles = [];
globalThis.fetch = async (_url, options) => {
@@ -68,10 +70,10 @@ test("runtime errors preserve server codes and authentication state", async () =
return jsonResponse(200, { data: { id: 1, username: "tester" } });
};
assert.deepEqual(await authProvider.check(), { authenticated: true });
assert.equal(authSession.get("token"), "role-token");
assert.equal(authSession.get("role"), undefined);
assert.deepEqual(roles, ["deleted-role", undefined]);
await expect(authProvider.check()).resolves.toEqual({ authenticated: true });
expect(authSession.get("token")).toBe("role-token");
expect(authSession.get("role")).toBeUndefined();
expect(roles).toEqual(["deleted-role", undefined]);
authSession.set("token", "maintenance-token");
portalRuntimeStore.clear();
@@ -85,9 +87,9 @@ test("runtime errors preserve server codes and authentication state", async () =
},
});
assert.deepEqual(await authProvider.check(), { authenticated: true });
assert.equal(authSession.get("token"), "maintenance-token");
assert.equal(portalRuntimeStore.getState().error?.code, "APP_PREPARING");
await expect(authProvider.check()).resolves.toEqual({ authenticated: true });
expect(authSession.get("token")).toBe("maintenance-token");
expect(portalRuntimeStore.getState().error?.code).toBe("APP_PREPARING");
authSession.set("token", "no-role-token");
portalRuntimeStore.clear();
@@ -101,10 +103,9 @@ test("runtime errors preserve server codes and authentication state", async () =
],
});
assert.deepEqual(await authProvider.check(), { authenticated: true });
assert.equal(authSession.get("token"), "no-role-token");
assert.equal(
portalRuntimeStore.getState().error?.code,
await expect(authProvider.check()).resolves.toEqual({ authenticated: true });
expect(authSession.get("token")).toBe("no-role-token");
expect(portalRuntimeStore.getState().error?.code).toBe(
"USER_HAS_NO_ROLES_ERR"
);
@@ -115,19 +116,20 @@ test("runtime errors preserve server codes and authentication state", async () =
errors: [{ code: "INVALID_TOKEN", message: "invalid token" }],
});
assert.deepEqual(await authProvider.check(), {
await expect(authProvider.check()).resolves.toEqual({
authenticated: false,
redirectTo: "/login",
});
assert.equal(authSession.get("token"), undefined);
expect(authSession.get("token")).toBeUndefined();
} finally {
globalThis.fetch = originalFetch;
globalThis.window = originalWindow;
authSession.clearAuthentication();
portalRuntimeStore.clear();
}
});
test("the WebSocket URL preserves the server prefix and selects a sub-application", () => {
it("the WebSocket URL preserves the server prefix and selects a sub-application", () => {
const originalWindow = globalThis.window;
try {
globalThis.window = {
@@ -135,8 +137,7 @@ test("the WebSocket URL preserves the server prefix and selects a sub-applicatio
NOCOBASE_API_URL: "https://example.com/nocobase/api/__app/crm",
NOCOBASE_PORTAL_BASE: "/nocobase/x/apps/crm/customer/",
};
assert.equal(
resolveNocoBaseWebSocketUrl(),
expect(resolveNocoBaseWebSocketUrl()).toBe(
"wss://example.com/nocobase/ws?__appName=crm"
);
} finally {
@@ -144,7 +145,7 @@ test("the WebSocket URL preserves the server prefix and selects a sub-applicatio
}
});
test("a stale WebSocket cannot disrupt its replacement", () => {
it("a stale WebSocket cannot disrupt its replacement", () => {
const originalWebSocket = globalThis.WebSocket;
const originalWindow = globalThis.window;
const originalSetInterval = globalThis.setInterval;
@@ -214,27 +215,27 @@ test("a stale WebSocket cannot disrupt its replacement", () => {
client.subscribe((message) => messages.push(message));
client.connect();
sockets[0].open();
assert.deepEqual(sentMessages.at(-1), {
expect(sentMessages.at(-1)).toEqual({
type: "auth:token",
payload: { token: "socket-token", authenticator: "basic" },
});
client.authenticate();
assert.deepEqual(sentMessages.at(-1), {
expect(sentMessages.at(-1)).toEqual({
type: "auth:token",
payload: { token: "socket-token", authenticator: "basic" },
});
assert.equal(sentMessages.length, 2);
expect(sentMessages).toHaveLength(2);
client.reconnect();
sockets[1].open();
sockets[0].message({ type: "maintaining", payload: { code: "APP_ERROR" } });
sockets[0].finishClose();
assert.equal(intervals.size, 1);
assert.equal(timeouts.size, 0);
assert.deepEqual(messages, []);
expect(intervals.size).toBe(1);
expect(timeouts.size).toBe(0);
expect(messages).toEqual([]);
sockets[1].message({ type: "maintaining", payload: { code: "APP_STOPPED" } });
assert.equal(messages[0]?.payload?.code, "APP_STOPPED");
expect(messages[0]?.payload?.code).toBe("APP_STOPPED");
client.close();
} finally {
globalThis.WebSocket = originalWebSocket;
@@ -248,7 +249,7 @@ test("a stale WebSocket cannot disrupt its replacement", () => {
}
});
test("only explicit application lifecycle responses become global runtime states", async () => {
it("only explicit application lifecycle responses become global runtime states", async () => {
const originalFetch = globalThis.fetch;
const client = new NocoBaseClient("https://example.com/api");
@@ -257,8 +258,8 @@ test("only explicit application lifecycle responses become global runtime states
globalThis.fetch = async () =>
jsonResponse(504, { message: "report timed out" });
await assert.rejects(() => client.request("reports:run"));
assert.equal(portalRuntimeStore.getState().error, undefined);
await expect(client.request("reports:run")).rejects.toThrow();
expect(portalRuntimeStore.getState().error).toBeUndefined();
globalThis.fetch = async () =>
jsonResponse(503, {
@@ -270,9 +271,9 @@ test("only explicit application lifecycle responses become global runtime states
},
});
await assert.rejects(() => client.request("reports:run"));
assert.equal(portalRuntimeStore.getState().error?.code, "APP_PREPARING");
assert.equal(portalRuntimeStore.getState().error?.status, 503);
await expect(client.request("reports:run")).rejects.toThrow();
expect(portalRuntimeStore.getState().error?.code).toBe("APP_PREPARING");
expect(portalRuntimeStore.getState().error?.status).toBe(503);
} finally {
globalThis.fetch = originalFetch;
portalRuntimeStore.clear();
@@ -1,11 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { expect, it } from "vitest";
import { nocobaseClient } from "../dist/client/index.js";
import { nocobaseClient } from "../src/client/index.ts";
import {
clearSystemSettingsCache,
loadSystemSettings,
} from "../dist/system-settings/index.js";
} from "../src/system-settings/index.ts";
const deferred = () => {
let resolve;
@@ -15,7 +14,7 @@ const deferred = () => {
return { promise, resolve };
};
test("a stale System Settings request cannot overwrite a forced refresh", async () => {
it("a stale System Settings request cannot overwrite a forced refresh", async () => {
const originalAction = nocobaseClient.action;
const first = deferred();
const second = deferred();
@@ -30,20 +29,20 @@ test("a stale System Settings request cannot overwrite a forced refresh", async
const refreshRequest = loadSystemSettings(true);
second.resolve({ appLang: "zh-CN" });
assert.deepEqual(await refreshRequest, { appLang: "zh-CN" });
await expect(refreshRequest).resolves.toEqual({ appLang: "zh-CN" });
first.resolve({ appLang: "en-US" });
assert.deepEqual(await staleRequest, { appLang: "en-US" });
await expect(staleRequest).resolves.toEqual({ appLang: "en-US" });
assert.deepEqual(await loadSystemSettings(), { appLang: "zh-CN" });
assert.equal(requestCount, 2);
await expect(loadSystemSettings()).resolves.toEqual({ appLang: "zh-CN" });
expect(requestCount).toBe(2);
} finally {
clearSystemSettingsCache();
nocobaseClient.action = originalAction;
}
});
test("clearing System Settings ignores an in-flight response", async () => {
it("clearing System Settings ignores an in-flight response", async () => {
const originalAction = nocobaseClient.action;
const first = deferred();
const second = deferred();
@@ -61,8 +60,8 @@ test("clearing System Settings ignores an in-flight response", async () => {
const nextRequest = loadSystemSettings();
second.resolve({ appLang: "zh-CN" });
assert.deepEqual(await nextRequest, { appLang: "zh-CN" });
assert.equal(requestCount, 2);
await expect(nextRequest).resolves.toEqual({ appLang: "zh-CN" });
expect(requestCount).toBe(2);
} finally {
clearSystemSettingsCache();
nocobaseClient.action = originalAction;
@@ -2,10 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const sdkRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
".."
);
const sdkRoot = path.dirname(fileURLToPath(import.meta.url));
const packageRoot = process.argv[2]
? path.resolve(process.argv[2])
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
restoreMocks: true,
},
});
+2
View File
@@ -19,6 +19,8 @@ export const InputPassword = ({ className, ...props }: InputPasswordProps) => {
/>
<button
type="button"
aria-label={showPassword ? "Hide password" : "Show password"}
aria-pressed={showPassword}
className={cn(
"appearance-none",
"absolute right-3 top-1/2 -translate-y-1/2"
-126
View File
@@ -1,126 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
const createStorage = () => {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
};
};
try {
const { AuthSession, NocoBaseClient } = await server.ssrLoadModule(
"@nocobase/portal-sdk/client"
);
const {
resolveNocoBaseAppName,
resolveNocoBasePortalName,
resolveNocoBaseSettingsUrl,
resolvePortalUrl,
} = await server.ssrLoadModule("@nocobase/portal-sdk/runtime");
assert.equal(
resolveNocoBaseAppName(
"/nocobase/x/apps/crm/customer/",
"/nocobase/api/__app/crm"
),
"crm"
);
assert.equal(
resolveNocoBaseAppName("/x/customer/", "/api/__app/analytics"),
"analytics"
);
assert.equal(resolveNocoBaseAppName("/x/customer/", "/api"), "main");
assert.equal(resolveNocoBasePortalName("/x/hub/"), "hub");
assert.equal(
resolveNocoBasePortalName("/nocobase/x/apps/demo6/hub/"),
"hub"
);
assert.equal(resolveNocoBasePortalName("/"), undefined);
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
location: { origin: "http://localhost:14000" },
NOCOBASE_API_URL: "/api",
NOCOBASE_PORTAL_BASE: "/x/demo/",
},
});
assert.equal(
resolvePortalUrl("/users?tab=active#list"),
"/x/demo/users?tab=active#list"
);
assert.equal(
resolveNocoBaseSettingsUrl(),
"http://localhost:14000/settings"
);
assert.equal(
new NocoBaseClient("/api").getHeaders()["X-Portal"],
"demo"
);
window.NOCOBASE_API_URL = "/api/__app/crm";
window.NOCOBASE_PORTAL_BASE = "/x/apps/crm/customer/";
assert.equal(
resolvePortalUrl("/users?tab=active#list"),
"/x/apps/crm/customer/users?tab=active#list"
);
assert.equal(
resolveNocoBaseSettingsUrl(),
"http://localhost:14000/settings/apps/crm"
);
assert.equal(
new NocoBaseClient("/api").getHeaders()["X-Portal"],
"customer"
);
window.NOCOBASE_API_URL = "http://127.0.0.1:13000/api";
window.NOCOBASE_PORTAL_BASE = "/x/demo/";
assert.equal(
resolvePortalUrl("/users?tab=active#list"),
"http://localhost:14000/x/demo/users?tab=active#list"
);
if (originalWindow === undefined) {
delete globalThis.window;
} else {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
});
}
const main = new AuthSession({ appName: "main", storage: createStorage() });
assert.equal(main.getStorageKey("token"), "NOCOBASE_TOKEN");
assert.equal(main.getStorageKey("auth"), "NOCOBASE_AUTH");
assert.equal(main.getStorageKey("role"), "NOCOBASE_ROLE");
const subApp = new AuthSession({
appName: "crm",
storage: createStorage(),
});
assert.equal(subApp.getStorageKey("token"), "NOCOBASE_CRM_TOKEN");
assert.equal(subApp.getStorageKey("auth"), "NOCOBASE_CRM_AUTH");
assert.equal(subApp.getStorageKey("role"), "NOCOBASE_CRM_ROLE");
const sharedToken = new AuthSession({
appName: "crm",
shareToken: true,
storage: createStorage(),
});
assert.equal(sharedToken.getStorageKey("token"), "NOCOBASE_TOKEN");
assert.equal(sharedToken.getStorageKey("auth"), "NOCOBASE_CRM_AUTH");
console.log("NocoBase authentication session regression tests passed");
} finally {
await server.close();
}
+29
View File
@@ -0,0 +1,29 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { InputPassword } from "@/components/auth/input-password";
describe("InputPassword", () => {
it("reveals and hides the password without changing its value", async () => {
const user = userEvent.setup();
render(
<InputPassword
aria-label="Password"
defaultValue="correct horse battery staple"
/>
);
const input = screen.getByLabelText("Password");
expect(input).toHaveAttribute("type", "password");
await user.click(screen.getByRole("button", { name: "Show password" }));
expect(input).toHaveAttribute("type", "text");
expect(input).toHaveValue("correct horse battery staple");
await user.click(screen.getByRole("button", { name: "Hide password" }));
expect(input).toHaveAttribute("type", "password");
expect(input).toHaveValue("correct horse battery staple");
});
});
@@ -0,0 +1,182 @@
import { render, screen } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import {
AclStoreProvider,
type AclState,
type AclStore,
} from "@nocobase/portal-sdk/acl";
import { AclGate } from "@/components/access-control/acl-gate";
import { PortalAccessDeniedView } from "@/components/access-control/portal-access-denied";
import { hasMultipleUserRoles } from "@/components/access-control/portal-access-denied-roles";
import { resolveRoleSwitcherContext } from "@/extensions/nocobase-acl/components/role-switcher-context";
import { starter as zhCN } from "@/locales/zh-CN";
vi.mock("@refinedev/core", () => ({
useGetIdentity: () => ({ data: undefined, isLoading: false }),
useTranslate: () => (_key: string, fallback: string) => fallback,
}));
const deniedData = {
portalName: "sales",
role: "admin",
roles: ["admin"],
roleMode: "allow-use-union" as const,
allowAnonymous: true,
};
const renderAclError = (message: string) => {
const errorState: AclState = {
status: "error",
error: new Error(message),
};
const store: AclStore = {
getState: () => errorState,
subscribe: () => () => undefined,
load: async () => errorState,
retry: async () => errorState,
clear: () => undefined,
recordPermissions: {
getState: () => ({}),
subscribe: () => () => undefined,
getPermission: () => undefined,
},
};
return render(
<AclStoreProvider store={store}>
<AclGate>Allowed</AclGate>
</AclStoreProvider>
);
};
describe("AclGate", () => {
it("shows a translated fallback for a generic permission error", () => {
renderAclError("Unable to load permissions");
expect(
screen.getByRole("heading", { name: "Unable to load permissions" })
).toBeInTheDocument();
expect(
screen.getByText("Permissions for the current role could not be loaded.")
).toBeInTheDocument();
});
it("shows useful permission error details", () => {
renderAclError("Gateway timed out");
expect(screen.getByText("Gateway timed out")).toBeInTheDocument();
});
});
describe("PortalAccessDeniedView", () => {
const renderView = (roleSwitcher?: ReactNode) =>
render(
<PortalAccessDeniedView
title="You do not have access to this Portal"
description="Your current role cannot access this Portal. Select another role to try again."
currentRoleTitle={roleSwitcher ? "Administrator" : undefined}
roleSwitcher={roleSwitcher}
/>
);
it("presents the current role and role switcher accessibly", () => {
renderView(createElement("button", { type: "button" }, "Administrator"));
expect(screen.getByText("403")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Switch role" })
).toBeInTheDocument();
expect(screen.getByText("Current role")).toBeInTheDocument();
expect(screen.getAllByText("Administrator")).toHaveLength(2);
expect(screen.getByText("Select role")).toBeInTheDocument();
expect(
screen.getByText("Portal access will be checked again after switching.")
).toBeInTheDocument();
});
it("omits the role region when switching is unavailable", () => {
renderView();
expect(screen.queryByRole("region")).not.toBeInTheDocument();
});
});
describe("Portal access helpers", () => {
it("resolves role switcher context from ready and denied ACL states", () => {
expect(
resolveRoleSwitcherContext(
{
status: "error",
error: new Error("Denied"),
portalAccessDenied: deniedData,
},
"member"
)
).toEqual({
currentRole: "admin",
roleMode: "allow-use-union",
allowAnonymous: true,
});
expect(
resolveRoleSwitcherContext(
{ status: "error", error: new Error("Network error") },
"admin"
)
).toEqual({ currentRole: "admin" });
expect(
resolveRoleSwitcherContext(
{
status: "error",
error: new Error("Denied"),
portalAccessDenied: { ...deniedData, role: "" },
},
"member"
)
).toEqual({
currentRole: "member",
roleMode: "allow-use-union",
allowAnonymous: true,
});
expect(
resolveRoleSwitcherContext(
{
status: "ready",
permissions: {
currentRole: "member",
roles: ["member"],
roleMode: "default",
allowAnonymous: false,
},
},
"admin"
)
).toEqual({
currentRole: "member",
roleMode: "default",
allowAnonymous: false,
});
});
it("detects whether a user has multiple switchable roles", () => {
expect(hasMultipleUserRoles([{ name: "admin" }])).toBe(false);
expect(
hasMultipleUserRoles([{ name: "__union__" }, { name: "admin" }])
).toBe(false);
expect(
hasMultipleUserRoles([{ name: "admin" }, { name: "member" }])
).toBe(true);
});
it("keeps the Chinese Portal access translations", () => {
expect(zhCN["acl.portalAccessDenied.title"]).toBe("无权访问此门户");
expect(zhCN["acl.portalAccessDenied.description"]).toBe(
"当前角色没有访问权限,请切换角色后重试。"
);
expect(zhCN["acl.roleSwitcher.selectRole"]).toBe("选择角色");
expect(zhCN["acl.roleSwitcher.recheckPortalAccess"]).toBe(
"切换后将重新检查门户访问权限"
);
});
});
-58
View File
@@ -1,58 +0,0 @@
import assert from "node:assert/strict";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
try {
const {
formatPortalErrorDiagnostic,
normalizePortalError,
redactPortalErrorText,
} = await server.ssrLoadModule(
"@/extensions/nocobase-error-boundary/error-diagnostics"
);
assert.deepEqual(normalizePortalError("render failed"), {
name: "Error",
message: "render failed",
});
const redacted = redactPortalErrorText(
"Authorization: Bearer secret-token\nCookie: session=cookie-secret; role=admin\nhttps://example.test/api?token=query-secret&view=all"
);
assert.doesNotMatch(
redacted,
/secret-token|cookie-secret|role=admin|query-secret|view=all/
);
assert.match(redacted, /\[REDACTED\]/);
const error = new Error(
"Unable to render https://example.test/chart?access_token=message-secret"
);
error.stack = `${error.name}: ${error.message}\n at Chart (chart.tsx:10:2)`;
const diagnostic = formatPortalErrorDiagnostic(error, {
occurredAt: "2026-08-01T12:00:00.000Z",
route: "/users?token=route-secret#active",
templateName: "Default Template",
templateVersion: "2.0.0",
componentStack: "\n at Chart (chart.tsx:10:2)",
});
assert.match(diagnostic, /Portal runtime error/);
assert.match(diagnostic, /Route: \/users/);
assert.match(diagnostic, /Template: Default Template 2\.0\.0/);
assert.match(diagnostic, /JavaScript stack:/);
assert.match(diagnostic, /React component stack:/);
assert.doesNotMatch(
diagnostic,
/message-secret|route-secret|access_token=|\?token=/
);
console.log("NocoBase error boundary regression tests passed");
} finally {
await server.close();
}
-180
View File
@@ -1,180 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
allowsMultipleFiles,
serializeFileFieldValue,
} from "../registry/nocobase-file-upload/form-value.ts";
import { isNocoBaseManagedFileUrl } from "../registry/nocobase-file-upload/file-url-policy.ts";
import {
getAvailableFileCount,
resolveFileUploadMode,
} from "../registry/nocobase-file-upload/upload-strategy.ts";
import {
matchesFileRules,
resolveMaxFileSize,
validateFileForField,
} from "../registry/nocobase-file-upload/validation.ts";
import { validateFieldValidationControllers } from "../src/lib/field-validation.ts";
import type {
FileFieldDescriptor,
FileStorageInfo,
NocoBaseFileRecord,
} from "../registry/nocobase-file-upload/types.ts";
const multiDescriptor: FileFieldDescriptor = {
sourceCollection: "documents",
fieldName: "files",
fileCollection: "files",
relation: "belongsToMany",
};
const singleDescriptor: FileFieldDescriptor = {
...multiDescriptor,
fieldName: "cover",
relation: "belongsTo",
};
const localStorage: FileStorageInfo = {
id: 1,
name: "local",
type: "local",
};
test("upload strategy remains compatible across storage capability versions", () => {
assert.equal(resolveFileUploadMode(localStorage), "multipart");
assert.equal(
resolveFileUploadMode({ ...localStorage, type: "s3-compatible" }),
"direct"
);
assert.equal(
resolveFileUploadMode({
...localStorage,
type: "s3-compatible",
clientUpload: false,
}),
"multipart"
);
assert.equal(
resolveFileUploadMode({ ...localStorage, clientUpload: true }),
"direct"
);
assert.equal(resolveFileUploadMode(localStorage, "direct"), "direct");
});
test("reserved uploads count toward the file limit", () => {
assert.equal(getAvailableFileCount(1, 0, 1, 1), 0);
assert.equal(getAvailableFileCount(4, 1, 1, 5), 2);
assert.equal(getAvailableFileCount(undefined, 2, 3, 4), 4);
});
test("only NocoBase-managed URLs receive preview and download flags", () => {
const browserUrl = "https://portal.example.com/users";
const apiUrl = "https://api.example.com/api";
assert.equal(
isNocoBaseManagedFileUrl("/storage/files/report.pdf", apiUrl, browserUrl),
true
);
assert.equal(
isNocoBaseManagedFileUrl(
"https://api.example.com/storage/report.pdf",
apiUrl,
browserUrl
),
true
);
assert.equal(
isNocoBaseManagedFileUrl(
"https://bucket.example.com/report.pdf?X-Amz-Signature=abc",
apiUrl,
browserUrl
),
false
);
assert.equal(
isNocoBaseManagedFileUrl("data:text/plain,hello", apiUrl, browserUrl),
false
);
});
test("relation shape controls form serialization", () => {
const first: NocoBaseFileRecord = { id: 1, filename: "first.txt" };
const second: NocoBaseFileRecord = { id: 2, filename: "second.txt" };
assert.equal(allowsMultipleFiles(multiDescriptor), true);
assert.equal(allowsMultipleFiles(singleDescriptor), false);
assert.deepEqual(serializeFileFieldValue(multiDescriptor, [first, second]), [
{ id: 1 },
{ id: 2 },
]);
assert.deepEqual(serializeFileFieldValue(singleDescriptor, [first, second]), {
id: 1,
});
});
test("field and storage validation use the strictest rules", () => {
const image = new File([new Uint8Array(8)], "avatar.png", {
type: "image/png",
});
const text = new File([new Uint8Array(8)], "notes.txt", {
type: "text/plain",
});
const messages = {
fileSizeExceeded: (size: number) => `too large: ${size}`,
fieldMimeTypeRejected: "wrong field type",
};
assert.equal(matchesFileRules(image, ["image/*", ".pdf"]), true);
assert.equal(matchesFileRules(text, ["image/*", ".pdf"]), false);
assert.equal(
resolveMaxFileSize(
{ ...singleDescriptor, maxSize: 20 },
{ ...localStorage, rules: { size: 10 } }
),
10
);
assert.deepEqual(
validateFileForField(
image,
{ ...singleDescriptor, accept: "image/*" },
messages
),
{ valid: true }
);
assert.deepEqual(
validateFileForField(
text,
{ ...singleDescriptor, accept: "image/*" },
messages
),
{ valid: false, code: "mimetype", message: "wrong field type" }
);
});
test("field validation controllers stop at the first validation error", async () => {
const calls: string[] = [];
const result = await validateFieldValidationControllers([
{
validate: () => {
calls.push("first");
return true;
},
},
{
validate: async () => {
calls.push("second");
return "Upload is still in progress";
},
},
{
validate: () => {
calls.push("third");
return true;
},
},
]);
assert.equal(result, "Upload is still in progress");
assert.deepEqual(calls, ["first", "second"]);
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
getPortalStorageKey,
loadPortalE2EEnvironment,
requirePortalE2ECredentials,
resolvePortalApiActionURL,
resolvePortalTestURL,
} from "../../e2e/support";
describe("Portal E2E environment", () => {
it("resolves a locally hosted sub-application Portal and its API metadata", () => {
const environment = loadPortalE2EEnvironment({
NOCOBASE_E2E_PORT: "4180",
NOCOBASE_PORTAL_BASE: "/x/apps/sales/crm",
NOCOBASE_E2E_API_URL:
"https://api.example.test/api/__app/sales",
NOCOBASE_E2E_AUTHENTICATOR: "password",
NOCOBASE_E2E_ROLE: "sales-manager",
});
expect(environment).toMatchObject({
origin: "http://127.0.0.1:4180",
port: 4180,
baseURL: "http://127.0.0.1:4180/x/apps/sales/crm/",
apiURL: "https://api.example.test/api/__app/sales",
portalBase: "/x/apps/sales/crm/",
portalName: "crm",
appName: "sales",
authenticator: "password",
role: "sales-manager",
});
expect(resolvePortalTestURL(environment, "/login")).toBe(
"http://127.0.0.1:4180/x/apps/sales/crm/login"
);
expect(resolvePortalApiActionURL(environment, "auth", "signIn")).toBe(
"https://api.example.test/api/__app/sales/auth:signIn"
);
});
it("uses app-scoped session keys unless the sub-app token is shared", () => {
const environment = loadPortalE2EEnvironment({
NOCOBASE_PORTAL_BASE: "/x/apps/sales/crm/",
NOCOBASE_API_URL: "/api/__app/sales",
});
expect(getPortalStorageKey(environment, "token")).toBe(
"NOCOBASE_SALES_TOKEN"
);
expect(getPortalStorageKey(environment, "role")).toBe(
"NOCOBASE_SALES_ROLE"
);
expect(
getPortalStorageKey({ ...environment, shareToken: true }, "token")
).toBe("NOCOBASE_TOKEN");
});
it("requires non-empty credentials for login tests", () => {
const environment = loadPortalE2EEnvironment({});
expect(() =>
requirePortalE2ECredentials(environment, {
NOCOBASE_E2E_ACCOUNT: "nocobase",
NOCOBASE_E2E_PASSWORD: "",
})
).toThrow(/NOCOBASE_E2E_ACCOUNT and NOCOBASE_E2E_PASSWORD are required/);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { validateFieldValidationControllers } from "@/lib/field-validation";
describe("field validation controllers", () => {
it("stops at the first validation error", async () => {
const calls: string[] = [];
const result = await validateFieldValidationControllers([
{
validate: () => {
calls.push("first");
return true;
},
},
{
validate: async () => {
calls.push("second");
return "Upload is still in progress";
},
},
{
validate: () => {
calls.push("third");
return true;
},
},
]);
expect(result).toBe("Upload is still in progress");
expect(calls).toEqual(["first", "second"]);
});
});
-277
View File
@@ -1,277 +0,0 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite";
const server = await createServer({
appType: "custom",
configFile: false,
logLevel: "silent",
root: fileURLToPath(new URL("..", import.meta.url)),
resolve: {
alias: [
{
find: "@/extensions/nocobase-acl",
replacement: fileURLToPath(
new URL("../registry/nocobase-acl/index.ts", import.meta.url)
),
},
{
find: "@",
replacement: fileURLToPath(new URL("../src", import.meta.url)),
},
],
},
server: { middlewareMode: true },
});
try {
const { NocoBaseHttpError } = await server.ssrLoadModule(
"/sdk/src/client/error.ts"
);
const { getPortalAccessDeniedData } = await server.ssrLoadModule(
"/sdk/src/acl/portal-access.ts"
);
const { resolveRoleSwitcherContext } = await server.ssrLoadModule(
"/registry/nocobase-acl/components/role-switcher-context.ts"
);
const { AclGate } = await server.ssrLoadModule(
"/src/components/access-control/acl-gate.tsx"
);
const { PortalAccessDeniedView } = await server.ssrLoadModule(
"/src/components/access-control/portal-access-denied.tsx"
);
const { hasMultipleUserRoles } = await server.ssrLoadModule(
"/src/components/access-control/portal-access-denied-roles.ts"
);
const { AclStoreProvider } = await server.ssrLoadModule(
"@nocobase/portal-sdk/acl"
);
const { starter: zhCN } = await server.ssrLoadModule(
"/src/locales/zh-CN.ts"
);
const renderAclError = (message) => {
const errorState = { status: "error", error: new Error(message) };
const store = {
getState: () => errorState,
subscribe: () => () => undefined,
load: async () => errorState,
retry: async () => errorState,
clear: () => undefined,
recordPermissions: {
getState: () => ({}),
subscribe: () => () => undefined,
getPermission: () => undefined,
},
};
return renderToStaticMarkup(
createElement(
AclStoreProvider,
{ store },
createElement(AclGate, null, createElement("span", null, "Allowed"))
)
);
};
const genericErrorMarkup = renderAclError("Unable to load permissions");
assert.equal(
genericErrorMarkup.match(/Unable to load permissions/g)?.length,
1
);
assert.match(
genericErrorMarkup,
/Permissions for the current role could not be loaded\./
);
const detailedErrorMarkup = renderAclError("Gateway timed out");
assert.match(detailedErrorMarkup, /Gateway timed out/);
const deniedData = {
portalName: "sales",
role: "admin",
roles: ["admin"],
roleMode: "allow-use-union",
allowAnonymous: true,
};
const deniedError = new NocoBaseHttpError({
status: 403,
message: "You do not have access to this Portal",
payload: {
errors: [
{
code: "PORTAL_ACCESS_DENIED",
message: "You do not have access to this Portal",
},
],
data: deniedData,
},
});
assert.deepEqual(getPortalAccessDeniedData(deniedError), deniedData);
const emptyRoleDenied = getPortalAccessDeniedData(
new NocoBaseHttpError({
status: 403,
message: "You do not have access to this Portal",
payload: {
errors: [{ code: "PORTAL_ACCESS_DENIED" }],
data: { ...deniedData, role: "" },
},
})
);
assert.equal(emptyRoleDenied?.role, undefined);
assert.equal(
getPortalAccessDeniedData(
new NocoBaseHttpError({
status: 403,
message: "No permissions",
payload: { errors: [{ code: "OTHER_FORBIDDEN" }], data: deniedData },
})
),
undefined
);
assert.equal(
getPortalAccessDeniedData(
new NocoBaseHttpError({
status: 403,
message: "You do not have access to this Portal",
payload: {
errors: [{ code: "PORTAL_ACCESS_DENIED" }],
data: { ...deniedData, roles: ["admin", 1] },
},
})
),
undefined
);
assert.equal(
getPortalAccessDeniedData(
new NocoBaseHttpError({
status: 500,
message: "Server error",
payload: {
errors: [{ code: "PORTAL_ACCESS_DENIED" }],
data: deniedData,
},
})
),
undefined
);
assert.deepEqual(
resolveRoleSwitcherContext(
{
status: "error",
error: deniedError,
portalAccessDenied: deniedData,
},
"member"
),
{
currentRole: "admin",
roleMode: "allow-use-union",
allowAnonymous: true,
}
);
assert.deepEqual(
resolveRoleSwitcherContext(
{ status: "error", error: new Error("Network error") },
"admin"
),
{ currentRole: "admin" }
);
assert.deepEqual(
resolveRoleSwitcherContext(
{
status: "error",
error: deniedError,
portalAccessDenied: { ...deniedData, role: "" },
},
"member"
),
{
currentRole: "member",
roleMode: "allow-use-union",
allowAnonymous: true,
}
);
assert.deepEqual(
resolveRoleSwitcherContext(
{
status: "ready",
permissions: {
currentRole: "member",
roles: ["member"],
roleMode: "default",
allowAnonymous: false,
},
},
"admin"
),
{
currentRole: "member",
roleMode: "default",
allowAnonymous: false,
}
);
const portalDeniedMarkup = renderToStaticMarkup(
createElement(PortalAccessDeniedView, {
title: "You do not have access to this Portal",
description:
"Your current role cannot access this Portal. Select another role to try again.",
currentRoleTitle: "Administrator",
roleSwitcher: createElement("button", { type: "button" }, "Administrator"),
})
);
assert.match(portalDeniedMarkup, />403</);
assert.match(portalDeniedMarkup, /role="region"/);
assert.match(portalDeniedMarkup, /aria-label="Switch role"/);
assert.match(portalDeniedMarkup, /Current role/);
assert.match(portalDeniedMarkup, /Administrator/);
assert.match(portalDeniedMarkup, /Select role/);
assert.match(
portalDeniedMarkup,
/Portal access will be checked again after switching\./
);
const portalDeniedWithoutSwitcherMarkup = renderToStaticMarkup(
createElement(PortalAccessDeniedView, {
title: "You do not have access to this Portal",
description:
"Your current role cannot access this Portal. Select another role to try again.",
})
);
assert.doesNotMatch(portalDeniedWithoutSwitcherMarkup, /role="region"/);
assert.equal(zhCN["acl.portalAccessDenied.title"], "无权访问此门户");
assert.equal(
zhCN["acl.portalAccessDenied.description"],
"当前角色没有访问权限,请切换角色后重试。"
);
assert.equal(zhCN["acl.roleSwitcher.selectRole"], "选择角色");
assert.equal(
zhCN["acl.roleSwitcher.recheckPortalAccess"],
"切换后将重新检查门户访问权限"
);
assert.equal(hasMultipleUserRoles([{ name: "admin" }]), false);
assert.equal(
hasMultipleUserRoles([
{ name: "__union__" },
{ name: "admin" },
]),
false
);
assert.equal(
hasMultipleUserRoles([
{ name: "admin" },
{ name: "member" },
]),
true
);
console.log("NocoBase Portal access denied regression tests passed");
} finally {
await server.close();
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
+36
View File
@@ -0,0 +1,36 @@
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
const root = fileURLToPath(new URL(".", import.meta.url));
const registryRoot = fileURLToPath(new URL("./registry", import.meta.url));
const extensionsRoot = fs.existsSync(registryRoot)
? registryRoot
: fileURLToPath(new URL("./src/extensions", import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@/extensions": extensionsRoot,
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
root,
environment: "jsdom",
setupFiles: ["./tests/setup/vitest.ts"],
include: [
"tests/logic/**/*.test.{ts,tsx}",
"tests/components/**/*.test.{ts,tsx}",
"registry/*/tests/**/*.test.{ts,tsx}",
],
coverage: {
provider: "v8",
reportsDirectory: "./coverage",
reporter: ["text", "html"],
},
},
});