Commit 19435e58 by Archer Committed by GitHub

test: parallelize Mongo-backed Vitest suites (#6984)

* test: parallelize mongo-backed vitest suites

* ci: split FastGPT test jobs

* ci: publish one coverage report

* test: fix app suite under retained mongo indexes

* test: keep app vitest config self-contained

* test: preserve root username in fixtures
parent a14f76c3
name: "FastGPT-Test"
name: 'FastGPT-Test'
on:
pull_request:
workflow_dispatch:
# Only one build per PR branch at a time
concurrency:
group: "test-fastgpt-${{ github.event.pull_request.number || github.ref }}"
group: 'test-fastgpt-${{ github.event.pull_request.number || github.ref }}'
cancel-in-progress: true
permissions:
# Required to checkout the code
contents: read
# Required to put a comment into the pull-request
issues: write
pull-requests: write
jobs:
test:
test-global:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
......@@ -28,45 +29,190 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"
node-version: '24'
cache: 'pnpm'
- name: "Install Deps"
- name: 'Install Deps'
run: pnpm install --frozen-lockfile
- name: "Test Global"
- name: 'Test Global'
run: pnpm test:global
- name: "Test Service"
run: pnpm test:service
- name: "Test App"
run: pnpm test:app
- name: "Report Coverage (Global)"
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
- name: 'Upload Coverage (Global)'
if: always() && hashFiles('packages/global/coverage/coverage-summary.json') !=
''
uses: davelosert/vitest-coverage-report-action@v2
uses: actions/upload-artifact@v4
with:
name: coverage-global
path: |
packages/global/coverage/coverage-final.json
packages/global/coverage/coverage-summary.json
test-service:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref || github.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name ||
github.repository }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
name: global
json-final-path: packages/global/coverage/coverage-final.json
json-summary-path: packages/global/coverage/coverage-summary.json
node-version: '24'
cache: 'pnpm'
- name: 'Install Deps'
run: pnpm install --frozen-lockfile
- name: 'Test Service'
run: pnpm test:service
- name: "Report Coverage (Service)"
- name: 'Upload Coverage (Service)'
if: always() && hashFiles('packages/service/coverage/coverage-summary.json') !=
''
uses: davelosert/vitest-coverage-report-action@v2
uses: actions/upload-artifact@v4
with:
name: service
json-final-path: packages/service/coverage/coverage-final.json
json-summary-path: packages/service/coverage/coverage-summary.json
name: coverage-service
path: |
packages/service/coverage/coverage-final.json
packages/service/coverage/coverage-summary.json
- name: "Report Coverage (App)"
test-app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref || github.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name ||
github.repository }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'
- name: 'Install Deps'
run: pnpm install --frozen-lockfile
- name: 'Test App'
run: pnpm test:app
- name: 'Upload Coverage (App)'
if: always() && hashFiles('projects/app/coverage/coverage-summary.json') != ''
uses: davelosert/vitest-coverage-report-action@v2
uses: actions/upload-artifact@v4
with:
name: coverage-app
path: |
projects/app/coverage/coverage-final.json
projects/app/coverage/coverage-summary.json
report-coverage:
runs-on: ubuntu-latest
needs: [test-global, test-service, test-app]
if: always()
steps:
- name: 'Download Coverage Artifacts'
uses: actions/download-artifact@v4
with:
name: app
json-final-path: projects/app/coverage/coverage-final.json
json-summary-path: projects/app/coverage/coverage-summary.json
path: coverage-artifacts
pattern: coverage-*
- name: 'Report Coverage'
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
const marker = '<!-- fastgpt-coverage-report -->';
const packages = [
{ name: 'Global', dir: 'coverage-global' },
{ name: 'Service', dir: 'coverage-service' },
{ name: 'App', dir: 'coverage-app' }
];
const formatPct = (value) => {
if (typeof value !== 'number') return 'N/A';
return `${value.toFixed(2)}%`;
};
const rows = packages.map((pkg) => {
const summaryPath = path.join(
process.cwd(),
'coverage-artifacts',
pkg.dir,
'coverage-summary.json'
);
if (!fs.existsSync(summaryPath)) {
return `| ${pkg.name} | N/A | N/A | N/A | N/A |`;
}
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
const total = summary.total || {};
return [
`| ${pkg.name}`,
formatPct(total.statements?.pct),
formatPct(total.branches?.pct),
formatPct(total.functions?.pct),
`${formatPct(total.lines?.pct)} |`
].join(' | ');
});
const body = [
marker,
'## Coverage Report',
'',
'| Package | Statements | Branches | Functions | Lines |',
'| --- | ---: | ---: | ---: | ---: |',
...rows
].join('\n');
await core.summary.addRaw(body).write();
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
try {
const comments = await github.rest.issues.listComments({
owner,
repo,
issue_number,
per_page: 100
});
const existing = comments.data.find((comment) =>
comment.body?.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body
});
}
} catch (error) {
core.warning(`Failed to publish coverage comment: ${error.message}`);
}
- name: 'Check Test Results'
run: |
if [ "${{ needs.test-global.result }}" != "success" ] ||
[ "${{ needs.test-service.result }}" != "success" ] ||
[ "${{ needs.test-app.result }}" != "success" ]; then
exit 1
fi
import { resolve } from 'node:path';
import { configDefaults, defineConfig } from 'vitest/config';
import { getTestMaxWorkers } from '../../test/vitestWorkers';
export default defineConfig({
resolve: {
......@@ -45,7 +46,8 @@ export default defineConfig({
outputFile: 'test-results.json',
setupFiles: '../../test/setup.ts',
globalSetup: '../../test/globalSetup.ts',
fileParallelism: false,
fileParallelism: true,
maxWorkers: getTestMaxWorkers(),
maxConcurrency: 10,
pool: 'threads',
testTimeout: 20000,
......
import { resolve } from 'node:path';
import { defineConfig } from 'vitest/config';
/**
* Keep this helper local to the app project because the app Docker build context
* does not include the repository root `test/` directory, but Next still
* type-checks this config during image builds.
*/
const getTestMaxWorkers = () => {
const raw = process.env.FASTGPT_TEST_MAX_WORKERS;
if (!raw) return 4;
if (raw.endsWith('%')) return raw as `${number}%`;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
};
export default defineConfig({
resolve: {
alias: {
......@@ -46,7 +60,8 @@ export default defineConfig({
outputFile: 'test-results.json',
setupFiles: '../../test/setup.ts',
globalSetup: '../../test/globalSetup.ts',
fileParallelism: false,
fileParallelism: true,
maxWorkers: getTestMaxWorkers(),
maxConcurrency: 10,
pool: 'threads',
testTimeout: 20000,
......
......@@ -14,11 +14,33 @@ import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema';
import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils';
import type { parseHeaderCertRet } from '@test/mocks/request';
/**
* Create an authenticated system root user fixture.
*
* `authSystemAdmin` checks for the literal username `root`, so the fixture must
* keep that stable. Some tests call this helper multiple times before per-test
* Mongo cleanup runs; reuse the existing root document to preserve unique
* indexes while still creating an isolated team/member for each caller.
*/
export async function getRootUser(): Promise<parseHeaderCertRet> {
const rootUser = await MongoUser.create({
username: 'root',
password: '123456'
});
const rootUser = await (async () => {
const existingRoot = await MongoUser.findOne({ username: 'root' });
if (existingRoot) return existingRoot;
try {
return await MongoUser.create({
username: 'root',
password: '123456'
});
} catch (error) {
if ((error as { code?: number }).code === 11000) {
const concurrentRoot = await MongoUser.findOne({ username: 'root' });
if (concurrentRoot) return concurrentRoot;
}
throw error;
}
})();
const team = await MongoTeam.create({
name: 'test team',
......
......@@ -2,18 +2,66 @@ import { vi } from 'vitest';
import { randomUUID } from 'crypto';
import type { Mongoose } from '@fastgpt/service/common/mongo';
const fileDbPrefix = `fastgpt_test_${process.env.VITEST_WORKER_ID ?? '0'}_${randomUUID().replaceAll(
'-',
''
)}`;
const dbNames = new WeakMap<Mongoose, string>();
const connectPromises = new WeakMap<Mongoose, Promise<Mongoose>>();
let dbSeq = 0;
const getDbName = (db: Mongoose) => {
const existing = dbNames.get(db);
if (existing) return existing;
const dbName = `${fileDbPrefix}_${dbSeq++}`;
dbNames.set(db, dbName);
return dbName;
};
const connectTestMongo = async (props: { db: Mongoose; url: string; connectedCb?: () => void }) => {
const { db, url, connectedCb } = props;
if (db.connection.readyState !== 0) {
return db;
}
const connecting = connectPromises.get(db);
if (connecting) return connecting;
const promise = (async () => {
await db.connect(url, {
dbName: getDbName(db),
maxPoolSize: 4,
minPoolSize: 0,
retryWrites: false,
serverSelectionTimeoutMS: 10000
});
await db.connection.db?.dropDatabase();
connectedCb?.();
return db;
})();
connectPromises.set(db, promise);
try {
return await promise;
} finally {
connectPromises.delete(db);
}
};
/**
* Mock MongoDB connection for testing
* Creates a unique database for each test run and drops it on connection
* Creates a stable, isolated database for each test file and Mongoose instance.
*/
vi.mock(import('@fastgpt/service/common/mongo/init'), async (importOriginal: any) => {
const mod = await importOriginal();
return {
...mod,
connectMongo: async (props: { db: Mongoose; url: string; connectedCb?: () => void }) => {
const { db, url } = props;
await db.connect(url, { dbName: randomUUID(), retryWrites: false });
await db.connection.db?.dropDatabase();
return connectTestMongo(props);
}
};
});
......
......@@ -8,11 +8,25 @@ import setupModels from './setupModels';
import { clean } from './datas/users';
import { connectionLogMongo, connectionMongo } from '@fastgpt/service/common/mongo';
import { loadVectorDBEnv } from './utils/env';
import type { Mongoose } from '@fastgpt/service/common/mongo';
vi.stubEnv('NODE_ENV', 'test');
loadVectorDBEnv({ envFileNames: ['.env.test.local'] });
/**
* Clears documents while keeping collections and indexes warm inside one test file.
* File-level DB names are already isolated, so per-case cleanup does not need to
* drop the whole database and force MongoDB to rebuild collection metadata.
*/
const clearMongoCollections = async (db: Mongoose | undefined) => {
const database = db?.connection.db;
if (!database) return;
const collections = await database.collections();
await Promise.all(collections.map((collection) => collection.deleteMany({})));
};
beforeAll(async () => {
vi.stubEnv('MONGODB_URI', inject('MONGODB_URI'));
await connectMongo({ db: connectionMongo, url: inject('MONGODB_URI') });
......@@ -44,6 +58,8 @@ beforeAll(async () => {
afterAll(async () => {
await connectionMongo?.connection.db?.dropDatabase();
await connectionLogMongo?.connection.db?.dropDatabase();
await connectionMongo?.disconnect();
await connectionLogMongo?.disconnect();
});
beforeEach(async () => {
......@@ -53,11 +69,10 @@ beforeEach(async () => {
onTestFinished(async () => {
clean();
// Ensure all sessions are closed before dropping database
try {
await Promise.all([
connectionMongo?.connection.db?.dropDatabase(),
connectionLogMongo?.connection.db?.dropDatabase()
clearMongoCollections(connectionMongo),
clearMongoCollections(connectionLogMongo)
]);
} catch (error) {
// Ignore errors during cleanup
......
/**
* Resolve Vitest worker count for Mongo-backed suites.
*
* The default is based on the current Mongo-backed service suite benchmark.
* Override with FASTGPT_TEST_MAX_WORKERS=8 on high-memory machines or when
* repeatedly starting/stopping mongodb-memory-server in local experiments.
*/
export const getTestMaxWorkers = () => {
const raw = process.env.FASTGPT_TEST_MAX_WORKERS;
if (!raw) return 4;
if (raw.endsWith('%')) return raw as `${number}%`;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
};
import { resolve } from 'path';
import { defineConfig } from 'vitest/config';
import { getTestMaxWorkers } from './test/vitestWorkers';
export default defineConfig({
resolve: {
......@@ -48,8 +49,8 @@ export default defineConfig({
outputFile: 'test-results.json',
setupFiles: 'test/setup.ts',
globalSetup: 'test/globalSetup.ts',
// File-level execution: serial (one file at a time to avoid MongoDB conflicts)
fileParallelism: false,
fileParallelism: true,
maxWorkers: getTestMaxWorkers(),
// Test-level execution within a file: parallel (up to 5 concurrent tests)
maxConcurrency: 10,
pool: 'threads',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment