Files
nocobase/scripts/test-db.mjs
chenos 463a7a8f38 feat(app-testing): test applications on any dialect, with a portability check and every server dialect in CI (#525)
* chore(db-testing): fail tests that choose a dialect outside the database packages

pnpm db-tests:check reads every test and test helper outside the database
packages and fails on a dialect package import, ':memory:', dialect: 'sqlite',
PRAGMA, sqlite_master or a trigger. A test whose subject is a dialect says so
with a db-test-portability marker and a reason; template tests, which ship to
users, are exempted in the script instead. The app-server, app-cli and
template tests still to be ported with @nocobase/app-testing are listed as
pending, and the check fails once an entry is no longer needed. It runs in
pnpm check and in the Quality workflow next to the dependency checks.

* feat(db-testing): select every package that tests with db-testing through test:db --all

* feat(db-testing): run db-testing tests on every server dialect

* feat(create-plugin): generate database tests on db-testing

* test(create-plugin): keep the generated-test assertion clear of the portability check

* fix(db-testing): clean up a partial create, drop Oracle test users before 23ai, and run one provisioner contract everywhere

`createSqlTestDatabaseProvisioner()` drops what earlier create statements made when a later one fails, since the caller never receives the drop() that would; a failing first statement is left alone, because the name may belong to something that predates the run.

The Oracle provisioner drops a test user through a PL/SQL block that ignores only ORA-01918, instead of `drop user if exists`, which only 23ai accepts, and grants the materialized view and synonym privileges a migration may need.

`describeTestDatabaseProvisioner()` treats `listProvisioned` as optional throughout and drops the stale-run database in a finally, so a failed assertion leaves nothing behind. PostgreSQL and MySQL run it in place of their own provisioner tests.

* chore(db-testing): report every stale exemption and catch the SQLite driver in the portability check

A listed file or a marker that no longer covers a violation is reported, and so is a file both marked and listed, so every kind of exemption only shrinks. New rules catch a `better-sqlite3` or `sqlite3` import, a knex `client` naming either, and a dialect package imported by a name built at run time; the PRAGMA rule matches SQL rather than prose that mentions it. The `config init` test's marker reads as a sentence.

* test: take the pending database tests' databases from @nocobase/db-testing

Fifteen of the eighteen test files the portability check still listed as pending no longer choose a dialect. app-server's collections artifact, doctor, provider and task-plan tests, its repository routes and app-cli's db command tests run on databases provisioned by @nocobase/db-testing, one per connection they name, instead of SQLite files and in-memory databases. The examples template's article, analytics, numeric, permission-seed and workflow tests do the same; its articles migration test is a describeMigration. The cases whose subject is SQLite itself move to files marked sqlite-only or dialect-specific: a dry run not creating a missing SQLite file, and the driver loaded for a configured dialect. The examples template's external CRM test stays on its SQLite stand-in and is exempted in the check, since a template's tests ship to users and carry no marker.

PENDING keeps only the three templates' app-server tests, which move with @nocobase/app-testing.

* fix(db): only read in a dry run of rollback and repair

A migrator's rollback and repair, and a seeder's repair, took the task lock, ensured the history table and, for rollback, upgraded legacy checksums before looking at dryRun. On a database no task had run on, `nocobase db rollback --dry-run` (and redo, which goes through it) and `db repair --dry-run` therefore created the history and lock tables while reporting that nothing changed. SQLite hid it: a dry run against a database file that does not exist yet takes a separate read-only path, and every server database exists.

A dry run now reads history the way history() does, treating a missing table as empty history, and takes no lock. The migrator and seeder integration suites assert that these dry runs create nothing on every dialect, and the app-cli dry-run test no longer tolerates bookkeeping tables.

* feat(app-cli): let a bound test command load a configuration file of its own

bindAppCommand() takes configPath and hands it to the application's runtime the way APP_CONFIG_FILE is, so a command's test can run against a configuration it wrote, such as one naming a test database.

* feat(app-plugin-authentication): sign in from other packages' tests through a ./testing entry

signIn() signs in through the application's own sign-in route and returns a session whose fetch sends requests as that user, so a test can exercise the real authentication and authorization chain instead of an x-test-user header or a hand-written authenticationToken. DEFAULT_ADMIN_CREDENTIALS names the administrator the default seed creates.

* fix(db): keep in-process task lock claims per connection, not per connection name

Two Database Managers in one process each have a connection called main on databases of their own; one migrating while the other did failed with TaskLockBusyError although they never shared a database. The claims are now kept by the Database Connection a migration connection was made from, so the same connection still fails fast while another one under the same name proceeds; managers on the same database still take turns through the lock row. db-testing drops the queue that ran its test databases' migrations and seeds one after another to avoid the error.

* feat(app-testing): start an application on test databases of its own

@nocobase/app-testing/server's createTestApp() starts an application through its own standalone server on databases provisioned by @nocobase/db-testing, through the configuration file a deployment's own config.yml goes through, and installs it on start; createAppTest() is the Vitest fixture around it. ./cli carries @nocobase/app-cli/testing and bindTestAppCommand(), which points the application a command opens at a test configuration. Both re-export the layer below, so an application's or a plugin's tests depend on this package alone.

* test(templates): run the application tests on app-testing and check authorization through a real session

The three templates' application server tests start the template on test databases written by createTestAppConfig() instead of SQLite files, and the examples template's database tests take their API from @nocobase/app-testing/server, which every template now depends on for development. The portability check's pending list is empty and goes away; a template's SQLite-only test stays in EXEMPT.

The examples template signs in through its own sign-in route as the administrator and as a seeded proposal engineer, and checks that the sales confidentiality restriction leaves confidential quotes out of the engineer's list and in the administrator's, and that they appear once the restriction is no longer assigned. The nocobase-app-development Skill and AGENTS.md describe testing through the whole application.

* test: take plugin and example test fixtures from @nocobase/app-testing alone

Plugins, examples and the create-plugin generator import database fixtures
from @nocobase/app-testing/server and the command runner from
@nocobase/app-testing/cli instead of @nocobase/db-testing and
@nocobase/app-cli/testing, and depend on @nocobase/app-testing alone.
The database test portability check now fails when a test in a plugin, an
example or a template imports either lower layer directly; libraries and
tools keep using them. AGENTS.md, the db-testing README and the
app-development commands reference describe the single entry.

* fix: select app-testing users in test:db --all and keep each Skill's test entry its own

`pnpm test:db <dialect> --all` selected packages by a declared
@nocobase/db-testing, so once plugins, examples and templates moved to
@nocobase/app-testing the nightly Database tests workflow would have
stopped running them on PostgreSQL and MySQL. It now selects packages that
are or declare either one.

The command-authoring block shared by the application and plugin Skills
ended with the Test section, which now names a different runner in each:
@nocobase/app-testing/cli for an application, and the released
@nocobase/app-cli/testing in the global plugin Skill. The section moves
after the shared block in both.
2026-10-03 10:38:59 -04:00

190 lines
6.7 KiB
JavaScript

// Runs packages' tests against one database dialect:
//
// pnpm test:db <dialect> --filter <package> [--filter <package>…] [-- <arguments for each package's test script>]
// pnpm test:db <dialect> --all [-- <arguments>]
//
// --all selects every package that declares @nocobase/db-testing or @nocobase/app-testing as a dependency, and those
// two themselves: the packages whose tests take their database from db-testing, directly or, for a plugin or an
// application, through app-testing.
//
// SQLite runs as is. Any other dialect is started from its package's Compose file in a disposable project with a
// random name and port, the tests run with NOCOBASE_TEST_DB_DIALECT and the dialect's host and port variables set,
// and the project is removed afterwards; KEEP_TEST_DB=1 keeps it. Run one at a time, as the database integration
// suites are: two at once compete for the same machine.
//
// Runs under `node --import tsx`, because the Compose service of each dialect and the runner are TypeScript sources.
import { spawn } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const repoRoot = path.resolve(import.meta.dirname, '..');
export class TestDbUsageError extends Error {}
export function parseTestDbArguments(argv) {
const [dialect, ...rest] = argv;
if (!dialect || dialect.startsWith('-')) {
throw new TestDbUsageError(
'Usage: pnpm test:db <dialect> (--filter <package> | --all) [-- <test arguments>]',
);
}
if (!/^[a-z][a-z0-9-]*$/.test(dialect)) {
throw new TestDbUsageError(`"${dialect}" is not a dialect name.`);
}
const filters = [];
const testArguments = [];
let all = false;
for (let index = 0; index < rest.length; index += 1) {
const argument = rest[index];
if (argument === '--') {
testArguments.push(...rest.slice(index + 1));
break;
}
if (argument === '--all') {
all = true;
} else if (argument === '--filter' || argument === '-F') {
const value = rest[index + 1];
if (!value || value.startsWith('-')) {
throw new TestDbUsageError(`${argument} needs a package name.`);
}
filters.push(value);
index += 1;
} else if (argument.startsWith('--filter=')) {
filters.push(argument.slice('--filter='.length));
} else {
throw new TestDbUsageError(
`Unexpected argument "${argument}". Pass test arguments after a standalone --.`,
);
}
}
if (all && filters.length > 0) {
throw new TestDbUsageError('Pass either --all or --filter, not both.');
}
// The whole workspace's tests are CI's job; on one machine they take minutes and say little about a dialect.
if (!all && filters.length === 0) {
throw new TestDbUsageError(
'Name the packages to test with --filter, for example --filter @nocobase/app-plugin-scheduler, or pass --all.',
);
}
return { dialect, all, filters, testArguments };
}
/** The pnpm invocation that runs the filtered packages' tests, one package after another. */
export function testDbCommand({ filters, testArguments }) {
return {
command: 'pnpm',
args: [
...filters.flatMap((filter) => ['--filter', filter]),
// One package at a time: several suites at once compete for one machine and one database server, and a
// timeout they cause reads as a test failure.
'--workspace-concurrency=1',
// Every package runs even after one fails, so a run reports each package's result on the dialect.
'--no-bail',
'run',
'test',
// `pnpm run` hands everything after the script name to the script; a `--` would reach Vitest as one more
// argument and stop it treating the rest as file filters.
...testArguments,
],
};
}
/** The packages a test takes its database from: db-testing, and app-testing, which re-exports it. */
const TEST_DATABASE_PACKAGES = [
'@nocobase/db-testing',
'@nocobase/app-testing',
];
/** Packages that are or declare one of TEST_DATABASE_PACKAGES, by name. */
export function dbTestingPackages(root = repoRoot) {
const names = [];
const packagesDirectory = path.join(root, 'packages');
for (const group of readdirSync(packagesDirectory, { withFileTypes: true })) {
if (!group.isDirectory()) continue;
for (const entry of readdirSync(path.join(packagesDirectory, group.name), {
withFileTypes: true,
})) {
const manifestPath = path.join(
packagesDirectory,
group.name,
entry.name,
'package.json',
);
if (!entry.isDirectory() || !existsSync(manifestPath)) continue;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const declared = {
...manifest.dependencies,
...manifest.devDependencies,
...manifest.peerDependencies,
};
if (
TEST_DATABASE_PACKAGES.includes(manifest.name) ||
TEST_DATABASE_PACKAGES.some((name) => name in declared)
) {
names.push(manifest.name);
}
}
}
return names.sort();
}
export async function testDb(argv, { cwd = repoRoot } = {}) {
const parsed = parseTestDbArguments(argv);
const { dialect } = parsed;
const { command, args } = testDbCommand({
filters: parsed.all ? dbTestingPackages(cwd) : parsed.filters,
testArguments: parsed.testArguments,
});
const environment = { NOCOBASE_TEST_DB_DIALECT: dialect };
if (dialect === 'sqlite') {
return run(command, args, { ...process.env, ...environment }, cwd);
}
const serviceModule = path.join(
cwd,
'packages/libs',
`db-${dialect}`,
'scripts/integration-service.ts',
);
if (!existsSync(serviceModule)) {
throw new TestDbUsageError(
`No Compose service is defined for "${dialect}": expected ${path.relative(cwd, serviceModule)}.`,
);
}
const { integrationService } = await import(
pathToFileURL(serviceModule).href
);
const { runWithDatabaseService } = await import(
pathToFileURL(
path.join(cwd, 'packages/libs/db-testkit/src/integration-runner.ts'),
).href
);
return runWithDatabaseService({
...integrationService,
command,
args,
environment,
});
}
function run(command, args, env, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, env, stdio: 'inherit' });
child.once('error', reject);
child.once('close', (code) => resolve(code ?? 1));
});
}
const isMain =
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) {
try {
process.exitCode = await testDb(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}