feat(core): Guide Instance AI credential selection (#37447)

This commit is contained in:
Predrag Ristic
2026-09-04 13:04:42 +00:00
committed by GitHub
parent 199433204f
commit be678fcb69
21 changed files with 2313 additions and 20 deletions
+1
View File
@@ -135,6 +135,7 @@ of the breaking removal on `3.x`.
`master` is dropped as empty.
3. Conflicts confined to **mechanical files** — tool-generated content with a deterministic
resolution (`pnpm-lock.yaml`, `packages/frontend/editor-ui/data/node-popularity.json`,
`packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json`, and
`.github/test-metrics/e2e-impact-map.json`) — are **auto-resolved during the replay**,
exactly as a human resolver would: the lockfile is regenerated with
`pnpm install --lockfile-only` (pnpm merges its own conflict markers), bot-maintained data
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import { writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const outputFile = resolve(
repositoryRoot,
'packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json',
);
const endpoint = process.env.N8N_CREDENTIAL_SETUPABILITY_ENDPOINT;
const roundSetupability = (value) => (value === null ? null : Math.round(value * 20) / 20);
export const roundPopularity = (value) => (value === null ? null : Math.round(value * 10) / 10);
async function main() {
if (!endpoint) {
throw new Error('N8N_CREDENTIAL_SETUPABILITY_ENDPOINT is required.');
}
console.log('Fetching credential setupability data.');
const response = await fetch(endpoint, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) {
throw new Error(`Credential setupability endpoint returned HTTP ${response.status}.`);
}
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
throw new Error('Credential setupability endpoint returned no data.');
}
const metrics = data.map(({ id, setupability, popularity }) => ({
id,
setupability: roundSetupability(setupability),
popularity: roundPopularity(popularity),
}));
await writeFile(outputFile, `${JSON.stringify(metrics, null, '\t')}\n`, 'utf8');
console.log(`Saved credential setupability data for ${metrics.length} credential types.`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { roundPopularity } from './fetch-credential-setupability.mjs';
describe('roundPopularity', () => {
it('preserves a missing value', () => {
assert.equal(roundPopularity(null), null);
});
it('rounds a value to one decimal place', () => {
assert.equal(roundPopularity(0.26), 0.3);
});
});
+1
View File
@@ -97,6 +97,7 @@ export const LOCKFILE = 'pnpm-lock.yaml';
export const MECHANICAL_PATHS = {
[LOCKFILE]: 'pnpm-regen',
'packages/frontend/editor-ui/data/node-popularity.json': 'take-master',
'packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json': 'take-master',
'.github/test-metrics/e2e-impact-map.json': 'take-master',
};
+7 -1
View File
@@ -62,6 +62,7 @@ const PRE_HEAD = 'PREHEAD';
const MASTER = 'MASTERSHA';
const MERGE_TREE = 'MERGETREEOID';
const POPULARITY = 'packages/frontend/editor-ui/data/node-popularity.json';
const SETUPABILITY = 'packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json';
const isRebase = (a) => a[0] === 'rebase' && a[1] !== '--abort';
const favouringOwnSide = (a) => a[0] === 'rebase' && a.includes('-X') && a.includes('theirs');
@@ -138,9 +139,14 @@ test('classifyPaths and blocksLockfileRegen split mechanical from code conflicts
const { mechanical, code } = classifyPaths([
LOCKFILE,
'packages/cli/x.ts',
SETUPABILITY,
'.github/test-metrics/e2e-impact-map.json',
]);
assert.deepEqual(mechanical, [
LOCKFILE,
SETUPABILITY,
'.github/test-metrics/e2e-impact-map.json',
]);
assert.deepEqual(mechanical, [LOCKFILE, '.github/test-metrics/e2e-impact-map.json']);
assert.deepEqual(code, ['packages/cli/x.ts']);
assert.equal(blocksLockfileRegen(['packages/cli/package.json']), true);
@@ -0,0 +1,102 @@
name: 'Util: Update Credential Setupability'
on:
schedule:
# Run every Monday at 00:30 UTC, after the node-popularity refresh starts.
- cron: '30 0 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: update-credential-setupability
cancel-in-progress: false
jobs:
update-setupability:
if: >-
github.repository == 'n8n-io/n8n' &&
vars.N8N_CREDENTIAL_SETUPABILITY_ENDPOINT != ''
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
pull-request-number: ${{ steps.create-pr.outputs.pull-request-number }}
steps:
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node.js and dependencies
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
- name: Fetch credential setupability data
run: node .github/scripts/fetch-credential-setupability.mjs
env:
N8N_CREDENTIAL_SETUPABILITY_ENDPOINT: ${{ vars.N8N_CREDENTIAL_SETUPABILITY_ENDPOINT }}
- name: Format generated file
run: >-
pnpm biome format --write
packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json
- name: Check for changes
id: check-changes
run: |
if [ -z "$(git status --porcelain -- packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json)" ]; then
echo "No changes to credential setupability data"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "Credential setupability data has changed"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.check-changes.outputs.has_changes == 'true'
id: create-pr
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.generate-token.outputs.token }}
add-paths: packages/@n8n/instance-ai/src/tools/nodes/credential-setupability.json
commit-message: 'chore: Update credential setupability data'
labels: 'automation:scheduled-update'
title: 'chore: Update credential setupability data'
body: |
This automated PR updates the credential setupability data used by Instance AI node discovery.
The data is fetched weekly from the internal telemetry endpoint. It contains rounded credential-type setup completion and popularity scores.
_Generated by the weekly credential setupability update workflow._
branch: update-credential-setupability
base: master
delete-branch: true
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
approve-and-automerge:
needs: [update-setupability]
if: >-
needs.update-setupability.outputs.pull-request-number != '' &&
github.repository == 'n8n-io/n8n'
permissions:
contents: write
pull-requests: write
# The callee's app credentials live on the release environment and cannot
# be forwarded as named secrets by the caller.
uses: ./.github/workflows/util-approve-and-set-automerge.yml # zizmor: ignore[secrets-inherit]
secrets: inherit
with:
pull-request-number: ${{ needs.update-setupability.outputs.pull-request-number }}
@@ -237,6 +237,30 @@ describe('transcriptAsText', () => {
expect(text).toContain('prompt: Here is the plan, approve?');
expect(text).toContain('user feedback: No — use a Webhook trigger, not a Schedule');
});
it('surfaces ask-user question types for process expectations', () => {
const transcript: TranscriptTurn[] = [
{
steps: [
{
kind: 'ask-user',
questions: [
{
id: 'service',
question: 'Which service?',
type: 'single',
options: ['RocketChat', 'Zulip'],
},
],
},
],
},
];
expect(transcriptAsText(transcript)).toContain(
'Q (single): Which service? [RocketChat / Zulip]',
);
});
});
describe('perTurnToolCallCounts', () => {
@@ -206,7 +206,9 @@ describe('buildTranscriptFromEvents', () => {
});
describe('ask-user routing', () => {
const questions = [{ id: 'q1', question: 'Which channels?' }];
const questions = [
{ id: 'q1', question: 'Which channel?', type: 'single', options: ['Slack', 'Teams'] },
];
it('renders ask-user from confirmation-request and skips the tool-call twin', () => {
const turns = buildTranscriptFromEvents({
@@ -222,7 +224,7 @@ describe('buildTranscriptFromEvents', () => {
'r1',
{
kind: 'questions' as const,
answers: [{ questionId: 'q1', selectedOptions: ['#general'] }],
answers: [{ questionId: 'q1', selectedOptions: ['Teams'] }],
},
],
]),
@@ -231,8 +233,10 @@ describe('buildTranscriptFromEvents', () => {
expect(interactions).toHaveLength(1);
expect(interactions[0]).toMatchObject({
kind: 'ask-user',
questions: [{ id: 'q1', question: 'Which channels?' }],
answers: [{ questionId: 'q1', selectedOptions: ['#general'] }],
questions: [
{ id: 'q1', question: 'Which channel?', type: 'single', options: ['Slack', 'Teams'] },
],
answers: [{ questionId: 'q1', selectedOptions: ['Teams'] }],
});
});
@@ -196,7 +196,14 @@ describe('transcript rendering', () => {
steps: [
{
kind: 'ask-user',
questions: [{ id: 'q1', question: 'Which channel?', options: ['#a', '#b'] }],
questions: [
{
id: 'q1',
question: 'Which channel?',
type: 'single',
options: ['#a', '#b'],
},
],
answers: [{ questionId: 'q1', selectedOptions: [], skipped: true }],
},
],
@@ -206,6 +213,7 @@ describe('transcript rendering', () => {
const html = generateWorkflowReport([result]);
expect(html).toContain('👤 (skipped)');
expect(html).toContain('ask-user (with answers)');
expect(html).toContain('<code>single</code>');
});
});
@@ -446,10 +446,14 @@ export function extractAskUserQuestions(raw: unknown[]): AskUserQuestion[] {
if (!isRecord(item)) continue;
const id = typeof item.id === 'string' ? item.id : '';
const question = typeof item.question === 'string' ? item.question : '';
const type =
item.type === 'single' || item.type === 'multi' || item.type === 'text'
? item.type
: undefined;
const options = Array.isArray(item.options)
? item.options.filter((o): o is string => typeof o === 'string')
: undefined;
if (id || question) questions.push({ id, question, options });
if (id || question) questions.push({ id, question, type, options });
}
return questions;
}
@@ -1018,6 +1018,7 @@ function renderInteraction(interaction: ToolInteraction): string | null {
}
const lines = interaction.questions
.map((q) => {
const type = q.type ? ` <code>${escapeHtml(q.type)}</code>` : '';
const opts =
q.options && q.options.length > 0
? ` <em>(${q.options.map((o) => escapeHtml(o)).join(' / ')})</em>`
@@ -1026,7 +1027,7 @@ function renderInteraction(interaction: ToolInteraction): string | null {
const answerHtml = answer
? `<div class="transcript-answer">👤 ${escapeHtml(answer)}</div>`
: '';
return `<li>${escapeHtml(q.question)}${opts}${answerHtml}</li>`;
return `<li>${escapeHtml(q.question)}${type}${opts}${answerHtml}</li>`;
})
.join('');
const summary =
@@ -448,6 +448,7 @@ export interface PlanTask {
export interface AskUserQuestion {
id: string;
question: string;
type?: 'single' | 'multi' | 'text';
options?: string[];
}
@@ -235,9 +235,10 @@ function describeInteraction(interaction: ToolInteraction): string | null {
}
const qs = interaction.questions
.map((q) => {
const type = q.type ? ` (${q.type})` : '';
const opts = q.options && q.options.length > 0 ? ` [${q.options.join(' / ')}]` : '';
const answer = answerByQId.get(q.id);
return `Q: ${q.question}${opts}${answer ? ` -> A: ${answer}` : ''}`;
return `Q${type}: ${q.question}${opts}${answer ? ` -> A: ${answer}` : ''}`;
})
.join(' | ');
return `Asked user: ${qs}`;
@@ -394,6 +394,41 @@ decision after testing.
- Always declare `output` on nodes that use unresolved credentials when mock
data is needed for verification.
## Credential Setup Preference
Discovery results can include a `setupPreference` array. Each entry has:
- `type`, the credential type
- `setupCompletionPercent`, a percentage from 0 to 100 rounded to the nearest
5 percentage points, or `null`
- `popularityScore`, a relative adoption score from 0 to 1 rounded to one
decimal place, or `null`
Setup completion measures completion of an Instance AI setup step containing
the credential; it is not an activation or validity rate. For either metric,
`null` means there was not enough data. Popularity is relative recent adoption,
not a percentage. Treat both as coarse signals and ignore small differences.
When choosing a service:
1. Honor explicit intent and existing workflow choices.
2. Prefer a semantically suitable service with a usable existing credential,
then apply the existing Gateway credits rules.
3. Compare setup preference only among the remaining semantically
interchangeable candidates. Before deciding, inspect discovery results for
every candidate the user named.
- When setup completion and popularity clearly support one candidate, choose it
and continue without asking.
- When the signals are close or conflict and the user has not delegated the
choice, ask exactly one `single` question. If skipped, choose a sensible default.
- When the user explicitly asks you to choose, make a sensible choice and
continue without asking.
Use judgment instead of calculating a combined score or applying a fixed
threshold. Never let this metadata override stronger semantic relevance or use
it to choose between authentication methods for the same service.
## Gateway credits Preference
"Gateway credits" is the user-facing name of n8n's managed credential
@@ -1,7 +1,13 @@
import {
NodeSearchEngine,
suggestedNodesData,
type SearchableNodeType,
} from '@n8n/ai-utilities/node-catalog';
import type { Mock } from 'vitest';
import { executeTool } from '../../__tests__/tool-test-utils';
import type { InstanceAiContext } from '../../types';
import { addSetupPreference } from '../nodes/setup-preference';
import { createNodesTool } from '../nodes.tool';
function createMockContext(overrides: Partial<InstanceAiContext> = {}): InstanceAiContext {
@@ -181,6 +187,10 @@ describe('nodes tool', () => {
];
const context = createMockContext();
(context.nodeService.listSearchable as Mock).mockResolvedValue(searchableNodes);
(context.nodeService.getDescription as Mock).mockResolvedValue({
properties: [{ type: 'credentialsSelect' }],
credentials: [{ name: 'gmailOAuth2' }],
});
const tool = createNodesTool(context, 'full');
const first = await executeTool(
@@ -203,6 +213,8 @@ describe('nodes tool', () => {
totalResults: 1,
results: [expect.objectContaining({ name: 'n8n-nodes-base.httpRequest' })],
});
expect(context.nodeService.getDescription).toHaveBeenCalledTimes(2);
expect(first).not.toHaveProperty('results.0.setupPreference');
});
it('should search nodes by connection type and enrich results with discriminators', async () => {
@@ -355,6 +367,105 @@ describe('nodes tool', () => {
});
});
describe('setup preference', () => {
it('should expose credential preferences without changing existing data or order', async () => {
const expectedTelegram = addSetupPreference({}, ['telegramApi']).setupPreference;
const expectedGmail = addSetupPreference({}, ['gmailOAuth2', 'googleApi']).setupPreference;
const credentialsByNode = new Map([
['n8n-nodes-base.gmail', ['gmailOAuth2', 'googleApi']],
['n8n-nodes-base.httpRequest', ['gmailOAuth2']],
['n8n-nodes-base.telegram', ['telegramApi']],
]);
const searchableNodes = [
{
name: 'n8n-nodes-base.telegram',
displayName: 'Telegram',
description: 'Send a notification message',
inputs: ['main'],
outputs: ['main'],
version: 1,
aiGateway: { supported: true, minVersion: 1 },
},
{
name: 'n8n-nodes-base.unknownSetupability',
displayName: 'Unknown Setupability',
description: 'Send a notification message',
inputs: ['main'],
outputs: ['main'],
version: 1,
},
] satisfies SearchableNodeType[];
const expectedSearchResults = new NodeSearchEngine(searchableNodes).searchByName(
'message',
5,
);
const context = createMockContext();
(context.nodeService.listSearchable as Mock).mockResolvedValue(searchableNodes);
(context.nodeService.getDescription as Mock).mockImplementation((nodeType: string) => ({
properties:
nodeType === 'n8n-nodes-base.httpRequest' ? [{ type: 'credentialsSelect' }] : [],
credentials: (credentialsByNode.get(nodeType) ?? []).map((name) => ({ name })),
}));
context.nodeService.listDiscriminators = vi.fn().mockResolvedValue({
resource: ['message'],
});
const tool = createNodesTool(context, 'full');
const searchResult = await executeTool<{
results: Array<{
name: string;
score: number;
note?: string;
aiGateway?: unknown;
discriminators?: unknown;
setupPreference?: unknown;
}>;
}>(tool, { action: 'search', query: 'message', limit: 5 } as never, {} as never);
const suggestedResult = await executeTool<{
results: Array<{
suggestedNodes: Array<{
name: string;
note?: string;
setupPreference?: unknown;
}>;
}>;
}>(tool, { action: 'suggested', categories: ['notification'] } as never, {} as never);
expect(searchResult.results.map(({ name, score }) => ({ name, score }))).toEqual(
expectedSearchResults.map(({ name, score }) => ({ name, score })),
);
const searchTelegram = searchResult.results.find(
(node) => node.name === 'n8n-nodes-base.telegram',
);
const searchUnknown = searchResult.results.find(
(node) => node.name === 'n8n-nodes-base.unknownSetupability',
);
const notificationNodes = suggestedResult.results[0]?.suggestedNodes;
const suggestedTelegram = notificationNodes?.find(
(node) => node.name === 'n8n-nodes-base.telegram',
);
expect(searchTelegram).toMatchObject({
aiGateway: { supported: true, minVersion: 1 },
discriminators: { resource: ['message'] },
setupPreference: expectedTelegram,
});
expect(suggestedTelegram?.setupPreference).toEqual(searchTelegram?.setupPreference);
expect(searchUnknown).not.toHaveProperty('setupPreference');
expect(notificationNodes?.map(({ name }) => name)).toEqual(
suggestedNodesData.notification.nodes.map(({ name }) => name),
);
expect(notificationNodes?.find((node) => node.name === 'n8n-nodes-base.gmail')).toEqual({
name: 'n8n-nodes-base.gmail',
note: "Default to this because it's easy for users to setup",
setupPreference: expectedGmail,
});
expect(
notificationNodes?.find((node) => node.name === 'n8n-nodes-base.httpRequest'),
).not.toHaveProperty('setupPreference');
});
});
describe('explore-resources action', () => {
it('should return error when exploreResources is not available', async () => {
const context = createMockContext();
@@ -7,6 +7,7 @@ import {
NodeSearchEngine,
categoryList,
suggestedNodesData,
type CategorySuggestedNode,
type SearchableNodeType,
} from '@n8n/ai-utilities/node-catalog';
import { z } from 'zod';
@@ -14,6 +15,7 @@ import { z } from 'zod';
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
import type { InstanceAiContext } from '../types';
import { pickPreferredChatModelNode } from './nodes/preferred-chat-model';
import { addSetupPreference, type NodeWithSetupPreference } from './nodes/setup-preference';
import { buildCredentialMap } from './workflows/resolve-credentials';
// ── Action schemas ──────────────────────────────────────────────────────────
@@ -145,6 +147,21 @@ interface SearchEngineCache {
engine?: NodeSearchEngine;
}
async function enrichWithSetupPreference<T extends { name: string }>(
context: InstanceAiContext,
node: T,
version?: number,
): Promise<NodeWithSetupPreference<T>> {
try {
const description = await context.nodeService.getDescription(node.name, version);
if (description.properties.some(({ type }) => type === 'credentialsSelect')) return node;
return addSetupPreference(node, description.credentials?.map(({ name }) => name) ?? []);
} catch {
return node;
}
}
// ── Handlers ────────────────────────────────────────────────────────────────
async function handleList(
@@ -181,13 +198,15 @@ async function handleSearch(
return { results: [], totalResults: 0 };
}
// Enrich results with discriminator info (resources/operations) when available
// Enrich results with discriminator and credential setup metadata when available.
const enriched = await Promise.all(
results.map(async (r) => {
if (!context.nodeService.listDiscriminators) return r;
const disc = await context.nodeService.listDiscriminators(r.name);
if (!disc) return r;
return { ...r, discriminators: disc };
const [node, discriminators] = await Promise.all([
enrichWithSetupPreference(context, r, r.version),
context.nodeService.listDiscriminators?.(r.name) ?? Promise.resolve(null),
]);
return discriminators ? { ...node, discriminators } : node;
}),
);
@@ -323,24 +342,29 @@ async function handleTypeDefinition(
return await resolveNodeTypeDefinitions(context, parsed.data.nodeTypes);
}
// eslint-disable-next-line @typescript-eslint/require-await
async function handleSuggested(input: Extract<FullInput, { action: 'suggested' }>) {
async function handleSuggested(
context: InstanceAiContext,
input: Extract<FullInput, { action: 'suggested' }>,
) {
const results: Array<{
category: string;
description: string;
patternHint: string;
suggestedNodes: Array<{ name: string; note?: string }>;
suggestedNodes: Array<NodeWithSetupPreference<CategorySuggestedNode>>;
}> = [];
const unknownCategories: string[] = [];
for (const cat of input.categories) {
const data = suggestedNodesData[cat];
if (data) {
const suggestedNodes = await Promise.all(
data.nodes.map(async (node) => await enrichWithSetupPreference(context, node)),
);
results.push({
category: cat,
description: data.description,
patternHint: data.patternHint,
suggestedNodes: data.nodes,
suggestedNodes,
});
} else {
unknownCategories.push(cat);
@@ -447,7 +471,7 @@ export function createNodesTool(
case 'type-definition':
return await handleTypeDefinition(context, input);
case 'suggested':
return await handleSuggested(input);
return await handleSuggested(context, input);
case 'explore-resources':
return await handleExploreResources(context, input);
}
@@ -0,0 +1,27 @@
import { addSetupPreference } from '../setup-preference';
vi.mock('../credential-setupability.json', () => ({
default: [
{ id: 'knownCredential', setupability: 0.42, popularity: 0.26 },
{ id: 'unknownCredential', setupability: null, popularity: null },
],
}));
describe('credential setup preference with missing data', () => {
it('preserves missing popularity and rounds known popularity', () => {
expect(addSetupPreference({}, ['knownCredential', 'unknownCredential'])).toEqual({
setupPreference: [
{
type: 'knownCredential',
setupCompletionPercent: 42,
popularityScore: 0.3,
},
{
type: 'unknownCredential',
setupCompletionPercent: null,
popularityScore: null,
},
],
});
});
});
@@ -0,0 +1,57 @@
import credentialSetupability from '../credential-setupability.json';
import { addSetupPreference } from '../setup-preference';
function expectedPreference(metric: (typeof credentialSetupability)[number]) {
return {
type: metric.id,
setupCompletionPercent:
metric.setupability === null ? null : Math.round(metric.setupability * 100),
popularityScore: metric.popularity === null ? null : Math.round(metric.popularity * 10) / 10,
};
}
describe('credential setupability data', () => {
it('should contain canonical credential metrics', () => {
expect(credentialSetupability.length).toBeGreaterThan(0);
expect(new Set(credentialSetupability.map(({ id }) => id)).size).toBe(
credentialSetupability.length,
);
for (const metric of credentialSetupability) {
expect(Object.keys(metric).sort()).toEqual(['id', 'popularity', 'setupability']);
expect(metric.id.trim()).toBe(metric.id);
expect(metric.id.length).toBeGreaterThan(0);
if (metric.setupability !== null) {
expect(metric.setupability).toBeGreaterThanOrEqual(0);
expect(metric.setupability).toBeLessThanOrEqual(1);
expect(metric.setupability * 20).toBeCloseTo(Math.round(metric.setupability * 20));
}
if (metric.popularity !== null) {
expect(metric.popularity).toBeGreaterThanOrEqual(0);
expect(metric.popularity).toBeLessThanOrEqual(1);
expect(metric.popularity * 10).toBeCloseTo(Math.round(metric.popularity * 10));
}
}
});
it('should attach metrics only for credentials supported by the node', () => {
const knownMetric = credentialSetupability.find(({ setupability }) => setupability !== null);
const unknownMetric = credentialSetupability.find(({ setupability }) => setupability === null);
if (!knownMetric || !unknownMetric) throw new Error('Expected known and unknown setupability');
const credentialTypes = [knownMetric.id, unknownMetric.id, knownMetric.id, 'missing'];
const node = { name: 'n8n-nodes-base.gmail' };
expect(addSetupPreference(node, credentialTypes)).toEqual({
...node,
setupPreference: [expectedPreference(knownMetric), expectedPreference(unknownMetric)].sort(
(left, right) => left.type.localeCompare(right.type),
),
});
});
it('should leave nodes without matching credential metrics unchanged', () => {
const node = { name: 'n8n-nodes-base.unknownSetupability' };
expect(addSetupPreference(node, ['unknownCredential'])).toBe(node);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
import credentialSetupability from './credential-setupability.json';
export interface SetupPreference {
type: string;
setupCompletionPercent: number | null;
popularityScore: number | null;
}
export type NodeWithSetupPreference<T extends object> = T & {
setupPreference?: SetupPreference[];
};
const setupPreferences = new Map<string, SetupPreference>();
for (const { id, setupability, popularity } of credentialSetupability) {
setupPreferences.set(id, {
type: id,
setupCompletionPercent: setupability === null ? null : Math.round(setupability * 100),
popularityScore: popularity === null ? null : Math.round(popularity * 10) / 10,
});
}
export function addSetupPreference<T extends object>(
node: T,
credentialTypes: readonly string[],
): NodeWithSetupPreference<T> {
const credentials = [...new Set(credentialTypes)]
.map((credentialType) => setupPreferences.get(credentialType))
.filter((preference): preference is SetupPreference => preference !== undefined)
.sort((left, right) => left.type.localeCompare(right.type));
return credentials.length > 0 ? { ...node, setupPreference: credentials } : node;
}
@@ -5,6 +5,6 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.json"],
"exclude": ["src/**/__tests__/**", "src/**/test-utils/**", "**/*.test.ts"]
}