Files
nocobase/scripts/check-db-test-portability.mjs
T
YANG QIA 0cc0327806 feat(studio): import NocoBase Studio into packages/apps (#10666)
NocoBase Studio moves from the private nocobase/studio repository
(archived at e4a18fd) into packages/apps/studio and is published as
@nocobase/studio. The framework submodule, the mirrored workspace file,
the lockfile and the submodule check go away; Studio now resolves the
framework packages and plugins from this workspace.

- packages/apps is a new package category for product applications.
  check-openapi checks Studio, and the database test portability check
  covers it; Studio's tests now take their databases from
  @nocobase/app-testing.
- A new migration, 202610220010_studio_rename_package, moves the
  migration and seed history recorded under the old package name
  `studio` to `@nocobase/studio` and rewrites the i18n namespace of
  workflow definitions copied from the software template. Released
  migrations are unchanged.
- Studio follows the framework changes since its last submodule
  pointer: the workflow plugin and the client's dev and settings route
  surfaces are gone.
- Prepared for publication: internal infrastructure, ticket keys and
  references to the predecessor product are removed, the demo data is
  in English, and tests are no longer published.
2026-10-10 19:53:49 +08:00

347 lines
12 KiB
JavaScript

// Verifies that tests outside the database packages do not choose a dialect.
//
// A test that needs a database gets it from `@nocobase/db-testing`, which runs it on SQLite by default and on the
// dialect `NOCOBASE_TEST_DB_DIALECT` names otherwise. A test that configures SQLite itself, or asserts on SQL only
// SQLite understands, passes on every default run and fails only once someone selects another database — which is
// how the plugins' migrations went unchecked on PostgreSQL and MySQL until `@nocobase/db-testing` existed. See
// AGENTS.md, "Database Tests Do Not Choose a Dialect".
//
// A test whose subject is a dialect — the SQLite driver, configuration that names one — says so in its first lines:
//
// // db-test-portability: sqlite-only — <why>
// // db-test-portability: dialect-specific — <why>
//
// Files that cannot carry the marker are listed below with the reason.
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
/** Groups whose tests are checked. The database packages own their dialects and are skipped below. */
const CHECKED_GROUPS = [
'plugins',
'examples',
'app',
'templates',
'apps',
'tools',
'libs',
];
/** `@nocobase/db`, `@nocobase/db-testkit`, `@nocobase/db-testing` and the dialect packages test dialects on purpose. */
const SKIPPED_LIBRARIES = /^db(?:-[a-z]+)?$/;
const SKIPPED_DIRECTORIES = new Set([
'node_modules',
'dist',
'build',
'coverage',
]);
const TEST_FILE = /\.test\.(?:[cm]?[jt]sx?)$/;
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/;
const DIALECTS = 'sqlite|postgres|mysql|oracle|mssql|kingbase|oceanbase|dameng';
export const RULES = [
{
id: 'dialect-import',
pattern: new RegExp(
String.raw`(?:\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"\`]@nocobase/db-(?:${DIALECTS})(?:/[^'"\`]*)?['"\`]`,
'm',
),
message:
'imports a dialect package; take the database from @nocobase/app-testing/server, or @nocobase/db-testing in a library',
},
{
id: 'dynamic-dialect-import',
// A dialect package named at run time, such as import(`@nocobase/db-${dialect}`).
pattern: /@nocobase\/db-\$\{/,
message:
'imports a dialect package chosen at run time; take the database from @nocobase/app-testing/server, or @nocobase/db-testing in a library',
},
{
id: 'sqlite-driver',
pattern: new RegExp(
String.raw`(?:\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"\`](?:better-sqlite3|sqlite3)['"\`]|\bclient\s*:\s*['"\`](?:better-sqlite3|sqlite3)['"\`]`,
'm',
),
message:
'opens SQLite through its driver; take the database from @nocobase/app-testing/server, or @nocobase/db-testing in a library',
},
{
id: 'memory-database',
pattern: /['"`]:memory:['"`]/,
message:
"configures ':memory:'; take the database from @nocobase/app-testing/server, or @nocobase/db-testing in a library",
},
{
id: 'sqlite-dialect',
pattern: /\bdialect\s*:\s*['"`]sqlite['"`]/,
message:
"configures dialect: 'sqlite'; take the database from @nocobase/app-testing/server, or @nocobase/db-testing in a library",
},
{
id: 'pragma',
// `pragma <name>` as SQL: followed by its argument, an assignment, the end of the statement or of the string
// holding it. Prose that mentions PRAGMA, and an HTTP `Pragma` header, are not.
pattern: /\bpragma\s+[a-z_]+\s*(?:\(|=|;|['"`]|$)/im,
message:
'runs a SQLite PRAGMA; assert on the schema with expectCollection() or inspectCollection()',
},
{
id: 'sqlite-catalog',
pattern: /\bsqlite_(?:master|schema)\b/i,
message:
"reads SQLite's catalog; use connection.schemaInspector or expectCollection()",
},
{
id: 'trigger',
pattern: /\bcreate\s+trigger\b/i,
message:
'creates a trigger; make the write fail with vi.spyOn() on the write instead',
},
];
const MARKER =
/db-test-portability:\s*(sqlite-only|dialect-specific)\b(?:\s*[—–-]\s*(\S.*))?/;
/**
* Files that do not carry the marker themselves. A template's tests ship inside the template a user scaffolds, so
* a note about this repository's checks has no place in them.
*/
export const EXEMPT = new Map([
[
'packages/templates/app-template-examples/tests/logic/config.test.ts',
'the application configuration under test names SQLite connections',
],
[
'packages/templates/app-template-examples/tests/components/numeric-examples.test.tsx',
'a client component test whose mocked API data names the SQLite dialect',
],
[
'packages/templates/app-template-examples/tests/logic/external-crm.test.ts',
'the external CRM stand-in under test is a SQLite file by design; server/providers/external-crm-sample.ts creates it on SQLite only',
],
]);
/**
* Where an application's or a plugin's tests take their test fixtures from. `@nocobase/app-testing` carries
* everything `@nocobase/db-testing` and `@nocobase/app-cli/testing` export, so these packages depend on it alone and
* install one copy of `@nocobase/db-testing`, whose module state — provisioner cache, stale-database cleanup — exists
* once per copy. Libraries, tools and the application runtime packages themselves use the layers below directly.
*/
const ENTRY_GROUPS = new Set(['plugins', 'examples', 'templates', 'apps']);
export const ENTRY_RULES = [
{
id: 'db-testing-entry',
pattern:
/(?:\bfrom\s+|\bimport\s*\(\s*|^\s*import\s+)['"`]@nocobase\/db-testing(?:\/vitest)?['"`]/m,
message:
'imports @nocobase/db-testing; an application or a plugin imports it from @nocobase/app-testing/server',
},
{
id: 'app-cli-testing-entry',
pattern:
/(?:\bfrom\s+|\bimport\s*\(\s*|^\s*import\s+)['"`]@nocobase\/app-cli\/testing['"`]/m,
message:
'imports @nocobase/app-cli/testing; an application or a plugin imports it from @nocobase/app-testing/cli',
},
];
/** The entry rules a test of an application or a plugin breaks; no marker or listing exempts it from them. */
export function findEntryViolations(relativePath, source) {
const group = relativePath.split('/')[1];
if (!ENTRY_GROUPS.has(group)) return [];
const violations = [];
for (const rule of ENTRY_RULES) {
const match = rule.pattern.exec(source);
if (!match) continue;
const line = source.slice(0, match.index).split('\n').length;
violations.push({ rule: rule.id, message: rule.message, line });
}
return violations;
}
/** The rules a source breaks, with the 1-based line of the first match of each. */
export function findViolations(source) {
const violations = [];
for (const rule of RULES) {
const match = rule.pattern.exec(source);
if (!match) continue;
const line = source.slice(0, match.index).split('\n').length;
violations.push({ rule: rule.id, message: rule.message, line });
}
return violations;
}
/**
* The marker a file declares in its first lines: `{ kind, reason }`, `{ kind, reason: undefined }` when the reason
* is missing, or `undefined`.
*/
export function readMarker(source) {
const head = source.split('\n', 5).join('\n');
const match = MARKER.exec(head);
if (!match) return undefined;
return { kind: match[1], reason: match[2]?.trim() || undefined };
}
/** Test files, and every source file under a `tests` directory: helpers configure databases as often as tests do. */
async function collectTestFiles(directory, files = [], insideTests = false) {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return files;
throw error;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
await collectTestFiles(
path.join(directory, entry.name),
files,
insideTests || entry.name === 'tests' || entry.name === '__tests__',
);
} else if (
SOURCE_FILE.test(entry.name) &&
(insideTests || TEST_FILE.test(entry.name))
) {
files.push(path.join(directory, entry.name));
}
}
return files;
}
async function checkedPackageDirectories(repositoryRoot) {
const directories = [];
for (const group of CHECKED_GROUPS) {
const groupDirectory = path.join(repositoryRoot, 'packages', group);
let entries;
try {
entries = await readdir(groupDirectory, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') continue;
throw error;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (group === 'libs' && SKIPPED_LIBRARIES.test(entry.name)) continue;
directories.push(path.join(groupDirectory, entry.name));
}
}
return directories;
}
/**
* Every problem in the repository: files breaking a rule without an exemption, markers without a reason, and
* listed files that no longer need their entry.
*/
export async function checkDbTestPortability({ repositoryRoot }) {
const problems = [];
const seen = new Set();
for (const directory of await checkedPackageDirectories(repositoryRoot)) {
for (const file of await collectTestFiles(directory)) {
const relativePath = path
.relative(repositoryRoot, file)
.split(path.sep)
.join('/');
const source = await readFile(file, 'utf8');
for (const violation of findEntryViolations(relativePath, source)) {
problems.push({
file: relativePath,
line: violation.line,
message: violation.message,
});
}
const violations = findViolations(source);
const marker = readMarker(source);
const listed = EXEMPT.has(relativePath);
if (listed) seen.add(relativePath);
if (marker && !marker.reason) {
problems.push({
file: relativePath,
line: 1,
message: `its db-test-portability marker gives no reason; write "// db-test-portability: ${marker.kind} — <why>"`,
});
continue;
}
// Every kind of exemption only shrinks: a listed file or a marker that no longer covers a violation is
// reported, so the exemption cannot outlive its reason and hide a construct added later.
if (violations.length === 0) {
if (listed) {
problems.push({
file: relativePath,
line: 1,
message:
'no longer chooses a dialect; remove it from EXEMPT in scripts/check-db-test-portability.mjs',
});
} else if (marker) {
problems.push({
file: relativePath,
line: 1,
message:
'no longer chooses a dialect; remove its db-test-portability marker',
});
}
continue;
}
if (marker && listed) {
problems.push({
file: relativePath,
line: 1,
message:
'carries a db-test-portability marker and is also listed in scripts/check-db-test-portability.mjs; remove the entry',
});
continue;
}
if (marker || listed) continue;
for (const violation of violations) {
problems.push({
file: relativePath,
line: violation.line,
message: violation.message,
});
}
}
}
for (const listedPath of EXEMPT.keys()) {
if (!seen.has(listedPath)) {
problems.push({
file: listedPath,
line: 1,
message:
'is listed in scripts/check-db-test-portability.mjs but no longer exists; remove the entry',
});
}
}
return problems;
}
async function main() {
const repositoryRoot = path.resolve(import.meta.dirname, '..');
const problems = await checkDbTestPortability({ repositoryRoot });
if (problems.length > 0) {
for (const problem of problems) {
console.error(`${problem.file}:${problem.line} ${problem.message}`);
if (process.env.GITHUB_ACTIONS) {
console.error(
`::error file=${problem.file},line=${problem.line}::${problem.message}`,
);
}
}
console.error(
'\nSee AGENTS.md, "Database Tests Do Not Choose a Dialect", and packages/libs/db-testing/README.md.',
);
process.exit(1);
}
console.log(
'Database tests choose no dialect outside the database packages.',
);
}
if (process.argv[1] === import.meta.filename) {
await main();
}