mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
ci: Speed up the PR build job and move the E2E matrix off its path (no-changelog) (#39736)
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"issues": 152
|
||||
"issues": 141
|
||||
}
|
||||
|
||||
@@ -960,7 +960,7 @@ from the master ruleset, and delete this section (tracked in DEVP-887).
|
||||
|
||||
**`blacksmith-4vcpu-ubuntu-2204`** - Unit tests (parallelized), linting (parallel file processing), typechecking (CPU-intensive), E2E test shards
|
||||
|
||||
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads
|
||||
**`blacksmith-8vcpu-ubuntu-2204`** - Heavy parallel workloads. The PR `install-and-build` job uses it with the default runner provider. Most PR jobs wait for that job, and a cold build keeps all 4 vCPUs of a smaller runner busy.
|
||||
|
||||
### Runner Provider Toggle
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
jobs:
|
||||
install-and-build:
|
||||
name: Install & Build
|
||||
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-4vcpu-ubuntu-2204' }}
|
||||
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-8vcpu-ubuntu-2204' }}
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=7168'
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -31,12 +31,16 @@ jobs:
|
||||
db_migrations: ${{ fromJSON(steps.ci-filter.outputs.results)['db-migrations'] == true }}
|
||||
e2e_performance: ${{ fromJSON(steps.ci-filter.outputs.results)['e2e-performance'] == true }}
|
||||
engine_e2e: ${{ fromJSON(steps.ci-filter.outputs.results)['engine-e2e'] == true }}
|
||||
# The main E2E suite runs. Read by e2e-matrix and e2e.
|
||||
run_e2e: >-
|
||||
${{ github.repository == 'n8n-io/n8n' && github.event_name != 'merge_group' &&
|
||||
(fromJSON(steps.ci-filter.outputs.results).runtime == true
|
||||
|| fromJSON(steps.ci-filter.outputs.results).e2e == true
|
||||
|| contains(github.event.pull_request.labels.*.name, 'force-e2e')) }}
|
||||
instance_ai_workflow_eval: ${{ fromJSON(steps.ci-filter.outputs.results)['instance-ai-workflow-eval'] == true }}
|
||||
commit_sha: ${{ steps.commit-sha.outputs.sha }}
|
||||
merge_base: ${{ steps.ci-filter.outputs.merge-base }}
|
||||
matrix: ${{ steps.generate-matrix.outputs.matrix }}
|
||||
db_test_matrix: ${{ steps.generate-db-test-matrix.outputs.db_test_matrix }}
|
||||
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
|
||||
affected_packages: ${{ steps.affected-packages.outputs.list }}
|
||||
changed_files: ${{ steps.ci-filter.outputs.changed-files }}
|
||||
currents_record_key: ${{ secrets.CURRENTS_RECORD_KEY != '' && 'present' || '' }}
|
||||
@@ -227,20 +231,6 @@ jobs:
|
||||
if: fromJSON(steps.ci-filter.outputs.results).ci
|
||||
run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate shard matrix
|
||||
id: generate-matrix
|
||||
if: github.event_name != 'merge_group' && (fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e)
|
||||
env:
|
||||
CHANGED_FILES: ${{ steps.ci-filter.outputs.changed-files }}
|
||||
MERGE_BASE: ${{ steps.ci-filter.outputs.merge-base }}
|
||||
PLAYWRIGHT_GREP_INVERT: ${{ github.event.pull_request.head.repo.fork == true && '@licensed' || '' }}
|
||||
PLAYWRIGHT_PROJECT: ${{ github.event.pull_request.head.repo.fork == true && 'sqlite:e2e' || 'multi-main:e2e' }}
|
||||
run: |
|
||||
FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//')
|
||||
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 20 --orchestrate --impact "--project=$PLAYWRIGHT_PROJECT" "--files=$FILES_CSV" "--base=$MERGE_BASE")
|
||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Generated here because a reusable workflow cannot resolve its own matrix.
|
||||
# PRs run SQLite plus the primary Postgres leg. The merge queue runs the
|
||||
# full Postgres range, so every merge still gates on the older majors.
|
||||
@@ -347,7 +337,7 @@ jobs:
|
||||
# (each of which invokes prepare-docker internally) short-circuit to a
|
||||
# cache hit instead of racing to rebuild.
|
||||
prepare-docker:
|
||||
name: Prepare Docker
|
||||
name: Prepare E2E
|
||||
needs: [install-and-build, format-check]
|
||||
if: >-
|
||||
github.repository == 'n8n-io/n8n' &&
|
||||
@@ -364,25 +354,66 @@ jobs:
|
||||
branch: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||
secrets: inherit
|
||||
|
||||
# Runs next to prepare-docker, so the matrix is off the path of the jobs that
|
||||
# wait for install-and-build. Only the e2e job reads it.
|
||||
e2e-matrix:
|
||||
name: Prepare E2E / Shard matrix
|
||||
needs: install-and-build
|
||||
if: needs.install-and-build.outputs.run_e2e == 'true'
|
||||
runs-on: ${{ vars.RUNNER_PROVIDER == 'github' && 'ubuntu-latest' || 'blacksmith-2vcpu-ubuntu-2204' }}
|
||||
outputs:
|
||||
matrix: ${{ steps.generate-matrix.outputs.matrix }}
|
||||
skip_tests: ${{ steps.generate-matrix.outputs.skip-tests }}
|
||||
steps:
|
||||
- uses: useblacksmith/checkout@bcec731f1eb1367240608d1c889a75b96db6ec53 # v1.5.0
|
||||
with:
|
||||
ref: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
# Impact selection reads package.json at the merge base.
|
||||
- name: Fetch merge base
|
||||
env:
|
||||
MERGE_BASE: ${{ needs.install-and-build.outputs.merge_base }}
|
||||
run: |
|
||||
if [ -n "$MERGE_BASE" ] && ! git cat-file -e "$MERGE_BASE^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --depth=1 origin "$MERGE_BASE"
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
|
||||
|
||||
- name: Generate shard matrix
|
||||
id: generate-matrix
|
||||
env:
|
||||
CHANGED_FILES: ${{ needs.install-and-build.outputs.changed_files }}
|
||||
MERGE_BASE: ${{ needs.install-and-build.outputs.merge_base }}
|
||||
PLAYWRIGHT_GREP_INVERT: ${{ github.event.pull_request.head.repo.fork == true && '@licensed' || '' }}
|
||||
PLAYWRIGHT_PROJECT: ${{ github.event.pull_request.head.repo.fork == true && 'sqlite:e2e' || 'multi-main:e2e' }}
|
||||
run: |
|
||||
FILES_CSV=$(echo "$CHANGED_FILES" | tr '\n' ',' | sed 's/,$//')
|
||||
MATRIX=$(node packages/testing/playwright/scripts/distribute-tests.mjs --matrix 20 --orchestrate --impact "--project=$PLAYWRIGHT_PROJECT" "--files=$FILES_CSV" "--base=$MERGE_BASE")
|
||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||
echo "skip-tests=$(node -e "process.stdout.write(JSON.parse(process.argv[1])[0]?.skip === true ? 'true' : 'false')" "$MATRIX")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Full e2e run. Internal PRs run multi-main (postgres + redis + caddy + 2 mains + 1 worker).
|
||||
# Fork PRs run sqlite-only and skip @licensed tests (no enterprise license secrets on forks).
|
||||
e2e:
|
||||
name: E2E
|
||||
needs: [install-and-build, prepare-docker]
|
||||
needs: [install-and-build, prepare-docker, e2e-matrix]
|
||||
if: >-
|
||||
needs.prepare-docker.result == 'success' &&
|
||||
(needs.install-and-build.outputs.runtime == 'true'
|
||||
|| needs.install-and-build.outputs.e2e == 'true'
|
||||
|| contains(github.event.pull_request.labels.*.name, 'force-e2e')) &&
|
||||
needs.install-and-build.outputs.skip_tests != 'true' &&
|
||||
github.event_name != 'merge_group'
|
||||
needs.e2e-matrix.result == 'success' &&
|
||||
needs.install-and-build.outputs.run_e2e == 'true' &&
|
||||
needs.e2e-matrix.outputs.skip_tests != 'true'
|
||||
uses: ./.github/workflows/test-e2e-reusable.yml
|
||||
with:
|
||||
branch: ${{ needs.install-and-build.outputs.commit_sha }}
|
||||
test-mode: docker-artifact
|
||||
test-command: ${{ github.event.pull_request.head.repo.fork == true && 'pnpm --filter=n8n-playwright test:container:sqlite:e2e --grep-invert=@licensed' || 'pnpm --filter=n8n-playwright test:container:multi-main:e2e' }}
|
||||
workers: '1'
|
||||
pre-generated-matrix: ${{ needs.install-and-build.outputs.matrix }}
|
||||
pre-generated-matrix: ${{ needs.e2e-matrix.outputs.matrix }}
|
||||
artifact-prefix: e2e
|
||||
currents-record-key: ${{ needs.install-and-build.outputs.currents_record_key }}
|
||||
secrets: inherit
|
||||
@@ -510,6 +541,7 @@ jobs:
|
||||
typecheck,
|
||||
lint,
|
||||
check-packaging,
|
||||
e2e-matrix,
|
||||
e2e,
|
||||
e2e-engine,
|
||||
dev-server-smoke,
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@
|
||||
"stylelint": "catalog:",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsc-watch": "^6.2.0",
|
||||
"turbo": "2.9.15",
|
||||
"turbo": "2.11.4",
|
||||
"typescript": "6.0.2",
|
||||
"yaml": "catalog:",
|
||||
"zx": "^8.8.5"
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./errors": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"require": "./dist/errors/index.js",
|
||||
"import": "./dist/errors/index.js"
|
||||
},
|
||||
"./parsers": {
|
||||
"require": "./dist/parsers/index.js",
|
||||
"import": "./dist/parsers/index.js",
|
||||
@@ -55,6 +60,9 @@
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"errors": [
|
||||
"dist/errors/index.d.ts"
|
||||
],
|
||||
"parsers": [
|
||||
"dist/parsers/index.d.ts"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// A light entry point for callers that need only the error classes, without the
|
||||
// rest of the package behind the root barrel.
|
||||
export { FolderResolutionError } from './folder-resolution.error';
|
||||
export { WorkflowEditorLockedError } from './workflow-editor-locked.error';
|
||||
export { WorkflowNotFoundError } from './workflow-not-found.error';
|
||||
export { WorkflowSaveConflictError } from './workflow-save-conflict.error';
|
||||
@@ -203,10 +203,12 @@ export { parseModelHeadersJson } from './utils/parse-model-headers';
|
||||
export { modelConfigId } from './utils/model-config-id';
|
||||
export { isEndpointModelConfig } from './utils/modal-session';
|
||||
export { resolveCustomModelExperimentDefaultsFromEnv } from './utils/custom-model-defaults';
|
||||
export { WorkflowSaveConflictError } from './errors/workflow-save-conflict.error';
|
||||
export { WorkflowNotFoundError } from './errors/workflow-not-found.error';
|
||||
export { WorkflowEditorLockedError } from './errors/workflow-editor-locked.error';
|
||||
export { FolderResolutionError } from './errors/folder-resolution.error';
|
||||
export {
|
||||
WorkflowSaveConflictError,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowEditorLockedError,
|
||||
FolderResolutionError,
|
||||
} from './errors';
|
||||
export {
|
||||
LEGACY_PLANNED_TASK_KINDS,
|
||||
PLANNED_TASK_KINDS,
|
||||
|
||||
@@ -584,6 +584,96 @@ function findNestedSchemaDir(dir: string, targetNames: string[]): string | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder name (lowercased) to `__schema__` path for one schema root. It holds the
|
||||
* same answers as the direct search in `findSchemaDirectory`, but it reads each
|
||||
* directory once.
|
||||
*/
|
||||
interface SchemaRootIndex {
|
||||
/** Direct children of the root. The flat search checks these first. */
|
||||
flat: Map<string, string>;
|
||||
/** First match in the pre-order walk that `findNestedSchemaDir` does. */
|
||||
nested: Map<string, string>;
|
||||
}
|
||||
|
||||
function buildSchemaRootIndex(root: string): SchemaRootIndex {
|
||||
const index: SchemaRootIndex = { flat: new Map(), nested: new Map() };
|
||||
|
||||
const walk = (dir: string, depth: number) => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
const key = entry.name.toLowerCase();
|
||||
const schemaPath = path.join(entryPath, '__schema__');
|
||||
if (fs.existsSync(schemaPath)) {
|
||||
if (depth === 0 && !index.flat.has(key)) index.flat.set(key, schemaPath);
|
||||
if (!index.nested.has(key)) index.nested.set(key, schemaPath);
|
||||
}
|
||||
|
||||
if (entry.name !== '__schema__' && entry.name !== 'node_modules') {
|
||||
walk(entryPath, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root, 0);
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema root indexes for the active generation run. A run looks up hundreds of
|
||||
* node versions, and each miss used to walk the whole `dist/nodes` tree again.
|
||||
* The indexes live only for one run, because other callers (and tests) can add
|
||||
* schema directories between calls.
|
||||
*/
|
||||
let activeSchemaIndexes: Map<string, SchemaRootIndex> | undefined;
|
||||
|
||||
/** Run `fn` with schema directory lookups served from a one-time index of each root. */
|
||||
export async function withSchemaDirIndex<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (activeSchemaIndexes) return await fn();
|
||||
|
||||
activeSchemaIndexes = new Map();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
activeSchemaIndexes = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function indexedSchemaDirectory(
|
||||
indexes: Map<string, SchemaRootIndex>,
|
||||
roots: string[],
|
||||
baseName: string,
|
||||
): string | undefined {
|
||||
const rootIndexes = roots.map((root) => {
|
||||
let index = indexes.get(root);
|
||||
if (!index) {
|
||||
index = buildSchemaRootIndex(root);
|
||||
indexes.set(root, index);
|
||||
}
|
||||
return index;
|
||||
});
|
||||
|
||||
const key = baseName.toLowerCase();
|
||||
for (const index of rootIndexes) {
|
||||
const found = index.flat.get(key);
|
||||
if (found) return found;
|
||||
}
|
||||
for (const index of rootIndexes) {
|
||||
const found = index.nested.get(key);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema roots to search, in priority order: the package currently being
|
||||
* generated (generate-node-defs-cli runs with CWD = that package, so e.g.
|
||||
@@ -604,7 +694,7 @@ export function schemaSearchRoots(): string[] {
|
||||
* @param baseName The base node name (e.g., 'gmail')
|
||||
* @returns Path to the __schema__ directory, or undefined if not found
|
||||
*/
|
||||
function findSchemaDirectory(baseName: string, schemaPath?: string): string | undefined {
|
||||
export function findSchemaDirectory(baseName: string, schemaPath?: string): string | undefined {
|
||||
const roots = schemaSearchRoots();
|
||||
|
||||
// If explicit schemaPath is provided, use it directly
|
||||
@@ -617,6 +707,10 @@ function findSchemaDirectory(baseName: string, schemaPath?: string): string | un
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSchemaIndexes) {
|
||||
return indexedSchemaDirectory(activeSchemaIndexes, roots, baseName);
|
||||
}
|
||||
|
||||
const possibleNames = [
|
||||
baseName.charAt(0).toUpperCase() + baseName.slice(1), // Gmail
|
||||
baseName, // gmail
|
||||
@@ -4611,6 +4705,10 @@ export interface GenerationResult {
|
||||
* @returns Result with count of nodes processed
|
||||
*/
|
||||
export async function orchestrateGeneration(options: GenerationOptions): Promise<GenerationResult> {
|
||||
return await withSchemaDirIndex(async () => await generateAll(options));
|
||||
}
|
||||
|
||||
async function generateAll(options: GenerationOptions): Promise<GenerationResult> {
|
||||
const { nodes, outputDir } = options;
|
||||
|
||||
// Group nodes by package, filtering hidden
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { findSchemaDirectory, withSchemaDirIndex } from './generate-types';
|
||||
|
||||
function addSchemaDir(root: string, relativeDir: string): string {
|
||||
const schemaDir = path.join(root, relativeDir, '__schema__');
|
||||
fs.mkdirSync(path.join(schemaDir, 'v1.0.0'), { recursive: true });
|
||||
fs.writeFileSync(path.join(schemaDir, 'v1.0.0', 'output.json'), '{}');
|
||||
return schemaDir;
|
||||
}
|
||||
|
||||
describe('schema directory index', () => {
|
||||
let packageDir: string;
|
||||
let nodesRoot: string;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
// realpath: on macOS the temp dir is a symlink, and process.cwd() resolves it.
|
||||
packageDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'schema-dir-index-test-')));
|
||||
nodesRoot = path.join(packageDir, 'dist', 'nodes');
|
||||
fs.mkdirSync(nodesRoot, { recursive: true });
|
||||
// schemaSearchRoots() puts <cwd>/dist/nodes ahead of nodes-base.
|
||||
process.chdir(packageDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(packageDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const names = [
|
||||
'__IdxTarget__',
|
||||
'__idxchainllm__',
|
||||
'__IdxSheets__',
|
||||
'__IdxDuplicate__',
|
||||
'__IdxHidden__',
|
||||
'__IdxMissing__',
|
||||
];
|
||||
|
||||
function buildTree() {
|
||||
// A nested match that sorts before the flat match. The flat match must win.
|
||||
addSchemaDir(nodesRoot, 'AAA/__IdxTarget__');
|
||||
const flatTarget = addSchemaDir(nodesRoot, '__IdxTarget__');
|
||||
// Folder casing differs from the node name.
|
||||
const chain = addSchemaDir(nodesRoot, 'chains/__IDXCHAINLLM__');
|
||||
// A folder with the node name but no schema, with a match below it.
|
||||
fs.mkdirSync(path.join(nodesRoot, '__IdxSheets__'), { recursive: true });
|
||||
const sheets = addSchemaDir(nodesRoot, '__IdxSheets__/V2/__IdxSheets__');
|
||||
// Two nested matches. Walk order decides, the same as the direct search.
|
||||
addSchemaDir(nodesRoot, 'Group1/__IdxDuplicate__');
|
||||
addSchemaDir(nodesRoot, 'Group2/__IdxDuplicate__');
|
||||
// The search skips node_modules.
|
||||
addSchemaDir(nodesRoot, 'node_modules/__IdxHidden__');
|
||||
return { flatTarget, chain, sheets };
|
||||
}
|
||||
|
||||
it('returns the same directories as the direct search', async () => {
|
||||
const { flatTarget, chain, sheets } = buildTree();
|
||||
|
||||
const direct = names.map((name) => findSchemaDirectory(name));
|
||||
const indexed = await withSchemaDirIndex(
|
||||
async () => await Promise.resolve(names.map((name) => findSchemaDirectory(name))),
|
||||
);
|
||||
|
||||
expect(indexed).toEqual(direct);
|
||||
expect(indexed[0]).toBe(flatTarget);
|
||||
expect(indexed[1]).toBe(chain);
|
||||
expect(indexed[2]).toBe(sheets);
|
||||
expect(indexed[3]).toBeDefined();
|
||||
expect(indexed[4]).toBeUndefined();
|
||||
expect(indexed[5]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps an explicit schema path ahead of the name lookup', async () => {
|
||||
buildTree();
|
||||
const explicit = addSchemaDir(nodesRoot, 'custom/place');
|
||||
|
||||
const result = await withSchemaDirIndex(
|
||||
async () => await Promise.resolve(findSchemaDirectory('__IdxTarget__', 'custom/place')),
|
||||
);
|
||||
|
||||
expect(result).toBe(explicit);
|
||||
});
|
||||
|
||||
it('does not keep the index after the run ends', async () => {
|
||||
await withSchemaDirIndex(async () => await Promise.resolve(findSchemaDirectory('__IdxLate__')));
|
||||
const late = addSchemaDir(nodesRoot, '__IdxLate__');
|
||||
|
||||
expect(findSchemaDirectory('__IdxLate__')).toBe(late);
|
||||
const nextRun = await withSchemaDirIndex(
|
||||
async () => await Promise.resolve(findSchemaDirectory('__IdxLate__')),
|
||||
);
|
||||
expect(nextRun).toBe(late);
|
||||
});
|
||||
});
|
||||
@@ -34,14 +34,26 @@ function generateUserManagementEmailTemplates() {
|
||||
|
||||
shell.mkdir('-p', destinationDir);
|
||||
|
||||
const templates = glob.sync('*.mjml', { cwd: sourceDir });
|
||||
templates.forEach((template) => {
|
||||
if (template.startsWith('_')) return;
|
||||
const source = path.resolve(sourceDir, template);
|
||||
const destination = path.resolve(destinationDir, template.replace(/\.mjml$/, '.handlebars'));
|
||||
const command = `pnpm mjml --output "${destination}" "${source}"`;
|
||||
shell.exec(command, { silent: false });
|
||||
const templates = glob.sync('*.mjml', { cwd: sourceDir }).filter((t) => !t.startsWith('_'));
|
||||
|
||||
// One mjml process for all templates: each `pnpm mjml` start costs about 0.3s.
|
||||
// With several inputs, mjml writes `<name>.html` into the output directory.
|
||||
const sources = templates.map((template) => `"${path.resolve(sourceDir, template)}"`).join(' ');
|
||||
const result = shell.exec(`pnpm mjml ${sources} --output "${destinationDir}${path.sep}"`, {
|
||||
silent: false,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`mjml failed to compile the email templates (exit code ${result.code})`);
|
||||
}
|
||||
|
||||
for (const template of templates) {
|
||||
const name = template.replace(/\.mjml$/, '');
|
||||
const compiled = path.resolve(destinationDir, `${name}.html`);
|
||||
if (!existsSync(compiled)) {
|
||||
throw new Error(`mjml did not write ${compiled}`);
|
||||
}
|
||||
shell.mv(compiled, path.resolve(destinationDir, `${name}.handlebars`));
|
||||
}
|
||||
|
||||
shell.cp(path.resolve(sourceDir, 'n8n-logo.png'), destinationDir);
|
||||
}
|
||||
@@ -74,14 +86,16 @@ async function buildPublicApiSpec() {
|
||||
// 1. Generate decorated route OpenAPI specs from source.
|
||||
generateDocs(v1Dir);
|
||||
|
||||
// 2. Bundle both sources.
|
||||
// 2. Bundle both sources in one redocly process. With several inputs, redocly
|
||||
// writes each bundle into the output directory under its source file name.
|
||||
const v1DistDir = path.resolve(ROOT_DIR, 'dist', 'public-api', 'v1');
|
||||
bundleSpecs(
|
||||
[path.join(v1Dir, SPEC_FILENAME), path.join(v1Dir, DECORATOR_ROOT_FILENAME)],
|
||||
v1DistDir,
|
||||
);
|
||||
|
||||
const eovDistSpec = path.join(v1DistDir, SPEC_FILENAME);
|
||||
bundleSpec(path.join(v1Dir, SPEC_FILENAME), eovDistSpec);
|
||||
|
||||
const decoratorDistSpec = path.join(v1DistDir, DECORATOR_ROOT_FILENAME);
|
||||
bundleSpec(path.join(v1Dir, DECORATOR_ROOT_FILENAME), decoratorDistSpec);
|
||||
|
||||
// 3. Merge the two specs into a single OpenAPI document, writing back to the eov spec path.
|
||||
const eovDoc = parseYaml(readFileSync(eovDistSpec, 'utf8'));
|
||||
@@ -125,13 +139,25 @@ async function loadOpenApiGenerator() {
|
||||
return generator;
|
||||
}
|
||||
|
||||
// Bundles a spec through redocly, resolving all $refs into a single file at `distPath`.
|
||||
function bundleSpec(sourcePath, distPath) {
|
||||
const result = shell.exec(`pnpm openapi bundle "${sourcePath}" --output "${distPath}"`, {
|
||||
// Bundles specs through redocly, resolving all $refs. Each source becomes one file in `distDir`.
|
||||
function bundleSpecs(sourcePaths, distDir) {
|
||||
const distPaths = sourcePaths.map((sourcePath) => path.join(distDir, path.basename(sourcePath)));
|
||||
// Remove old output, so that the check below cannot pass on a stale file.
|
||||
for (const distPath of distPaths) rmSync(distPath, { force: true });
|
||||
|
||||
const sources = sourcePaths.map((sourcePath) => `"${sourcePath}"`).join(' ');
|
||||
const result = shell.exec(`pnpm openapi bundle ${sources} --output "${distDir}"`, {
|
||||
silent: true,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`redocly failed to bundle ${sourcePath}:\n${result.stderr || result.stdout}`);
|
||||
throw new Error(
|
||||
`redocly failed to bundle ${sourcePaths.join(', ')}:\n${result.stderr || result.stdout}`,
|
||||
);
|
||||
}
|
||||
for (const [i, distPath] of distPaths.entries()) {
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error(`redocly did not write the bundle for ${sourcePaths[i]} to ${distPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types';
|
||||
|
||||
// `@n8n/instance-ai` source util imported dynamically (no built entry for this
|
||||
// deep subpath); loaded in `beforeAll` to avoid top-level `await`.
|
||||
let buildAgentTreeFromEvents: (events: InstanceAiEvent[]) => InstanceAiAgentNode;
|
||||
let findAgentNodeInTree: (
|
||||
tree: InstanceAiAgentNode,
|
||||
agentId: string,
|
||||
) => InstanceAiAgentNode | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildAgentTreeFromEvents, findAgentNodeInTree } = (await import(
|
||||
'../../../../../@n8n/instance-ai/src/utils/agent-tree.js'
|
||||
)) as {
|
||||
buildAgentTreeFromEvents: (events: InstanceAiEvent[]) => InstanceAiAgentNode;
|
||||
findAgentNodeInTree: (
|
||||
tree: InstanceAiAgentNode,
|
||||
agentId: string,
|
||||
) => InstanceAiAgentNode | undefined;
|
||||
});
|
||||
});
|
||||
import type { InstanceAiEvent } from '@n8n/api-types';
|
||||
import { buildAgentTreeFromEvents, findAgentNodeInTree } from '@n8n/instance-ai';
|
||||
|
||||
describe('buildAgentTreeFromEvents', () => {
|
||||
it('should build a tree from run-start + text-delta + run-finish', () => {
|
||||
|
||||
+2
-9
@@ -1,15 +1,8 @@
|
||||
// The barrel is mocked so this file exercises the adapter's own wiring rather than pulling the
|
||||
// whole instance-ai package in behind it, matching the other adapter tests.
|
||||
vi.mock('@n8n/instance-ai', async () => {
|
||||
const { WorkflowSaveConflictError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error.js'
|
||||
);
|
||||
const { WorkflowNotFoundError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js'
|
||||
);
|
||||
const { WorkflowEditorLockedError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error.js'
|
||||
);
|
||||
const { WorkflowSaveConflictError, WorkflowNotFoundError, WorkflowEditorLockedError } =
|
||||
await import('@n8n/instance-ai/errors');
|
||||
return {
|
||||
WorkflowSaveConflictError,
|
||||
WorkflowNotFoundError,
|
||||
|
||||
+1
-3
@@ -1,8 +1,6 @@
|
||||
// Mock the barrel import so these adapter tests only exercise local formatting helpers.
|
||||
vi.mock('@n8n/instance-ai', async () => {
|
||||
const { WorkflowNotFoundError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js'
|
||||
);
|
||||
const { WorkflowNotFoundError } = await import('@n8n/instance-ai/errors');
|
||||
return {
|
||||
WorkflowNotFoundError,
|
||||
wrapUntrustedData(content: string, source: string, label?: string): string {
|
||||
|
||||
+11
-15
@@ -1,17 +1,11 @@
|
||||
// Mock the barrel import so these adapter tests only exercise local formatting helpers.
|
||||
vi.mock('@n8n/instance-ai', async () => {
|
||||
const { WorkflowSaveConflictError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error.js'
|
||||
);
|
||||
const { WorkflowNotFoundError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error.js'
|
||||
);
|
||||
const { WorkflowEditorLockedError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error.js'
|
||||
);
|
||||
const { FolderResolutionError } = await import(
|
||||
'../../../../../@n8n/instance-ai/src/errors/folder-resolution.error.js'
|
||||
);
|
||||
const {
|
||||
WorkflowSaveConflictError,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowEditorLockedError,
|
||||
FolderResolutionError,
|
||||
} = await import('@n8n/instance-ai/errors');
|
||||
return {
|
||||
WorkflowSaveConflictError,
|
||||
WorkflowNotFoundError,
|
||||
@@ -1814,9 +1808,11 @@ import type { DataTableRepository } from '@/modules/data-table/data-table.reposi
|
||||
import type { DataTableService } from '@/modules/data-table/data-table.service';
|
||||
import type { InstanceWriteAccessService } from '@/services/instance-write-access.service';
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import { WorkflowEditorLockedError } from '../../../../../@n8n/instance-ai/src/errors/workflow-editor-locked.error';
|
||||
import { WorkflowNotFoundError } from '../../../../../@n8n/instance-ai/src/errors/workflow-not-found.error';
|
||||
import { WorkflowSaveConflictError } from '../../../../../@n8n/instance-ai/src/errors/workflow-save-conflict.error';
|
||||
import {
|
||||
WorkflowEditorLockedError,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowSaveConflictError,
|
||||
} from '@n8n/instance-ai/errors';
|
||||
import type { WorkflowService } from '@/workflows/workflow.service';
|
||||
import { AiPreferenceScopeFullError } from '@/errors/response-errors/ai-preference-scope-full.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit",
|
||||
"build:unchecked": "pnpm run build",
|
||||
"build-node-dev": "tsc",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
|
||||
Generated
+31
-31
@@ -1141,8 +1141,8 @@ importers:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0([email protected])
|
||||
turbo:
|
||||
specifier: 2.9.15
|
||||
version: 2.9.15
|
||||
specifier: 2.11.4
|
||||
version: 2.11.4
|
||||
typescript:
|
||||
specifier: 6.0.2
|
||||
version: 6.0.2
|
||||
@@ -14060,33 +14060,33 @@ packages:
|
||||
'@tsconfig/[email protected]':
|
||||
resolution: {integrity: sha512-T/CfdwFry660WjZor56z0F3pxeCllt8KOxWcHFW6ZEuULKUObTDEMdgtctyuJPxwqyWDsvHRfxHaJ4FIICyoqQ==}
|
||||
|
||||
'@turbo/darwin-64@2.9.18':
|
||||
resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==}
|
||||
'@turbo/darwin-64@2.11.4':
|
||||
resolution: {integrity: sha512-fEmcQVa95k79tZjcay5b2A4cpR/VrlNUki4AN7EbA+32vj5C5rBUE1q3i2yOFxsB2n01sOlzIZ+s2p2zADCTig==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@turbo/darwin-arm64@2.9.15':
|
||||
resolution: {integrity: sha512-fDSx56oqoFuS+yUQw7hqjQTkjrSLdMcplhuLC8HcSkWC6YrpwEmUUYsPYHPxy4ALvLxnmPQuk6XoSD8tdkjP+g==}
|
||||
'@turbo/darwin-arm64@2.11.4':
|
||||
resolution: {integrity: sha512-SgXCVgBC1v+bdE6+KQGV6Kob7nBYo75zxzNFFuA4jLdM0J8lYamdTUoZSHLR0dy+kXVEYZqSC98CKqC17NG3Og==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@turbo/linux-64@2.9.18':
|
||||
resolution: {integrity: sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw==}
|
||||
'@turbo/linux-64@2.11.4':
|
||||
resolution: {integrity: sha512-MWM7Au6NPJMBmHJAzFzQ/PACYNRpBOqnBkj+7csdR+YhJ+0XBg4Z64wFwLnGFDac0gc+YlasWOZ5t05bGs54aQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
os: [android, linux]
|
||||
|
||||
'@turbo/linux-arm64@2.9.15':
|
||||
resolution: {integrity: sha512-cbOaDe1ijz5As+mimOOHgmRMolZZZO7miNBHHp5xdiYMm2Q/Dwu1JVLx/Kw4s7xjocG/oEoHrpHrxpEAIEfNiw==}
|
||||
'@turbo/linux-arm64@2.11.4':
|
||||
resolution: {integrity: sha512-rRVOh0UNlLbKPuf70FokqjahO/hCGgBmgvZcNXUPayn8Wk0zXIsVoNKpr0UQUhhR+KMiibvBKkVAGDViDFQHKA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
os: [android, linux]
|
||||
|
||||
'@turbo/windows-64@2.9.18':
|
||||
resolution: {integrity: sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw==}
|
||||
'@turbo/windows-64@2.11.4':
|
||||
resolution: {integrity: sha512-COr+++1GwDg09XbJdm3sP1gEQApeV43UknYP9h82qet1tAGJrGQAxKVTMrf2JMsMw8MkJRnQj3gAVuQmV1kIyQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@turbo/windows-arm64@2.9.15':
|
||||
resolution: {integrity: sha512-fOHEsLcqVdFXLw2ApWv4gxwfHzkUnpo9rHGml+9+dyHj148m/Bc+556kEvb5+4u6prI1LMd8zEZE2HcO6Jn2VQ==}
|
||||
'@turbo/windows-arm64@2.11.4':
|
||||
resolution: {integrity: sha512-gh5lwDA2d4aBdLvTANhFsFEgxbBqKZ4m4dCyChw9GoX8b+Esk8H3p25Q+rlWCUVJ2o6Hne10ZTL0QSFfWD4gIA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
@@ -23347,8 +23347,8 @@ packages:
|
||||
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
|
||||
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
|
||||
|
||||
turbo@2.9.15:
|
||||
resolution: {integrity: sha512-VpKvD9Z0Hu/xrGUAYX1wnhfpqv835wIwGqeKfulvBPTOcDap0E3nFwyzCAVV85fB1sBcBDEfTP+7FSW7GzwWSQ==}
|
||||
turbo@2.11.4:
|
||||
resolution: {integrity: sha512-nedZtJ2hjWpU/JBFq7Aj1GQBxQ56si+kIUmNZBUX35O35TIuhSBR6/u3yHHmk81U+RBdBcVjvLQ3/iOFzMwKsA==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
@@ -32115,22 +32115,22 @@ snapshots:
|
||||
|
||||
'@tsconfig/[email protected]': {}
|
||||
|
||||
'@turbo/darwin-64@2.9.18':
|
||||
'@turbo/darwin-64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@turbo/darwin-arm64@2.9.15':
|
||||
'@turbo/darwin-arm64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@turbo/linux-64@2.9.18':
|
||||
'@turbo/linux-64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@turbo/linux-arm64@2.9.15':
|
||||
'@turbo/linux-arm64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@turbo/windows-64@2.9.18':
|
||||
'@turbo/windows-64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@turbo/windows-arm64@2.9.15':
|
||||
'@turbo/windows-arm64@2.11.4':
|
||||
optional: true
|
||||
|
||||
'@tybys/[email protected]':
|
||||
@@ -43527,14 +43527,14 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
turbo@2.9.15:
|
||||
turbo@2.11.4:
|
||||
optionalDependencies:
|
||||
'@turbo/darwin-64': 2.9.18
|
||||
'@turbo/darwin-arm64': 2.9.15
|
||||
'@turbo/linux-64': 2.9.18
|
||||
'@turbo/linux-arm64': 2.9.15
|
||||
'@turbo/windows-64': 2.9.18
|
||||
'@turbo/windows-arm64': 2.9.15
|
||||
'@turbo/darwin-64': 2.11.4
|
||||
'@turbo/darwin-arm64': 2.11.4
|
||||
'@turbo/linux-64': 2.11.4
|
||||
'@turbo/linux-arm64': 2.11.4
|
||||
'@turbo/windows-64': 2.11.4
|
||||
'@turbo/windows-arm64': 2.11.4
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
@@ -11,10 +11,8 @@
|
||||
* fewer issues. Nothing raises it automatically: a count above the baseline
|
||||
* fails the nightly instead.
|
||||
*
|
||||
* The baseline is the POST-BUILD count: CI's lint job builds dependencies before
|
||||
* this check runs, and built dist trees surface ~8 extra issues that a cold
|
||||
* checkout doesn't. Run `pnpm build` before `pnpm boundaries:baseline`, or CI
|
||||
* will read a higher count than you saw locally.
|
||||
* Turbo 2.11 skips gitignored files, so built dist trees no longer change the
|
||||
* count. It also checks dynamic `import()` calls, which 2.9 did not.
|
||||
*
|
||||
* ponytail: parses turbo's "N issues found" line — the ratchet is a single
|
||||
* number, not a per-issue snapshot, so it can't tell a fixed issue from a new
|
||||
|
||||
Reference in New Issue
Block a user