mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix(core): Make placeholder() return string (no-changelog) (#30100)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e3e70d6068
commit
0feec2fea6
|
|
@ -65,6 +65,7 @@ interface PairwiseArgs {
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
maxExamples?: number;
|
maxExamples?: number;
|
||||||
exampleIds?: Set<string>;
|
exampleIds?: Set<string>;
|
||||||
|
examplesJsonl?: string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
judgeModel: string;
|
judgeModel: string;
|
||||||
|
|
@ -104,6 +105,7 @@ function parseArgs(argv: string[]): PairwiseArgs {
|
||||||
parsePositiveInt(get('--concurrency'), '--concurrency') ?? Number(DEFAULTS.CONCURRENCY),
|
parsePositiveInt(get('--concurrency'), '--concurrency') ?? Number(DEFAULTS.CONCURRENCY),
|
||||||
maxExamples: parsePositiveInt(get('--max-examples'), '--max-examples'),
|
maxExamples: parsePositiveInt(get('--max-examples'), '--max-examples'),
|
||||||
exampleIds,
|
exampleIds,
|
||||||
|
examplesJsonl: get('--examples-jsonl'),
|
||||||
timeoutMs:
|
timeoutMs:
|
||||||
parsePositiveNumber(get('--timeout-ms'), '--timeout-ms') ?? Number(DEFAULTS.TIMEOUT_MS),
|
parsePositiveNumber(get('--timeout-ms'), '--timeout-ms') ?? Number(DEFAULTS.TIMEOUT_MS),
|
||||||
outputDir: get('--output-dir') ?? defaultOutputDir,
|
outputDir: get('--output-dir') ?? defaultOutputDir,
|
||||||
|
|
@ -180,6 +182,9 @@ interface DatasetExample {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExamples(args: PairwiseArgs, logger: EvalLogger): Promise<DatasetExample[]> {
|
async function loadExamples(args: PairwiseArgs, logger: EvalLogger): Promise<DatasetExample[]> {
|
||||||
|
if (args.examplesJsonl) {
|
||||||
|
return loadExamplesFromJsonl(args.examplesJsonl, logger);
|
||||||
|
}
|
||||||
logger.info(`Fetching dataset "${args.dataset}" from LangSmith`);
|
logger.info(`Fetching dataset "${args.dataset}" from LangSmith`);
|
||||||
const lsClient = new LangSmithClient();
|
const lsClient = new LangSmithClient();
|
||||||
const examples: DatasetExample[] = [];
|
const examples: DatasetExample[] = [];
|
||||||
|
|
@ -217,6 +222,52 @@ async function loadExamples(args: PairwiseArgs, logger: EvalLogger): Promise<Dat
|
||||||
return examples;
|
return examples;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load examples from a JSONL file. Accepts the shape produced by a previous
|
||||||
|
* pairwise run (`results.jsonl`) where each row carries `exampleId`, `prompt`,
|
||||||
|
* `dos`, `donts`. Useful for re-running a frozen example set after the source
|
||||||
|
* LangSmith dataset has changed.
|
||||||
|
*/
|
||||||
|
function loadExamplesFromJsonl(filePath: string, logger: EvalLogger): DatasetExample[] {
|
||||||
|
const absolute = path.resolve(filePath);
|
||||||
|
logger.info(`Loading examples from local JSONL: ${absolute}`);
|
||||||
|
const content = readFileSync(absolute, 'utf8');
|
||||||
|
const examples: DatasetExample[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let row = 0;
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
row++;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Skipping JSONL row ${row}: invalid JSON (${(error as Error).message})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isRecord(parsed)) continue;
|
||||||
|
const id = typeof parsed.exampleId === 'string' ? parsed.exampleId : '';
|
||||||
|
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
||||||
|
if (!id || !prompt) {
|
||||||
|
logger.warn(`Skipping JSONL row ${row}: missing exampleId or prompt`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Each iteration of the same example yields a row in results.jsonl;
|
||||||
|
// dedupe by id so we only run the example once per requested iteration.
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
examples.push({
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
dos: typeof parsed.dos === 'string' ? parsed.dos : undefined,
|
||||||
|
donts: typeof parsed.donts === 'string' ? parsed.donts : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info(`Loaded ${examples.length} unique examples from ${absolute}`);
|
||||||
|
return examples;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-example runner
|
// Per-example runner
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -56,14 +56,7 @@ const createMockSDKFunctions = (): SDKFunctions => ({
|
||||||
content,
|
content,
|
||||||
options,
|
options,
|
||||||
})),
|
})),
|
||||||
placeholder: jest.fn((value: string) => ({
|
placeholder: jest.fn((value: string) => `<__PLACEHOLDER_VALUE__${value}__>`),
|
||||||
__placeholder: true as const,
|
|
||||||
hint: value,
|
|
||||||
toString: () => `<__PLACEHOLDER_VALUE__${value}__>`,
|
|
||||||
toJSON() {
|
|
||||||
return this.toString();
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
newCredential: jest.fn((name: string) => ({ __newCredential: true, name })),
|
newCredential: jest.fn((name: string) => ({ __newCredential: true, name })),
|
||||||
ifElse: jest.fn(),
|
ifElse: jest.fn(),
|
||||||
switchCase: jest.fn(),
|
switchCase: jest.fn(),
|
||||||
|
|
@ -970,17 +963,15 @@ describe('AST Interpreter', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('expr(placeholder(...)) error', () => {
|
describe('expr(placeholder(...)) round-trip', () => {
|
||||||
it('should throw clear error when expr receives a PlaceholderValue', () => {
|
it('prepends = to the placeholder marker so it parses as an n8n expression', () => {
|
||||||
const funcs: SDKFunctions = {
|
const funcs: SDKFunctions = {
|
||||||
...createMockSDKFunctions(),
|
...createMockSDKFunctions(),
|
||||||
expr,
|
expr,
|
||||||
};
|
};
|
||||||
const code = `const val = expr(placeholder('Your ID'));
|
const code = `const val = expr(placeholder('Your ID'));
|
||||||
export default val;`;
|
export default val;`;
|
||||||
expect(() => interpretSDKCode(code, funcs)).toThrow(
|
expect(interpretSDKCode(code, funcs)).toBe('=<__PLACEHOLDER_VALUE__Your ID__>');
|
||||||
"expr(placeholder('Your ID')) is invalid",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,9 @@ describe('Expression System', () => {
|
||||||
expect(result).toBe("={{ $('Config').item.json.apiUrl }}");
|
expect(result).toBe("={{ $('Config').item.json.apiUrl }}");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw clear error when called with a PlaceholderValue', () => {
|
it('prepends = to a placeholder marker (preserves round-trip with `=marker`)', () => {
|
||||||
const placeholderObj = { __placeholder: true, hint: 'Your API URL' };
|
const marker = '<__PLACEHOLDER_VALUE__Your API URL__>';
|
||||||
expect(() => expr(placeholderObj as unknown as string)).toThrow(
|
expect(expr(marker)).toBe('=' + marker);
|
||||||
"expr(placeholder('Your API URL')) is invalid. Use placeholder() directly as the value, not inside expr().",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw clear error when called with a NewCredentialValue', () => {
|
it('should throw clear error when called with a NewCredentialValue', () => {
|
||||||
|
|
|
||||||
|
|
@ -36,17 +36,6 @@ export function isExpression(value: unknown): boolean {
|
||||||
* expr('={{ $json.x }}') // '={{ $json.x }}' (strips redundant =)
|
* expr('={{ $json.x }}') // '={{ $json.x }}' (strips redundant =)
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
function isPlaceholderLike(value: unknown): value is { __placeholder: true; hint: string } {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'__placeholder' in value &&
|
|
||||||
(value as Record<string, unknown>).__placeholder === true &&
|
|
||||||
'hint' in value &&
|
|
||||||
typeof (value as Record<string, unknown>).hint === 'string'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNewCredentialLike(value: unknown): value is { __newCredential: true; name: string } {
|
function isNewCredentialLike(value: unknown): value is { __newCredential: true; name: string } {
|
||||||
return (
|
return (
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
|
|
@ -60,15 +49,10 @@ function isNewCredentialLike(value: unknown): value is { __newCredential: true;
|
||||||
|
|
||||||
export function expr(expression: string): string {
|
export function expr(expression: string): string {
|
||||||
if (typeof expression !== 'string') {
|
if (typeof expression !== 'string') {
|
||||||
// At runtime, the AST interpreter may pass non-string values (e.g. PlaceholderImpl objects).
|
// At runtime, the AST interpreter may pass non-string values (e.g. NewCredentialImpl objects).
|
||||||
// TypeScript narrows to `never` here since the param is typed as `string`,
|
// TypeScript narrows to `never` here since the param is typed as `string`,
|
||||||
// so we re-bind as `unknown` to perform runtime type checks.
|
// so we re-bind as `unknown` to perform runtime type checks.
|
||||||
const value: unknown = expression;
|
const value: unknown = expression;
|
||||||
if (isPlaceholderLike(value)) {
|
|
||||||
throw new Error(
|
|
||||||
`expr(placeholder('${value.hint}')) is invalid. Use placeholder() directly as the value, not inside expr().`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isNewCredentialLike(value)) {
|
if (isNewCredentialLike(value)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`expr(newCredential('${value.name}')) is invalid. Use newCredential() directly in the credentials config, not inside expr().`,
|
`expr(newCredential('${value.name}')) is invalid. Use newCredential() directly in the credentials config, not inside expr().`,
|
||||||
|
|
|
||||||
|
|
@ -365,7 +365,7 @@ describe('generate-types', () => {
|
||||||
it('should map string type with Expression wrapper', () => {
|
it('should map string type with Expression wrapper', () => {
|
||||||
const prop: NodeProperty = { name: 'url', displayName: 'URL', type: 'string', default: '' };
|
const prop: NodeProperty = { name: 'url', displayName: 'URL', type: 'string', default: '' };
|
||||||
const result = generateTypes.mapPropertyType(prop);
|
const result = generateTypes.mapPropertyType(prop);
|
||||||
expect(result).toBe('string | Expression<string> | PlaceholderValue');
|
expect(result).toBe('string | Expression<string>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map number type with Expression wrapper', () => {
|
it('should map number type with Expression wrapper', () => {
|
||||||
|
|
@ -554,7 +554,7 @@ describe('generate-types', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = generateTypes.mapPropertyType(prop);
|
const result = generateTypes.mapPropertyType(prop);
|
||||||
expect(result).toBe('Array<string | Expression<string> | PlaceholderValue>');
|
expect(result).toBe('Array<string | Expression<string>>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map fixedCollection type to proper nested interface', () => {
|
it('should map fixedCollection type to proper nested interface', () => {
|
||||||
|
|
@ -606,7 +606,7 @@ describe('generate-types', () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const result = generateTypes.mapPropertyType(prop);
|
const result = generateTypes.mapPropertyType(prop);
|
||||||
expect(result).toContain('attendees?: Array<string | Expression<string> | PlaceholderValue>');
|
expect(result).toContain('attendees?: Array<string | Expression<string>>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map fixedCollection with multipleValues to array type', () => {
|
it('should map fixedCollection with multipleValues to array type', () => {
|
||||||
|
|
@ -759,7 +759,7 @@ describe('generate-types', () => {
|
||||||
const result = generateTypes.mapPropertyType(prop);
|
const result = generateTypes.mapPropertyType(prop);
|
||||||
// Should generate nested structure with proper types
|
// Should generate nested structure with proper types
|
||||||
expect(result).toContain('systemMessage?:');
|
expect(result).toContain('systemMessage?:');
|
||||||
expect(result).toContain('string | Expression<string> | PlaceholderValue');
|
expect(result).toContain('string | Expression<string>');
|
||||||
expect(result).toContain('maxIterations?:');
|
expect(result).toContain('maxIterations?:');
|
||||||
expect(result).toContain('number | Expression<number>');
|
expect(result).toContain('number | Expression<number>');
|
||||||
expect(result).toContain('returnIntermediateSteps?:');
|
expect(result).toContain('returnIntermediateSteps?:');
|
||||||
|
|
@ -845,7 +845,7 @@ describe('generate-types', () => {
|
||||||
expect(result).toContain('@builderHint You can add multiple intervals');
|
expect(result).toContain('@builderHint You can add multiple intervals');
|
||||||
});
|
});
|
||||||
|
|
||||||
// PlaceholderValue tests - string type should include PlaceholderValue, other types should not
|
// PlaceholderValue is no longer emitted by codegen — these tests guard against regressions.
|
||||||
it('should NOT include PlaceholderValue in options type', () => {
|
it('should NOT include PlaceholderValue in options type', () => {
|
||||||
const prop: NodeProperty = {
|
const prop: NodeProperty = {
|
||||||
name: 'method',
|
name: 'method',
|
||||||
|
|
@ -2586,7 +2586,7 @@ describe('generate-types', () => {
|
||||||
default: null,
|
default: null,
|
||||||
};
|
};
|
||||||
const result = generateTypes.mapPropertyType(prop);
|
const result = generateTypes.mapPropertyType(prop);
|
||||||
expect(result).toBe('string | Expression<string> | PlaceholderValue');
|
expect(result).toBe('string | Expression<string>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle options with numeric values', () => {
|
it('should handle options with numeric values', () => {
|
||||||
|
|
|
||||||
|
|
@ -744,11 +744,8 @@ function mapNestedPropertyTypeInner(
|
||||||
|
|
||||||
switch (prop.type) {
|
switch (prop.type) {
|
||||||
case 'string': {
|
case 'string': {
|
||||||
if (prop.builderHint?.placeholderSupported === false) {
|
|
||||||
return 'string | Expression<string>';
|
return 'string | Expression<string>';
|
||||||
}
|
}
|
||||||
return 'string | Expression<string> | PlaceholderValue';
|
|
||||||
}
|
|
||||||
case 'number':
|
case 'number':
|
||||||
return 'number | Expression<number>';
|
return 'number | Expression<number>';
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
|
|
@ -882,6 +879,12 @@ function generateNestedPropertyJSDoc(
|
||||||
lines.push(`${indent} * @builderHint ${safeBuilderHint}`);
|
lines.push(`${indent} * @builderHint ${safeBuilderHint}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placeholder support flag — signals to the builder agent (and the runtime
|
||||||
|
// guard) that placeholder() is rejected for this parameter.
|
||||||
|
if (prop.builderHint?.placeholderSupported === false) {
|
||||||
|
lines.push(`${indent} * @placeholderSupported false`);
|
||||||
|
}
|
||||||
|
|
||||||
// Search/load method annotations — signals to the builder agent that
|
// Search/load method annotations — signals to the builder agent that
|
||||||
// explore-node-resources can resolve real IDs for this parameter.
|
// explore-node-resources can resolve real IDs for this parameter.
|
||||||
if (prop.modes) {
|
if (prop.modes) {
|
||||||
|
|
@ -1375,14 +1378,11 @@ function generateCollectionType(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip Expression<...> and PlaceholderValue from a type string.
|
* Strip Expression<...> from a type string.
|
||||||
* Used when noDataExpression is true to produce plain types.
|
* Used when noDataExpression is true to produce plain types.
|
||||||
*/
|
*/
|
||||||
function stripExpressionFromType(typeStr: string): string {
|
function stripExpressionFromType(typeStr: string): string {
|
||||||
return typeStr
|
return typeStr.replace(/\s*\|\s*Expression<[^>]+>/g, '').trim();
|
||||||
.replace(/\s*\|\s*Expression<[^>]+>/g, '')
|
|
||||||
.replace(/\s*\|\s*PlaceholderValue/g, '')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1436,11 +1436,8 @@ function mapPropertyTypeInner(
|
||||||
|
|
||||||
switch (prop.type) {
|
switch (prop.type) {
|
||||||
case 'string': {
|
case 'string': {
|
||||||
if (prop.builderHint?.placeholderSupported === false) {
|
|
||||||
return 'string | Expression<string>';
|
return 'string | Expression<string>';
|
||||||
}
|
}
|
||||||
return 'string | Expression<string> | PlaceholderValue';
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'number':
|
case 'number':
|
||||||
return 'number | Expression<number>';
|
return 'number | Expression<number>';
|
||||||
|
|
@ -1908,6 +1905,12 @@ export function generatePropertyJSDoc(
|
||||||
lines.push(` * @builderHint ${safeBuilderHint}`);
|
lines.push(` * @builderHint ${safeBuilderHint}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placeholder support flag — signals to the builder agent (and the runtime
|
||||||
|
// guard) that placeholder() is rejected for this parameter.
|
||||||
|
if (prop.builderHint?.placeholderSupported === false) {
|
||||||
|
lines.push(' * @placeholderSupported false');
|
||||||
|
}
|
||||||
|
|
||||||
// Search/load method annotations — signals to the builder agent that
|
// Search/load method annotations — signals to the builder agent that
|
||||||
// explore-node-resources can resolve real IDs for this parameter.
|
// explore-node-resources can resolve real IDs for this parameter.
|
||||||
if (prop.modes) {
|
if (prop.modes) {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ export type {
|
||||||
// Split in batches types
|
// Split in batches types
|
||||||
SplitInBatchesBuilder,
|
SplitInBatchesBuilder,
|
||||||
// Other types
|
// Other types
|
||||||
PlaceholderValue,
|
|
||||||
NewCredentialValue,
|
NewCredentialValue,
|
||||||
AllItemsContext,
|
AllItemsContext,
|
||||||
EachItemContext,
|
EachItemContext,
|
||||||
|
|
|
||||||
|
|
@ -74,18 +74,6 @@ export interface NewCredentialValue {
|
||||||
readonly id?: string;
|
readonly id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Placeholder Values
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for values the user needs to fill in.
|
|
||||||
*/
|
|
||||||
export interface PlaceholderValue {
|
|
||||||
readonly __placeholder: true;
|
|
||||||
readonly hint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Error Handling
|
// Error Handling
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -432,7 +420,10 @@ export interface WorkflowContext {
|
||||||
*/
|
*/
|
||||||
export interface NodeConfig<TParams = IDataObject> {
|
export interface NodeConfig<TParams = IDataObject> {
|
||||||
parameters?: TParams;
|
parameters?: TParams;
|
||||||
credentials?: Record<string, CredentialReference | NewCredentialValue | PlaceholderValue>;
|
credentials?: Record<
|
||||||
|
string,
|
||||||
|
string | CredentialReference | NewCredentialValue | { value: string }
|
||||||
|
>;
|
||||||
name?: string;
|
name?: string;
|
||||||
position?: [number, number];
|
position?: [number, number];
|
||||||
webhookId?: string;
|
webhookId?: string;
|
||||||
|
|
@ -1169,7 +1160,7 @@ export type StickyFn = (
|
||||||
config?: StickyNoteConfig,
|
config?: StickyNoteConfig,
|
||||||
) => NodeInstance<'n8n-nodes-base.stickyNote', 'v1', void>;
|
) => NodeInstance<'n8n-nodes-base.stickyNote', 'v1', void>;
|
||||||
|
|
||||||
export type PlaceholderFn = (hint: string) => PlaceholderValue;
|
export type PlaceholderFn = (hint: string) => string;
|
||||||
|
|
||||||
export type NewCredentialFn = (name: string, id?: string) => NewCredentialValue;
|
export type NewCredentialFn = (name: string, id?: string) => NewCredentialValue;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { resolveMainInputCount } from './input-resolver';
|
||||||
import { validateNodeConfig } from './schema-validator';
|
import { validateNodeConfig } from './schema-validator';
|
||||||
import { isStickyNoteType, isHttpRequestType } from '../constants/node-types';
|
import { isStickyNoteType, isHttpRequestType } from '../constants/node-types';
|
||||||
import type { WorkflowBuilder, WorkflowJSON } from '../types/base';
|
import type { WorkflowBuilder, WorkflowJSON } from '../types/base';
|
||||||
|
import { containsPlaceholderMarker } from '../workflow-builder/string-utils';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
setSchemaBaseDirs,
|
setSchemaBaseDirs,
|
||||||
|
|
@ -502,6 +503,8 @@ export function validateWorkflow(
|
||||||
validateRequiredInputsConnected(json, options.nodeTypesProvider, errors);
|
validateRequiredInputsConnected(json, options.nodeTypesProvider, errors);
|
||||||
// Validate that emitted connection types are actually exposed by the source node's mode
|
// Validate that emitted connection types are actually exposed by the source node's mode
|
||||||
validateOutputUsage(json, options.nodeTypesProvider, warnings);
|
validateOutputUsage(json, options.nodeTypesProvider, warnings);
|
||||||
|
// Reject placeholder() in slots that opt out via builderHint.placeholderSupported === false
|
||||||
|
validatePlaceholderSlots(json, options.nodeTypesProvider, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge node input-count consistency
|
// Merge node input-count consistency
|
||||||
|
|
@ -1016,6 +1019,58 @@ function validateOutputUsage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject `placeholder()` markers found in parameter slots whose property
|
||||||
|
* description carries `builderHint.placeholderSupported === false`.
|
||||||
|
*
|
||||||
|
* This is the runtime side of the type-level signal that used to live in the
|
||||||
|
* generated `string | Expression<string>` union (which previously omitted
|
||||||
|
* `PlaceholderValue`). Now that `placeholder()` returns a plain `string`, the
|
||||||
|
* type system can no longer block placement; this validator does at runtime.
|
||||||
|
*
|
||||||
|
* Uses `containsPlaceholderMarker` (not `isPlaceholderValue`) so that the
|
||||||
|
* marker is rejected anywhere in the value — including `expr(placeholder())`,
|
||||||
|
* which produces `=<__PLACEHOLDER_VALUE__…__>`, and placeholders embedded
|
||||||
|
* inside `={{ … }}` expressions.
|
||||||
|
*
|
||||||
|
* Walks top-level properties only — the known declarations
|
||||||
|
* (webhook `path`, langchain agent `text`) are top-level fields. Nested
|
||||||
|
* collection / fixedCollection support can be added later if a node opts out
|
||||||
|
* of placeholders for a nested field.
|
||||||
|
*/
|
||||||
|
function validatePlaceholderSlots(
|
||||||
|
json: WorkflowJSON,
|
||||||
|
nodeTypesProvider: INodeTypes,
|
||||||
|
errors: ValidationError[],
|
||||||
|
): void {
|
||||||
|
for (const node of json.nodes) {
|
||||||
|
if (!node.name || !node.parameters) continue;
|
||||||
|
|
||||||
|
const version =
|
||||||
|
typeof node.typeVersion === 'string' ? parseFloat(node.typeVersion) : (node.typeVersion ?? 1);
|
||||||
|
|
||||||
|
const nodeType = nodeTypesProvider.getByNameAndVersion(node.type, version);
|
||||||
|
const properties = nodeType?.description?.properties;
|
||||||
|
if (!properties) continue;
|
||||||
|
|
||||||
|
const params = node.parameters as Record<string, unknown>;
|
||||||
|
for (const prop of properties) {
|
||||||
|
if (prop.builderHint?.placeholderSupported !== false) continue;
|
||||||
|
const value = params[prop.name];
|
||||||
|
if (!containsPlaceholderMarker(value)) continue;
|
||||||
|
|
||||||
|
errors.push(
|
||||||
|
new ValidationError(
|
||||||
|
'INVALID_PARAMETER',
|
||||||
|
`Node "${node.name}": placeholder() is not supported for parameter '${prop.name}'. Use a literal value or expr() instead.`,
|
||||||
|
node.name,
|
||||||
|
prop.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if connections use valid input indices for their target nodes.
|
* Check if connections use valid input indices for their target nodes.
|
||||||
* Reports warnings for connections to input indices that don't exist.
|
* Reports warnings for connections to input indices that don't exist.
|
||||||
|
|
|
||||||
|
|
@ -2987,4 +2987,119 @@ describe('Validation', () => {
|
||||||
expect(warnings).toHaveLength(0);
|
expect(warnings).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validatePlaceholderSlots (builderHint.placeholderSupported=false)', () => {
|
||||||
|
const mockNodeTypesProviderWithPlaceholderOptOut = {
|
||||||
|
getByNameAndVersion: (_type: string, _version?: number) => ({
|
||||||
|
description: {
|
||||||
|
inputs: ['main'],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'path',
|
||||||
|
displayName: 'Path',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
builderHint: { placeholderSupported: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'method',
|
||||||
|
displayName: 'Method',
|
||||||
|
type: 'string',
|
||||||
|
default: 'GET',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getByName: (type: string) =>
|
||||||
|
mockNodeTypesProviderWithPlaceholderOptOut.getByNameAndVersion(type),
|
||||||
|
getKnownTypes: () => ({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeWorkflow = (paramValue: string) => ({
|
||||||
|
id: 'test',
|
||||||
|
name: 'Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'webhook-1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: { path: paramValue },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bare placeholder() marker', () => {
|
||||||
|
const result = validateWorkflow(makeWorkflow('<__PLACEHOLDER_VALUE__my path__>'), {
|
||||||
|
nodeTypesProvider: mockNodeTypesProviderWithPlaceholderOptOut as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = result.errors.filter(
|
||||||
|
(e) => e.code === 'INVALID_PARAMETER' && e.message.includes('placeholder()'),
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].nodeName).toBe('Webhook');
|
||||||
|
expect(errors[0].parameterName).toBe('path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects placeholder() wrapped in expr() — leading "=" prefix', () => {
|
||||||
|
const result = validateWorkflow(makeWorkflow('=<__PLACEHOLDER_VALUE__my path__>'), {
|
||||||
|
nodeTypesProvider: mockNodeTypesProviderWithPlaceholderOptOut as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = result.errors.filter(
|
||||||
|
(e) => e.code === 'INVALID_PARAMETER' && e.message.includes('placeholder()'),
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].nodeName).toBe('Webhook');
|
||||||
|
expect(errors[0].parameterName).toBe('path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects placeholder() embedded inside a larger expression', () => {
|
||||||
|
const result = validateWorkflow(
|
||||||
|
makeWorkflow('={{ "prefix-" + "<__PLACEHOLDER_VALUE__my path__>" }}'),
|
||||||
|
{ nodeTypesProvider: mockNodeTypesProviderWithPlaceholderOptOut as never },
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors = result.errors.filter(
|
||||||
|
(e) => e.code === 'INVALID_PARAMETER' && e.message.includes('placeholder()'),
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag literal values', () => {
|
||||||
|
const result = validateWorkflow(makeWorkflow('webhook-path'), {
|
||||||
|
nodeTypesProvider: mockNodeTypesProviderWithPlaceholderOptOut as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = result.errors.filter(
|
||||||
|
(e) => e.code === 'INVALID_PARAMETER' && e.message.includes('placeholder()'),
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag placeholder() in slots without the opt-out hint', () => {
|
||||||
|
const provider = {
|
||||||
|
getByNameAndVersion: () => ({
|
||||||
|
description: {
|
||||||
|
inputs: ['main'],
|
||||||
|
properties: [{ name: 'path', displayName: 'Path', type: 'string', default: '' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getByName: () => provider.getByNameAndVersion(),
|
||||||
|
getKnownTypes: () => ({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateWorkflow(makeWorkflow('=<__PLACEHOLDER_VALUE__my path__>'), {
|
||||||
|
nodeTypesProvider: provider as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = result.errors.filter(
|
||||||
|
(e) => e.code === 'INVALID_PARAMETER' && e.message.includes('placeholder()'),
|
||||||
|
);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -354,16 +354,15 @@ describe('Node Builder', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('placeholder()', () => {
|
describe('placeholder()', () => {
|
||||||
it('should create a placeholder value with hint', () => {
|
it('returns the placeholder marker string', () => {
|
||||||
const p = placeholder('Enter Channel');
|
const p = placeholder('Enter Channel');
|
||||||
expect(p.__placeholder).toBe(true);
|
expect(typeof p).toBe('string');
|
||||||
expect(p.hint).toBe('Enter Channel');
|
expect(p).toBe('<__PLACEHOLDER_VALUE__Enter Channel__>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serialize to placeholder format', () => {
|
it('JSON-serializes to the placeholder marker', () => {
|
||||||
const p = placeholder('API Key');
|
const p = placeholder('API Key');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- Testing custom toString behavior
|
expect(JSON.stringify({ key: p })).toBe('{"key":"<__PLACEHOLDER_VALUE__API Key__>"}');
|
||||||
expect(String(p)).toBe('<__PLACEHOLDER_VALUE__API Key__>');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -461,8 +460,8 @@ describe('Node Builder', () => {
|
||||||
expect((stored as { __newCredential?: boolean }).__newCredential).toBe(true);
|
expect((stored as { __newCredential?: boolean }).__newCredential).toBe(true);
|
||||||
expect((stored as { name?: string }).name).toBe('Slack Bot');
|
expect((stored as { name?: string }).name).toBe('Slack Bot');
|
||||||
expect((stored as { id?: string }).id).toBeUndefined();
|
expect((stored as { id?: string }).id).toBeUndefined();
|
||||||
// The original __placeholder marker is gone — credentials maps never carry it.
|
// The placeholder marker string is gone — credentials maps never carry it.
|
||||||
expect((stored as { __placeholder?: boolean }).__placeholder).toBeUndefined();
|
expect(typeof stored).not.toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes a placeholder() credential to undefined (omitted from JSON)', () => {
|
it('serializes a placeholder() credential to undefined (omitted from JSON)', () => {
|
||||||
|
|
@ -521,6 +520,63 @@ describe('Node Builder', () => {
|
||||||
expect((stored as { __newCredential?: boolean }).__newCredential).toBe(true);
|
expect((stored as { __newCredential?: boolean }).__newCredential).toBe(true);
|
||||||
expect((stored as { name?: string }).name).toBe('Slack Bot');
|
expect((stored as { name?: string }).name).toBe('Slack Bot');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes { value: placeholder() } shape to newCredential', () => {
|
||||||
|
const n = node({
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
version: 2.2,
|
||||||
|
config: { credentials: { slackApi: { value: placeholder('Slack token') } } },
|
||||||
|
});
|
||||||
|
const stored = n.config.credentials?.slackApi as { __newCredential?: boolean; name?: string };
|
||||||
|
expect(stored.__newCredential).toBe(true);
|
||||||
|
expect(stored.name).toBe('Slack token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes { value: literal } shape to newCredential', () => {
|
||||||
|
const n = node({
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
version: 2.2,
|
||||||
|
config: { credentials: { slackApi: { value: 'My Slack Token' } } },
|
||||||
|
});
|
||||||
|
const stored = n.config.credentials?.slackApi as { __newCredential?: boolean; name?: string };
|
||||||
|
expect(stored.__newCredential).toBe(true);
|
||||||
|
expect(stored.name).toBe('My Slack Token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes { id: placeholder, name: literal } to newCredential keyed by name', () => {
|
||||||
|
const n = node({
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
version: 2.2,
|
||||||
|
config: { credentials: { slackApi: { id: placeholder('id hint'), name: 'Slack Bot' } } },
|
||||||
|
});
|
||||||
|
const stored = n.config.credentials?.slackApi as { __newCredential?: boolean; name?: string };
|
||||||
|
expect(stored.__newCredential).toBe(true);
|
||||||
|
expect(stored.name).toBe('Slack Bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes { id: placeholder, name: placeholder } to newCredential keyed by name hint', () => {
|
||||||
|
const n = node({
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
version: 2.2,
|
||||||
|
config: {
|
||||||
|
credentials: {
|
||||||
|
slackApi: { id: placeholder('id hint'), name: placeholder('Slack Bot') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stored = n.config.credentials?.slackApi as { __newCredential?: boolean; name?: string };
|
||||||
|
expect(stored.__newCredential).toBe(true);
|
||||||
|
expect(stored.name).toBe('Slack Bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a raw {id, name} CredentialReference through unchanged', () => {
|
||||||
|
const n = node({
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
version: 2.2,
|
||||||
|
config: { credentials: { slackApi: { id: 'cred-1', name: 'Slack Bot' } } },
|
||||||
|
});
|
||||||
|
expect(n.config.credentials?.slackApi).toEqual({ id: 'cred-1', name: 'Slack Bot' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('then() with multiple targets (fan-out)', () => {
|
describe('then() with multiple targets (fan-out)', () => {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
type NodeInput,
|
type NodeInput,
|
||||||
type TriggerInput,
|
type TriggerInput,
|
||||||
type StickyNoteConfig,
|
type StickyNoteConfig,
|
||||||
type PlaceholderValue,
|
|
||||||
type NewCredentialValue,
|
type NewCredentialValue,
|
||||||
type CredentialReference,
|
type CredentialReference,
|
||||||
type DeclaredConnection,
|
type DeclaredConnection,
|
||||||
|
|
@ -21,6 +20,7 @@ import {
|
||||||
type IfElseTarget,
|
type IfElseTarget,
|
||||||
type SwitchCaseTarget,
|
type SwitchCaseTarget,
|
||||||
} from '../../types/base';
|
} from '../../types/base';
|
||||||
|
import { extractHint, isPlaceholderValue } from '../string-utils';
|
||||||
import {
|
import {
|
||||||
isSwitchCaseComposite,
|
isSwitchCaseComposite,
|
||||||
isIfElseComposite,
|
isIfElseComposite,
|
||||||
|
|
@ -100,32 +100,77 @@ function generateNodeName(type: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collapse `placeholder('hint')` markers inside a credentials map into
|
* Normalize the various credential-slot shapes the agent (or hand-written code)
|
||||||
* `newCredential('hint')`. The two have identical intent in this slot —
|
* may emit into the canonical `CredentialReference | NewCredentialValue` form
|
||||||
* "a credential is required, no real one is bound yet" — so we normalize at
|
* downstream code expects.
|
||||||
* config ingest. Downstream code (resolveCredentials, hasNewCredential, the
|
*
|
||||||
* `__newCredential` toJSON path) only ever sees `__newCredential` markers in
|
* Accepted shapes (all converge to `NewCredentialValue` or `CredentialReference`):
|
||||||
* credential slots, never `__placeholder` ones.
|
* - bare placeholder marker string → `newCredential(extractedHint)`
|
||||||
|
* - `{ value: <string|placeholder> }` → `newCredential(<hint or literal>)`
|
||||||
|
* - `{ id: <placeholder>, name: <string|placeholder> }` → `newCredential(<name or id-hint>)`
|
||||||
|
* - `{ id: <string>, name: <string> }` → unchanged `CredentialReference`
|
||||||
|
* - `NewCredentialValue` instance → unchanged
|
||||||
*
|
*
|
||||||
* Returns a new config object when any normalization happens; otherwise a
|
* Returns a new config object when any normalization happens; otherwise a
|
||||||
* shallow copy (matching the previous `{ ...config }` semantics).
|
* shallow copy (matching the previous `{ ...config }` semantics).
|
||||||
*/
|
*/
|
||||||
export function normalizeNodeConfig(config: NodeConfig): NodeConfig {
|
export function normalizeNodeConfig(config: NodeConfig): NodeConfig {
|
||||||
const creds = config?.credentials;
|
const creds = config?.credentials as Record<string, unknown> | undefined;
|
||||||
if (!creds) return { ...config };
|
if (!creds) return { ...config };
|
||||||
|
|
||||||
let normalizedCreds:
|
let normalizedCreds:
|
||||||
| Record<string, CredentialReference | NewCredentialValue | PlaceholderValue>
|
| Record<string, CredentialReference | NewCredentialValue | string>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
const setNormalized = (key: string, next: CredentialReference | NewCredentialValue | string) => {
|
||||||
|
normalizedCreds ??= { ...creds } as Record<
|
||||||
|
string,
|
||||||
|
CredentialReference | NewCredentialValue | string
|
||||||
|
>;
|
||||||
|
normalizedCreds[key] = next;
|
||||||
|
};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(creds)) {
|
for (const [key, value] of Object.entries(creds)) {
|
||||||
if (value && typeof value === 'object' && '__placeholder' in value) {
|
// 1. Bare placeholder marker string → newCredential(extractedHint)
|
||||||
normalizedCreds ??= { ...creds };
|
if (typeof value === 'string' && isPlaceholderValue(value)) {
|
||||||
normalizedCreds[key] = new NewCredentialImpl(value.hint);
|
setNormalized(key, new NewCredentialImpl(extractHint(value)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
// Already a NewCredentialValue — leave alone
|
||||||
|
if ('__newCredential' in obj) continue;
|
||||||
|
|
||||||
|
// 2. { value: ... } → newCredential
|
||||||
|
if ('value' in obj && !('id' in obj)) {
|
||||||
|
const v = String(obj.value);
|
||||||
|
const name = isPlaceholderValue(v) ? extractHint(v) : v;
|
||||||
|
setNormalized(key, new NewCredentialImpl(name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. { id, name } where id is a placeholder marker → newCredential(name)
|
||||||
|
if ('id' in obj) {
|
||||||
|
const idStr = typeof obj.id === 'string' ? obj.id : '';
|
||||||
|
if (isPlaceholderValue(idStr)) {
|
||||||
|
const rawName = obj.name;
|
||||||
|
const name =
|
||||||
|
typeof rawName === 'string' && rawName.length > 0 && !isPlaceholderValue(rawName)
|
||||||
|
? rawName
|
||||||
|
: typeof rawName === 'string' && isPlaceholderValue(rawName)
|
||||||
|
? extractHint(rawName)
|
||||||
|
: extractHint(idStr);
|
||||||
|
setNormalized(key, new NewCredentialImpl(name));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. else: leave as CredentialReference / plain string
|
||||||
|
}
|
||||||
|
|
||||||
if (!normalizedCreds) return { ...config };
|
if (!normalizedCreds) return { ...config };
|
||||||
return { ...config, credentials: normalizedCreds };
|
return { ...config, credentials: normalizedCreds } as NodeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1159,38 +1204,23 @@ export function sticky(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder implementation
|
* Create a placeholder value for template parameters.
|
||||||
*/
|
|
||||||
class PlaceholderImpl implements PlaceholderValue {
|
|
||||||
readonly __placeholder = true as const;
|
|
||||||
readonly hint: string;
|
|
||||||
|
|
||||||
constructor(hint: string) {
|
|
||||||
this.hint = hint;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString(): string {
|
|
||||||
return `<__PLACEHOLDER_VALUE__${this.hint}__>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): string {
|
|
||||||
return this.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a placeholder value for template parameters
|
|
||||||
*
|
*
|
||||||
* Placeholders are used to mark values that need to be filled in
|
* Returns the marker string `<__PLACEHOLDER_VALUE__<hint>__>`, which is
|
||||||
* when a workflow template is instantiated.
|
* structurally a `string` — so it flows through every string-typed slot in
|
||||||
|
* generated node types without TypeScript complaints. The workflow compiler
|
||||||
|
* recognises the marker via {@link isPlaceholderValue} and treats the slot as
|
||||||
|
* "to be filled in by the user before execution".
|
||||||
*
|
*
|
||||||
* Inside a node's `credentials` slot, `placeholder(hint)` is normalized to
|
* Inside a node's `credentials` slot, the marker is normalised to
|
||||||
* `newCredential(hint)` at config ingest — the two have identical intent
|
* `newCredential(hint)` at config ingest — see {@link normalizeNodeConfig}.
|
||||||
* there ("a credential is required, no real one bound yet"). Outside the
|
*
|
||||||
* credentials slot the original placeholder semantics are unchanged.
|
* Note: a small number of parameters carry `placeholderSupported: false` in
|
||||||
|
* their description (e.g. webhook `path`) and the workflow compiler will throw
|
||||||
|
* a clear error if a placeholder lands in such a slot.
|
||||||
*
|
*
|
||||||
* @param hint - Description shown to users (e.g., 'Enter Channel')
|
* @param hint - Description shown to users (e.g., 'Enter Channel')
|
||||||
* @returns A placeholder value that serializes to the placeholder format
|
* @returns The placeholder marker string.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
|
|
@ -1200,8 +1230,8 @@ class PlaceholderImpl implements PlaceholderValue {
|
||||||
* // Serializes channel as: '<__PLACEHOLDER_VALUE__Enter Channel__>'
|
* // Serializes channel as: '<__PLACEHOLDER_VALUE__Enter Channel__>'
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function placeholder(hint: string): PlaceholderValue {
|
export function placeholder(hint: string): string {
|
||||||
return new PlaceholderImpl(hint);
|
return `<__PLACEHOLDER_VALUE__${hint}__>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -161,12 +161,12 @@ describe('isDataTableWithoutTable', () => {
|
||||||
expect(isDataTableWithoutTable(node)).toBe(true);
|
expect(isDataTableWithoutTable(node)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true for dataTable node with placeholder value', () => {
|
it('returns true for dataTable node with placeholder marker value', () => {
|
||||||
const node = createNode({
|
const node = createNode({
|
||||||
type: 'n8n-nodes-base.dataTable',
|
type: 'n8n-nodes-base.dataTable',
|
||||||
config: {
|
config: {
|
||||||
parameters: {
|
parameters: {
|
||||||
dataTableId: { value: { __placeholder: true } },
|
dataTableId: { value: '<__PLACEHOLDER_VALUE__Select table__>' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
* Functions for determining which nodes should have pin data generated.
|
* Functions for determining which nodes should have pin data generated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isPlaceholderValue } from './string-utils';
|
||||||
import { isHttpRequestType, isWebhookType, isDataTableType } from '../constants/node-types';
|
import { isHttpRequestType, isWebhookType, isDataTableType } from '../constants/node-types';
|
||||||
import type { NodeInstance } from '../types/base';
|
import type { NodeInstance } from '../types/base';
|
||||||
|
|
||||||
|
|
@ -12,11 +13,10 @@ import type { NodeInstance } from '../types/base';
|
||||||
* Nodes with new credentials need pin data to avoid execution errors.
|
* Nodes with new credentials need pin data to avoid execution errors.
|
||||||
*
|
*
|
||||||
* Note: by the time a NodeInstance reaches this code, credentials slots only
|
* Note: by the time a NodeInstance reaches this code, credentials slots only
|
||||||
* ever contain `CredentialReference` or `__newCredential` markers — never
|
* ever contain `CredentialReference` or `__newCredential` markers. Bare
|
||||||
* `__placeholder` markers. `placeholder()` values supplied for credentials
|
* placeholder marker strings and the `{ value }` convenience shape supplied
|
||||||
* are normalized to `__newCredential` at config ingest in
|
* for credentials are normalized to `__newCredential` at config ingest in
|
||||||
* `node-builder.ts#normalizeNodeConfig`, so we don't need a second check
|
* `node-builder.ts#normalizeNodeConfig`, so we don't need a second check here.
|
||||||
* here.
|
|
||||||
*/
|
*/
|
||||||
export function hasNewCredential(node: NodeInstance<string, string, unknown>): boolean {
|
export function hasNewCredential(node: NodeInstance<string, string, unknown>): boolean {
|
||||||
// Check main node credentials
|
// Check main node credentials
|
||||||
|
|
@ -75,12 +75,8 @@ export function isDataTableWithoutTable(node: NodeInstance<string, string, unkno
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if value is a placeholder (user needs to fill in)
|
// Check if value is a placeholder marker (user needs to fill in)
|
||||||
if (
|
if (isPlaceholderValue(dataTableId.value)) {
|
||||||
typeof dataTableId.value === 'object' &&
|
|
||||||
dataTableId.value !== null &&
|
|
||||||
'__placeholder' in dataTableId.value
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,14 +108,22 @@ function serializeNode(
|
||||||
// post-redaction string `"[REDACTED]"`). Pass through unchanged.
|
// post-redaction string `"[REDACTED]"`). Pass through unchanged.
|
||||||
n8nNode.credentials = deepCopy(config.credentials);
|
n8nNode.credentials = deepCopy(config.credentials);
|
||||||
} else {
|
} else {
|
||||||
// `NodeConfig.credentials` is typed wide (also accepts PlaceholderValue)
|
// `NodeConfig.credentials` is typed wide (string | { value } | etc.) at the
|
||||||
// at the public API. By this point `normalizeNodeConfig` has rewritten any
|
// public API. By this point `normalizeNodeConfig` has rewritten the loose
|
||||||
// placeholder() markers to newCredential() markers, so no __placeholder
|
// shapes to `CredentialReference | NewCredentialValue`. Defensively skip
|
||||||
// values remain at runtime. Narrow the value type for the serializer.
|
// any leftover placeholder marker strings or `{ value }` objects (they are
|
||||||
|
// placeholders the user must still fill in and have no `id`/`name` to
|
||||||
|
// serialize). Plain strings (e.g. legacy 'YOUR_CREDENTIALS' style refs)
|
||||||
|
// pass through unchanged for backwards compatibility.
|
||||||
const resolvable: NonNullable<NodeJSON['credentials']> = {};
|
const resolvable: NonNullable<NodeJSON['credentials']> = {};
|
||||||
for (const [key, value] of Object.entries(config.credentials)) {
|
for (const [key, value] of Object.entries(config.credentials)) {
|
||||||
if (value && typeof value === 'object' && '__placeholder' in value) continue;
|
if (typeof value === 'string') {
|
||||||
resolvable[key] = value;
|
if (value.startsWith('<__PLACEHOLDER_VALUE__') && value.endsWith('__>')) continue;
|
||||||
|
resolvable[key] = value as unknown as { id?: string; name: string };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && 'value' in value && !('id' in value)) continue;
|
||||||
|
resolvable[key] = value as { id?: string; name: string };
|
||||||
}
|
}
|
||||||
n8nNode.credentials = deepCopy(resolvable);
|
n8nNode.credentials = deepCopy(resolvable);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
filterMethodsFromPath,
|
filterMethodsFromPath,
|
||||||
parseVersion,
|
parseVersion,
|
||||||
isPlaceholderValue,
|
isPlaceholderValue,
|
||||||
|
containsPlaceholderMarker,
|
||||||
isResourceLocatorLike,
|
isResourceLocatorLike,
|
||||||
normalizeResourceLocators,
|
normalizeResourceLocators,
|
||||||
escapeNewlinesInStringLiterals,
|
escapeNewlinesInStringLiterals,
|
||||||
|
|
@ -106,6 +107,38 @@ describe('workflow-builder/string-utils', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('containsPlaceholderMarker', () => {
|
||||||
|
it('returns true for bare placeholder string', () => {
|
||||||
|
expect(containsPlaceholderMarker('<__PLACEHOLDER_VALUE__test__>')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for expr()-wrapped placeholder (leading "=")', () => {
|
||||||
|
expect(containsPlaceholderMarker('=<__PLACEHOLDER_VALUE__test__>')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for placeholder embedded in an expression block', () => {
|
||||||
|
expect(containsPlaceholderMarker('={{ "prefix-" + "<__PLACEHOLDER_VALUE__name__>" }}')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for literal strings without the marker', () => {
|
||||||
|
expect(containsPlaceholderMarker('regular string')).toBe(false);
|
||||||
|
expect(containsPlaceholderMarker('=expression')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for incomplete marker (missing closing)', () => {
|
||||||
|
expect(containsPlaceholderMarker('<__PLACEHOLDER_VALUE__test')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-string values', () => {
|
||||||
|
expect(containsPlaceholderMarker(123)).toBe(false);
|
||||||
|
expect(containsPlaceholderMarker(null)).toBe(false);
|
||||||
|
expect(containsPlaceholderMarker(undefined)).toBe(false);
|
||||||
|
expect(containsPlaceholderMarker({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isResourceLocatorLike', () => {
|
describe('isResourceLocatorLike', () => {
|
||||||
it('returns true for object with mode and value', () => {
|
it('returns true for object with mode and value', () => {
|
||||||
expect(isResourceLocatorLike({ mode: 'list', value: 'test' })).toBe(true);
|
expect(isResourceLocatorLike({ mode: 'list', value: 'test' })).toBe(true);
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,31 @@ export function isPlaceholderValue(value: unknown): boolean {
|
||||||
return value.startsWith('<__PLACEHOLDER_VALUE__') && value.endsWith('__>');
|
return value.startsWith('<__PLACEHOLDER_VALUE__') && value.endsWith('__>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value contains a placeholder marker anywhere within it.
|
||||||
|
*
|
||||||
|
* Unlike {@link isPlaceholderValue}, which requires the marker to span the
|
||||||
|
* entire string, this catches placeholders embedded in larger strings — most
|
||||||
|
* notably `=<__PLACEHOLDER_VALUE__…__>` produced by wrapping `placeholder()`
|
||||||
|
* inside `expr()`, or placeholders concatenated inside `={{ … }}` blocks.
|
||||||
|
*/
|
||||||
|
export function containsPlaceholderMarker(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
return /<__PLACEHOLDER_VALUE__[\s\S]*?__>/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the original hint from a placeholder marker string.
|
||||||
|
* Returns the input unchanged if it does not match the marker format.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* extractHint('<__PLACEHOLDER_VALUE__OpenAI key__>') // → 'OpenAI key'
|
||||||
|
*/
|
||||||
|
export function extractHint(value: string): string {
|
||||||
|
if (!isPlaceholderValue(value)) return value;
|
||||||
|
return value.slice('<__PLACEHOLDER_VALUE__'.length, -'__>'.length);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an object looks like a resource locator value.
|
* Check if an object looks like a resource locator value.
|
||||||
* Resource locators have a 'mode' property (typically 'list', 'id', 'url', or 'name')
|
* Resource locators have a 'mode' property (typically 'list', 'id', 'url', or 'name')
|
||||||
|
|
|
||||||
|
|
@ -194,12 +194,9 @@ describe('workflow-builder/validation-helpers', () => {
|
||||||
expect(issues[0].path).toBe('items[1]');
|
expect(issues[0].path).toBe('items[1]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips PlaceholderValue objects', () => {
|
it('skips placeholder marker strings', () => {
|
||||||
const issues = findMissingExpressionPrefixes({
|
const issues = findMissingExpressionPrefixes({
|
||||||
field: {
|
field: '<__PLACEHOLDER_VALUE__{{ $json.field }}__>',
|
||||||
__placeholder: true,
|
|
||||||
hint: '{{ $json.field }}',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(issues).toHaveLength(0);
|
expect(issues).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
* Pure functions extracted from WorkflowBuilderImpl
|
* Pure functions extracted from WorkflowBuilderImpl
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isPlaceholderValue } from './string-utils';
|
||||||
import { isTriggerNodeType } from '../utils/trigger-detection';
|
import { isTriggerNodeType } from '../utils/trigger-detection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -124,6 +125,8 @@ export function findMissingExpressionPrefixes(
|
||||||
const issues: Array<{ path: string; value: string }> = [];
|
const issues: Array<{ path: string; value: string }> = [];
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
|
// Skip placeholder markers — their embedded hint is documentation, not an expression.
|
||||||
|
if (isPlaceholderValue(value)) return issues;
|
||||||
// If string starts with '=', it's already an expression - {{ }} is valid template syntax inside
|
// If string starts with '=', it's already an expression - {{ }} is valid template syntax inside
|
||||||
// Otherwise check if it contains {{ $ pattern (n8n variable reference without = prefix)
|
// Otherwise check if it contains {{ $ pattern (n8n variable reference without = prefix)
|
||||||
if (!value.startsWith('=') && value.includes('{{ $')) {
|
if (!value.startsWith('=') && value.includes('{{ $')) {
|
||||||
|
|
@ -134,10 +137,6 @@ export function findMissingExpressionPrefixes(
|
||||||
issues.push(...findMissingExpressionPrefixes(item, `${path}[${index}]`));
|
issues.push(...findMissingExpressionPrefixes(item, `${path}[${index}]`));
|
||||||
});
|
});
|
||||||
} else if (value && typeof value === 'object') {
|
} else if (value && typeof value === 'object') {
|
||||||
// Skip PlaceholderValue objects - their hint property is documentation, not actual expressions
|
|
||||||
if ('__placeholder' in value && (value as { __placeholder: boolean }).__placeholder) {
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
for (const [key, val] of Object.entries(value)) {
|
for (const [key, val] of Object.entries(value)) {
|
||||||
const newPath = path ? `${path}.${key}` : key;
|
const newPath = path ? `${path}.${key}` : key;
|
||||||
issues.push(...findMissingExpressionPrefixes(val, newPath));
|
issues.push(...findMissingExpressionPrefixes(val, newPath));
|
||||||
|
|
@ -181,6 +180,7 @@ export function findInvalidDateMethods(
|
||||||
const issues: Array<{ path: string; value: string }> = [];
|
const issues: Array<{ path: string; value: string }> = [];
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
|
if (isPlaceholderValue(value)) return issues;
|
||||||
if (hasLuxonToISOStringMisuse(value)) {
|
if (hasLuxonToISOStringMisuse(value)) {
|
||||||
issues.push({ path, value });
|
issues.push({ path, value });
|
||||||
}
|
}
|
||||||
|
|
@ -189,10 +189,6 @@ export function findInvalidDateMethods(
|
||||||
issues.push(...findInvalidDateMethods(item, `${path}[${index}]`));
|
issues.push(...findInvalidDateMethods(item, `${path}[${index}]`));
|
||||||
});
|
});
|
||||||
} else if (value && typeof value === 'object') {
|
} else if (value && typeof value === 'object') {
|
||||||
// Skip PlaceholderValue objects
|
|
||||||
if ('__placeholder' in value && (value as { __placeholder: boolean }).__placeholder) {
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
for (const [key, val] of Object.entries(value)) {
|
for (const [key, val] of Object.entries(value)) {
|
||||||
const newPath = path ? `${path}.${key}` : key;
|
const newPath = path ? `${path}.${key}` : key;
|
||||||
issues.push(...findInvalidDateMethods(val, newPath));
|
issues.push(...findInvalidDateMethods(val, newPath));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user