mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
46703e395f
* fix(docs): pin the generator's sort locale to en-US
localeCompare with no locale argument uses the runtime default, which
varies with LANG and the ICU build. Against the real 254 catalog names,
tr-TR reorders 141 positions, et-EE 45, cs-CZ 2 and lt-LT diverges at
index 40 — so a contributor on any of those regenerates a different
integrations.json and fails CI with no obvious cause.
Pins en-US at all four sort sites. The committed artifacts are unchanged:
regenerating before and after leaves the tree byte-identical.
Adds a guard test asserting the committed catalog matches an explicit
en-US ordering, plus one that fails if an unpinned localeCompare returns.
* fix(vanta): remove the MIME Type field whose value was always discarded
The block rendered an advanced MIME Type input and forwarded it as the
tool's mimeType, but the upload path never reads it: file-input.ts sets
`resolved.contentType || userFile.type || input.mimeType || …`, and every
return path of downloadServableFileFromStorage yields a non-empty content
type — a literal, getMimeTypeFromExtension's GENERIC_MIME_TYPE fallback,
or resolveServableDocBytes' constants and getContentType fallback. The
placeholder claimed it was 'used when the file has no type of its own',
which never happens.
Removes the subBlock, its mapper write and its inputs entry, and marks the
tool param hidden so it is no longer advertised to the model on a path
where it cannot take effect. The param itself stays, because the base64
branch still reads it.
Letting a typed value win instead was rejected: for a compiled artifact
the storage-resolved type is the only one matching the bytes actually
sent, so an override would break the case the resolver exists to fix.
* fix(github): resolve the PR head SHA for file comments
github_comment sent commit_id as undefined for every file comment: the
param was hidden with no subBlock and no mapper write, so GitHub — which
marks commit_id required on POST /pulls/{n}/comments — answered 422 on a
path the commentType dropdown exposes.
When commitId is absent the tool now fetches the pull request first and
uses head.sha, mirroring how Jira resolves cloudId from domain.
Also removes the position param, which GitHub marks deprecated ("Use
line instead"); line is already a real subBlock.
* fix(google-drive): expose the page token so pagination is reachable
list, search, list_comments, list_permissions and list_revisions each
declared a hidden pageToken and forwarded it to Google, but the block had
no subBlock of that name and never has — so every list was capped at one
page and the nextPageToken output had nowhere to go.
Adds a per-operation Page Token field mirroring the block's existing
per-operation pageSize fields, collapses them onto the canonical
pageToken in the params mapper, and flips the tool param to user-only so
it is documented and settable.
* fix(confluence): stop documenting a cloudId users cannot supply
All 46 Confluence tools marked cloudId 'user-only', publishing it on 46
doc rows, but the block has no cloudId subBlock so no user could ever
fill it. createConfluenceClient already resolves the cloud id from the
domain through the shared Atlassian resolver, exactly as Jira does, and
Jira marks the same param hidden.
Marks cloudId hidden to match. domain stays user-only and settable — it
is now the only user-provided param on every Confluence tool.
* fix(docs): teach the source scanner about regex literals
blankStringsAndComments was a single regex with no concept of a regex
literal, so two shapes silently truncated a block's subBlock list:
/don't/ the apostrophe opened a phantom string that swallowed the
following entries
/[}]/ the brace in the character class closed the enclosing object
Both returned a short list with no warning — a confident wrong answer,
which for the hidden-param filter means silently deleting a user-settable
row. No block file uses a regex literal today, so this was latent.
Replaces the regex with a linear scanner that distinguishes a regex
literal from a division by the previous significant character, blanks
regex bodies whole (their last character is arbitrary source, same reason
comments are blanked whole), and tracks ${} nesting so a backtick inside
a template expression cannot end the template early.
The scanner now returns null when it ends inside an unterminated
construct. All three call sites treat that as UNKNOWN rather than
guessing, so the filter switches off instead of stripping.
Generated artifacts are byte-identical and the warning count is unchanged.
* fix(github): gate the commit lookup on path, coerce line, name the failing field
Three corrections to the file-comment fix, from a validation sweep.
needsCommitLookup did not check `path`, and ran before the `path` branch in
request.url. A file comment with an empty File Path — reachable, since path
is not required on the block — went GET /pulls/{n} then POST /comments with
path undefined, a 422. On staging it posted to /pulls/{n}/reviews, which
GitHub documents as creating a pending review and where commit_id is
optional. The lookup is now gated on path, so only a request headed for
/comments triggers it.
The block has no tools.config.params, so `line` reached the tool as the
string the short-input produced while GitHub types it as an integer — file
comments would still have 422'd, one API call later. Coerced in
request.body, which runs at execution; anything non-finite is omitted
rather than sent as NaN.
readGitHubErrorMessage returned only the top-level message, so a 422 read
"Validation Failed" with no indication of which field was rejected. The
errors[] detail is now appended. Responses without errors[] are unchanged.
* test(github): cover the comment routing cases the gate changed
Adds the file_comment-without-a-path case (which the commit lookup now skips),
the untouched-block default where commentType is unset, a pr_comment carrying a
path, and the line coercion on both the direct and resolved-commit paths.
* fix(google-drive): let an agent feed the page token back in
A page token is an opaque continuation value produced by a previous tool
response, not an account-specific id the user has to supply, so 'user-only'
hid it from agent blocks: they saw nextPageToken in the result and could not
send it back, silently reporting page one as the whole answer. Every other
pagination token in the tool set is 'user-or-llm'.
Also covers the case the mapper guard actually defends — a per-operation page
token surviving an operation switch, which reaches inputs because
shouldSerializeSubBlock skips condition evaluation for advanced fields.
* docs(generator): name the load-bearing newline rule and report an unscannable source
- Record why '\\n' is in REGEX_ALLOWED_AFTER: formatters emit a binary '/' at
end-of-line, so every line-leading '/' in blocks/*.ts is a real regex, including
the ones in table.ts and table_v2.ts. Removing it silently mis-scans those two.
- Split a scanner failure out of the spread-only 'ids: null' case. Both scans come
back empty for the same reason when blankStringsAndComments bails, so the mapper's
renames were dropped with no warning; it now reports a parseError and warns. The
spread case is unchanged, and its TSDoc no longer claims a cause that was false.
- Route every catalog sort through an exported compareCatalogNames so the ordering
test exercises the generator's comparator instead of re-deriving it, and match
localeCompare arguments whole so localeCompare() and a variable locale are caught.
- Note that downloadServableFileFromStorage guarantees a non-empty content type, so
the Vanta mimeType fallback chain reads as deliberately defensive.
Artifacts regenerate byte-identically and the generator warning set is unchanged.
* fix(vanta): declare the removed uploadMimeType subblock as dropped
check-block-registry fails a PR that deletes a subblock id without a
migration entry, because a deployed workflow can still hold a value under
that id. The serializer already discards an orphan silently, but the repo's
contract is that the removal is declared rather than inferred.
Uses the _removed_ form, scoped to upload_document_file: the value has no
replacement field to move to.
* fix(github): run the two-phase PR comment on the secure transport
The file-comment flow posted its comment from `transformResponse` with a bare
global `fetch`, so that request carried no abort signal, no response ceiling and
no DNS/SSRF validation — cancelling a workflow still left the comment posted.
`transformResponse` cannot receive the signal; `directExecution` can. Both tools
now run the lookup and the POST through a new `secureGitHubRequest`, mirroring
`secureBitbucketRead`, with the signal forwarded to each. Routing, line coercion
and the `errors[]` detail are unchanged, and a failed response still throws an
error carrying `status`/`statusText`/`data` as the transport does.
Request bodies and the comment payload are explicitly typed instead of
`Record<string, any>`.
* fix(vanta): keep mimeType an ordinary upload parameter
`mimeType` was marked hidden, but `visibility: 'hidden'` is reserved for
system-injected params such as OAuth tokens. It also left the base64 upload path
with no way to set a content type, since `fileContent` is hidden too.
* fix(generator): lex regex-in-keyword-position and template interpolation
The scanner chose regex-vs-division from the previous significant character, so
a regex in operand position (`return /x/`, `typeof /x/`, `case /x/`, ...) lexed
as a division off the keyword's last letter and its body stayed in the
structural view; `azure_devops.ts` is inert today only because the braces in its
`return /^\d{4}-\d{2}-\d{2}$/` happen to balance.
The `${}` depth counter was also not string-aware, so a brace inside a quoted
expression miscounted, the closing backtick was lost and the block was reported
unreadable — which silently stops filtering resolver-derived hidden params.
Both scans now run on one set of lexer primitives: `${}` expressions are lexed
with the same string, comment, regex and template handling as top-level code.
Regenerated docs, tool metadata and the integration catalog are byte-identical
and the generator's warning count is unchanged.
* fix(github): stop forwarding the GitHub token across a redirect origin
secureGitHubRequest passed no redirectPolicy, and the transport only strips
credential headers when one is present, so an api.github.com redirect to another
origin carried the workspace's Authorization: Bearer header to the new host.
Adopts the standard policy already used by the internal Google Drive client.
stripAuthOnRedirect stays off: GitHub redirects same-origin for legitimate
reasons (a renamed repository answers 301), and dropping auth there would turn
a working call into a 401.
* fix(github): reject a fractional comment line instead of truncating it
toLineNumber ran Math.trunc, so line 3.9 posted the review comment on line 3 —
a silent change to what the caller asked for, on a field where landing on the
wrong line of the diff is invisible until someone reads the comment. A
non-integer now fails with a message naming the field, matching how a missing
head commit SHA fails on this path. Blank and unparseable input is still
omitted: line is optional and nothing usable was supplied.
* fix(github): select the comment endpoint by comment type, not by path
The endpoint was chosen by the presence of path, while the body was chosen by
commentType, so a pr_comment naming a file posted a review body to
POST /pulls/{n}/comments. GitHub documents body, commit_id and path as required
there, so that request can only ever 422 — it has been broken since before this
PR. The endpoint now follows the comment type: only a file comment carrying a
path uses /comments, everything else stays on /reviews. The test that codified
the broken routing is corrected, and the full type/path matrix is pinned.
* test(confluence): drop the cloudId visibility invariant test
Removed at request. The visibility change itself is unaffected; it loses
only the guard that would have caught a future edit reverting one of the
46 files.
* fix(github): send an explicit User-Agent and stop downgrading a redirected comment POST
`secureGitHubRequest` powers the GitHub comment tool's `directExecution`
path, which bypasses the declarative transport. Two behaviors the transport
provided did not survive the move.
User-Agent: the transport sets `User-Agent: Sim` on every request it formats
(`request-transport.ts`), and `secureFetchWithPinnedIP` adds none of its own —
it builds the request with raw `node:https` and passes headers through
verbatim. Production runs on Bun, whose `node:http` shim injects
`user-agent: Bun/x.y.z`, so GitHub does not reject these calls today; the
defect is that Sim's deliberate attribution is silently replaced by a runtime
version string, and that the tool depends on an undocumented runtime behavior
that does not hold under Node, where GitHub answers 403 "Request forbidden by
administrative rules". Set in the helper rather than in the tool's header map
so every future caller inherits it; a caller-supplied value still wins.
Redirect method: the policy was `mode: 'standard'`, under which
`resolveRedirectHop` rewrites a 301/302'd POST to a bodyless GET regardless of
origin. GitHub answers 301 within api.github.com for a renamed repository, so
commenting on a PR there would GET `/pulls/{n}/comments`, receive a JSON array,
fail the payload shape check, and report success with no comment created.
`legacy` keeps the method and body across that hop. Cross-origin credential
stripping is unaffected — the guarded follower strips Authorization,
Proxy-Authorization and Cookie whenever `sendCredentialsOnCrossOriginRedirect`
is false, in either mode.
* fix(vanta): drop the dead whenOperation from the removed-subblock entry
migrateBlockSubblockIds handles a _removed_ target before it consults
whenOperation, so the scope was never applied. Mine was the only _removed_
entry in the file carrying one.
Unconditional deletion is also what this case wants. Subblock values are
keyed by id and are not cleared when the operation changes, so a user who
filled the MIME field and then switched the block to another operation has
the value stored under that operation; a scoped delete would strand it
permanently. The field no longer exists for any operation, so it should go
regardless of the stored operation.
* fix(docs-gen): close a regex-vs-division gap and make three guards testable
`REGEX_ALLOWED_AFTER` was missing `'/'`, so a regex directly after a division
operator lexed as a second division: in `x / y / /[}]/` the character class was
left in the structural view and its `}` closed the enclosing object early,
truncating the block's subBlock ids with no warning. Add `'/'`, and guard the
`'+'`/`'-'` entries with a `++`/`--` lookbehind so a postfix update still reads
as a value and `i++ / 2` stays a division rather than a phantom regex that runs
to end-of-input and reports the block unreadable.
The `.`/`#` property guard and the `'\n'` entry both survived their mutants.
`counts.in / 2, m: preturn / 2` is self-cancelling — the mis-lexed regex closes
on the second slash and blanks nothing structural — so the fixtures now leave an
odd number of slashes on the line. The `'\n'` entry had no coverage at all: its
fixture is now the wrapped `.match(` newline `/re/` shape that `blocks/table.ts`
and `blocks/table_v2.ts` produce, which is what that entry (not the preceding
`(`, which the newline overwrites) actually decides. Its comment claimed a count
of line-leading regexes that drifts with the sources; restate it without one.
Drop the two locale tests that could not fail. CI runs under an `en-US` default,
where an unpinned `localeCompare` returns exactly what the pinned one does, so no
behavioural comparison discriminates; and asserting the committed
`integrations.json` against the comparator that produced it agrees by
construction. The source grep for a literal locale argument is the real guard.
Generated output is byte-identical and the extraction differential over
`apps/sim/blocks/blocks/` is empty.
927 lines
33 KiB
TypeScript
927 lines
33 KiB
TypeScript
import fs from 'fs'
|
|
import path from 'path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
extractAllBlockConfigs,
|
|
extractBlockSuppliedParamIds,
|
|
extractToolInfo,
|
|
extractUserSettableParamIds,
|
|
getToolInfo,
|
|
parseConstProperties,
|
|
parsePropertiesContent,
|
|
} from './generate-docs'
|
|
|
|
describe('documentation tool metadata', () => {
|
|
it('keeps legitimate parameters named params', async () => {
|
|
const tool = await getToolInfo('supabase_rpc')
|
|
|
|
expect(tool?.params.map(({ name }) => name)).toContain('params')
|
|
})
|
|
|
|
it('does not render operation model-input metadata as a parameter', async () => {
|
|
const tool = await getToolInfo('elevenlabs_sound_effects')
|
|
|
|
expect(tool?.params.map(({ name }) => name)).not.toContain('modelInput')
|
|
})
|
|
|
|
it('documents Reducto table format values using their wire identifiers', async () => {
|
|
const tool = await getToolInfo('reducto_parser_v2')
|
|
|
|
expect(tool?.params.find(({ name }) => name === 'tableOutputFormat')).toMatchObject({
|
|
description: 'Table output format (`md` for Markdown or `html` for HTML). Defaults to `md`.',
|
|
})
|
|
})
|
|
|
|
it('documents only the URL and headers accepted by File Fetch', async () => {
|
|
const tool = await getToolInfo('file_fetch')
|
|
|
|
expect(tool?.params).toEqual([
|
|
{
|
|
name: 'fileUrl',
|
|
type: 'string',
|
|
required: true,
|
|
description: 'URL of the file to fetch and parse.',
|
|
},
|
|
{
|
|
name: 'headers',
|
|
type: 'object',
|
|
required: false,
|
|
description: 'HTTP headers to include when fetching URL-based files.',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('uses evaluated descriptions instead of emitting source concatenation syntax', async () => {
|
|
const tool = await getToolInfo('sendgrid_list_templates')
|
|
|
|
expect(tool?.params.find(({ name }) => name === 'pageSize')?.description).toBe(
|
|
'Number of templates to return per page (default: 20, max: 200). When paginating with pageToken, pass the same pageSize used on the first request to keep page boundaries consistent.'
|
|
)
|
|
})
|
|
|
|
it('uses evaluated constants instead of emitting template expressions', async () => {
|
|
const tool = await getToolInfo('table_batch_insert_rows')
|
|
|
|
expect(tool?.description).toBe('Insert multiple rows into a table at once (up to 1000 rows)')
|
|
expect(tool?.params.find(({ name }) => name === 'rows')?.description).toBe(
|
|
'Array of row data objects (max 1000 rows)'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('documentation input parameter parsing', () => {
|
|
it('stops at operation metadata', () => {
|
|
const tool = extractToolInfo(
|
|
'example_generate',
|
|
`
|
|
export const exampleTool = {
|
|
id: 'example_generate',
|
|
description: 'Generate an example',
|
|
params: {
|
|
prompt: {
|
|
type: 'string',
|
|
required: true,
|
|
description: 'The prompt',
|
|
},
|
|
},
|
|
operation: {
|
|
modelInput: {
|
|
mode: 'project',
|
|
select: (params) => ({ prompt: params.prompt }),
|
|
},
|
|
secretProvenance: {
|
|
inputPaths: ['prompt'],
|
|
},
|
|
input: (params) => ({
|
|
body: { prompt: params.prompt },
|
|
}),
|
|
},
|
|
outputs: {},
|
|
}
|
|
`
|
|
)
|
|
|
|
expect(tool?.params).toEqual([
|
|
{
|
|
name: 'prompt',
|
|
type: 'string',
|
|
required: true,
|
|
description: 'The prompt',
|
|
},
|
|
])
|
|
})
|
|
|
|
/**
|
|
* Pins the hidden-param filter on the source-parsing path in {@link extractToolInfo}. Tools
|
|
* with an entry in tool-metadata.ts never reach it, so it must be driven with synthetic
|
|
* source rather than through `getToolInfo`. Without this the filter can be deleted outright
|
|
* and the whole suite stays green.
|
|
*/
|
|
describe('the hidden-param filter on the source-parsing path', () => {
|
|
const source = `
|
|
export const exampleTool = {
|
|
id: 'example_send',
|
|
description: 'Send an example',
|
|
params: {
|
|
message: {
|
|
type: 'string',
|
|
required: true,
|
|
description: 'The message',
|
|
},
|
|
apiKey: {
|
|
type: 'string',
|
|
required: true,
|
|
visibility: 'hidden',
|
|
description: 'The API key the block injects',
|
|
},
|
|
instanceUrl: {
|
|
type: 'string',
|
|
required: true,
|
|
visibility: 'hidden',
|
|
description: 'Resolved from the credential',
|
|
},
|
|
},
|
|
outputs: {},
|
|
}
|
|
`
|
|
|
|
const paramNames = (ids: ReadonlySet<string> | null) =>
|
|
extractToolInfo('example_send', source, '', '', '', ids)?.params.map(({ name }) => name)
|
|
|
|
it('drops a hidden param the block does not supply', () => {
|
|
expect(paramNames(new Set(['message']))).toEqual(['message'])
|
|
})
|
|
|
|
it('keeps a hidden param the block exposes as its own field', () => {
|
|
expect(paramNames(new Set(['message', 'apiKey']))).toEqual(['message', 'apiKey'])
|
|
})
|
|
|
|
it('keeps every param when the block-supplied set is UNKNOWN', () => {
|
|
expect(paramNames(null)).toEqual(['message', 'apiKey', 'instanceUrl'])
|
|
})
|
|
})
|
|
|
|
it('stops at legacy request metadata after a comment', () => {
|
|
const tool = extractToolInfo(
|
|
'example_send',
|
|
`
|
|
export const exampleTool = {
|
|
id: 'example_send',
|
|
description: 'Send an example',
|
|
params: {
|
|
message: {
|
|
type: 'string',
|
|
required: true,
|
|
description: 'The message',
|
|
},
|
|
},
|
|
// Direct execution short-circuits this legacy request descriptor.
|
|
request: {
|
|
url: () => '',
|
|
method: 'POST',
|
|
modelInput: {
|
|
mode: 'project',
|
|
select: (params) => ({ message: params.message }),
|
|
},
|
|
},
|
|
directExecution: async () => ({ success: true }),
|
|
outputs: {},
|
|
}
|
|
`
|
|
)
|
|
|
|
expect(tool?.params.map(({ name }) => name)).toEqual(['message'])
|
|
})
|
|
})
|
|
|
|
describe('documentation output property parsing', () => {
|
|
it('keeps a response field named items inside an array element', () => {
|
|
const properties = parsePropertiesContent(`
|
|
vaults: {
|
|
type: 'array',
|
|
description: 'List of accessible vaults',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string', description: 'Vault ID' },
|
|
items: { type: 'number', description: 'Number of items in the vault' },
|
|
},
|
|
},
|
|
},
|
|
`)
|
|
|
|
expect(Object.keys(properties)).toEqual(['vaults'])
|
|
expect(properties.vaults.items.properties.items).toEqual({
|
|
type: 'number',
|
|
description: 'Number of items in the vault',
|
|
})
|
|
})
|
|
|
|
it('keeps a response field named items that directly references a constant', () => {
|
|
const properties = parsePropertiesContent('items: ATTENDEES_OUTPUT,', 'calcom')
|
|
|
|
expect(properties.items).toMatchObject({
|
|
type: 'array',
|
|
description: 'List of attendees',
|
|
})
|
|
})
|
|
|
|
it('keeps a response field named items that references a constant property', () => {
|
|
const properties = parsePropertiesContent('items: EVENT_TYPE_OUTPUT_PROPERTIES.id,', 'calcom')
|
|
|
|
expect(properties.items).toEqual({
|
|
type: 'number',
|
|
description: 'Event type ID',
|
|
})
|
|
})
|
|
|
|
it('keeps items fields in constant-defined property maps', () => {
|
|
const typesContent = `
|
|
export const RECORD_OUTPUT_PROPERTIES = {
|
|
id: { type: 'string', description: 'Record ID' },
|
|
}
|
|
`
|
|
const properties = parseConstProperties(
|
|
`
|
|
items: {
|
|
type: 'object',
|
|
description: 'Result page',
|
|
properties: {
|
|
object: { type: 'string', description: 'Page type' },
|
|
data: {
|
|
type: 'array',
|
|
description: 'Result records',
|
|
items: { type: 'object', properties: RECORD_OUTPUT_PROPERTIES },
|
|
},
|
|
hasMore: { type: 'boolean', description: 'Whether more results exist' },
|
|
},
|
|
},
|
|
`,
|
|
'test',
|
|
typesContent,
|
|
0
|
|
)
|
|
|
|
expect(Object.keys(properties)).toEqual(['items'])
|
|
expect(Object.keys(properties.items.properties)).toEqual(['object', 'data', 'hasMore'])
|
|
expect(properties.items.properties.data.items.properties.id).toEqual({
|
|
type: 'string',
|
|
description: 'Record ID',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('hidden tool params in the Input table', () => {
|
|
const blockSource = (blockFile: string) =>
|
|
fs.readFileSync(path.join(import.meta.dirname, '../apps/sim/blocks/blocks', blockFile), 'utf-8')
|
|
|
|
const paramNames = async (toolId: string, blockFile: string) => {
|
|
const info = await getToolInfo(toolId, extractUserSettableParamIds(blockSource(blockFile)))
|
|
return info?.params.map((param) => param.name) ?? []
|
|
}
|
|
|
|
it('extracts the param ids a block exposes to the user', () => {
|
|
const ids = extractUserSettableParamIds(blockSource('mailchimp.ts'))
|
|
|
|
expect(ids).toContain('apiKey')
|
|
expect(extractUserSettableParamIds(blockSource('jira.ts'))).not.toContain('cloudId')
|
|
})
|
|
|
|
it('keeps a hidden tool param the block exposes as a user-typed field', async () => {
|
|
await expect(paramNames('mailchimp_add_member', 'mailchimp.ts')).resolves.toContain('apiKey')
|
|
})
|
|
|
|
it('drops hidden params the block never exposes', async () => {
|
|
await expect(paramNames('jira_retrieve', 'jira.ts')).resolves.not.toContain('cloudId')
|
|
await expect(paramNames('jira_write', 'jira.ts')).resolves.not.toContain('cloudId')
|
|
|
|
const salesforce = await paramNames('salesforce_query', 'salesforce.ts')
|
|
expect(salesforce).not.toContain('idToken')
|
|
expect(salesforce).not.toContain('instanceUrl')
|
|
|
|
await expect(paramNames('netsuite_execute_suiteql', 'netsuite.ts')).resolves.not.toContain(
|
|
'instanceUrl'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('subBlock param extraction', () => {
|
|
const blockSource = (blockFile: string) =>
|
|
fs.readFileSync(path.join(import.meta.dirname, '../apps/sim/blocks/blocks', blockFile), 'utf-8')
|
|
|
|
it('extracts ids from a block whose subBlocks array contains commented-out code', () => {
|
|
const ids = extractUserSettableParamIds(blockSource('google_drive.ts'))
|
|
|
|
expect(ids).toContain('operation')
|
|
expect(ids).toContain('mimeType')
|
|
expect(ids).toContain('fileName')
|
|
expect(ids).toContain('uploadFolderSelector')
|
|
})
|
|
|
|
it('extracts ids past a commented-out subBlock that ends a line on an open bracket', () => {
|
|
const ids = extractUserSettableParamIds(blockSource('human_in_the_loop.ts'))
|
|
|
|
expect(ids).toContain('notification')
|
|
expect(ids).toContain('inputFormat')
|
|
})
|
|
|
|
it('returns no ids for blocks whose subBlocks array is genuinely empty', () => {
|
|
for (const blockFile of ['chat_trigger.ts', 'manual_trigger.ts']) {
|
|
expect(extractUserSettableParamIds(blockSource(blockFile))).toEqual([])
|
|
}
|
|
})
|
|
|
|
/**
|
|
* The spreads name fields arrays this scanner never follows, so what the block supplies is
|
|
* UNKNOWN. Answering `[]` asserts the block supplies nothing, and the hidden-param filter
|
|
* reads that as licence to strip every hidden param from every tool the block owns — silently,
|
|
* with no `parseError` and so no warning. `NotionV2Block` has exactly this shape and is only
|
|
* harmless today because no `notion_*` tool carries a hidden param besides `accessToken`.
|
|
*/
|
|
it('reports a subBlocks array of only unfollowable spreads as UNKNOWN, not empty', () => {
|
|
for (const blockFile of [
|
|
'imap.ts',
|
|
'generic_webhook.ts',
|
|
'circleback.ts',
|
|
'rss.ts',
|
|
'sim_workspace_event.ts',
|
|
]) {
|
|
expect(extractUserSettableParamIds(blockSource(blockFile))).toBeNull()
|
|
}
|
|
|
|
const supplied = extractBlockSuppliedParamIds(
|
|
`subBlocks: [...NotionBlock.subBlocks],`,
|
|
'NotionV2'
|
|
)
|
|
expect(supplied.ids).toBeNull()
|
|
expect(supplied.parseError).toBeNull()
|
|
})
|
|
|
|
it('still returns the inline ids when a spread sits alongside them', () => {
|
|
expect(
|
|
extractUserSettableParamIds(`subBlocks: [...Base.subBlocks, { id: 'operation' }],`)
|
|
).toEqual(['operation'])
|
|
})
|
|
|
|
it('ignores an id inside a comment or string literal at the top level of a subBlock', () => {
|
|
expect(
|
|
extractUserSettableParamIds(`subBlocks: [\n { // id: 'ghost',\n id: 'real' },\n],`)
|
|
).toEqual(['real'])
|
|
|
|
expect(
|
|
extractUserSettableParamIds(
|
|
`subBlocks: [\n { placeholder: "id: 'ghost'",\n id: 'real' },\n],`
|
|
)
|
|
).toEqual(['real'])
|
|
|
|
expect(
|
|
extractUserSettableParamIds(
|
|
`subBlocks: [\n { placeholder: "canonicalParamId: 'ghost'",\n id: 'real',\n canonicalParamId: 'canonical' },\n],`
|
|
)
|
|
).toEqual(['real', 'canonical'])
|
|
})
|
|
|
|
it('throws when the subBlocks array holds literal objects but yields no ids', () => {
|
|
expect(() =>
|
|
extractUserSettableParamIds(`subBlocks: [\n { title: 'No id here' },\n],`)
|
|
).toThrow(/subBlocks/)
|
|
})
|
|
|
|
it('throws when the subBlocks array bracket scan fails', () => {
|
|
expect(() => extractUserSettableParamIds(`subBlocks: [\n { id: 'operation' },\n`)).toThrow(
|
|
/subBlocks/
|
|
)
|
|
})
|
|
|
|
/**
|
|
* Shapes taken verbatim from the blocks that ship them: `SlackV2Block`,
|
|
* `VideoGeneratorV3Block`, `NotionV2Block` and `LinearV2Block`.
|
|
*/
|
|
describe('subBlocks shapes the array-literal scan cannot walk', () => {
|
|
it('reports a subBlocks value that is not an array literal at all', () => {
|
|
expect(() =>
|
|
extractUserSettableParamIds(
|
|
`subBlocks: withFalAIModelOptions(VideoGeneratorV2Block.subBlocks, MODELS),`,
|
|
'VideoGeneratorV3'
|
|
)
|
|
).toThrow(/VideoGeneratorV3: subBlocks/)
|
|
})
|
|
|
|
it('reports an array whose only element is a bare helper call', () => {
|
|
expect(() =>
|
|
extractUserSettableParamIds(
|
|
`subBlocks: [...getSlackV2ActionSubBlocks(), ...getTrigger('slack_oauth').subBlocks],`,
|
|
'SlackV2'
|
|
)
|
|
).toThrow(/SlackV2: subBlocks/)
|
|
})
|
|
|
|
/**
|
|
* The elements name fields arrays, so the array parsed fine and there is nothing to warn
|
|
* about — but this scanner never follows a spread, so the fields are UNKNOWN rather than
|
|
* absent. `[]` would be a confident wrong answer that strips every hidden param the block's
|
|
* tools declare.
|
|
*/
|
|
it('reports an array of nothing but named fields arrays as UNKNOWN', () => {
|
|
expect(
|
|
extractUserSettableParamIds(
|
|
`subBlocks: [\n ...NotionBlock.subBlocks,\n ...getTrigger('notion_page_created').subBlocks,\n],`,
|
|
'NotionV2'
|
|
)
|
|
).toBeNull()
|
|
|
|
expect(
|
|
extractUserSettableParamIds(
|
|
`subBlocks: [\n ...LinearBlock.subBlocks.filter((sb) => !sb.id?.startsWith('webhookSecret')),\n],`,
|
|
'LinearV2'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('does not fail a block that overrides a spread subBlock instead of naming an id', () => {
|
|
expect(
|
|
extractUserSettableParamIds(
|
|
`subBlocks: [\n ...Base.subBlocks.map((sb) => (sb.id === 'x' ? { ...sb, required: true } : sb)),\n],`,
|
|
'OverridingV2'
|
|
)
|
|
).toBeNull()
|
|
})
|
|
|
|
it('leaves a readable array alone even when it also spreads an opaque helper', () => {
|
|
expect(
|
|
extractUserSettableParamIds(
|
|
`subBlocks: [\n ...SERVICE_ACCOUNT_SUBBLOCKS,\n { id: 'operation' },\n],`,
|
|
'GoogleDrive'
|
|
)
|
|
).toEqual(['operation'])
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('hidden params supplied by the block mapper', () => {
|
|
const blockSource = (blockFile: string) =>
|
|
fs.readFileSync(path.join(import.meta.dirname, '../apps/sim/blocks/blocks', blockFile), 'utf-8')
|
|
|
|
const paramNames = async (toolId: string, blockFile: string) => {
|
|
const info = await getToolInfo(toolId, extractBlockSuppliedParamIds(blockSource(blockFile)).ids)
|
|
return info?.params.map((param) => param.name) ?? []
|
|
}
|
|
|
|
it("keeps Cal.com's required attendee, assembled as result.attendee in the mapper", async () => {
|
|
expect(extractBlockSuppliedParamIds(blockSource('calcom.ts')).ids).toContain('attendee')
|
|
await expect(paramNames('calcom_create_booking', 'calcom.ts')).resolves.toContain('attendee')
|
|
})
|
|
|
|
it("keeps JSM's workspaceId, renamed from assetWorkspaceId in the mapper", async () => {
|
|
expect(extractBlockSuppliedParamIds(blockSource('jira_service_management.ts')).ids).toContain(
|
|
'workspaceId'
|
|
)
|
|
await expect(
|
|
paramNames('jsm_list_object_schemas', 'jira_service_management.ts')
|
|
).resolves.toContain('workspaceId')
|
|
})
|
|
|
|
it('keeps the file params Textract renames from its document field', async () => {
|
|
const ids = extractBlockSuppliedParamIds(blockSource('textract.ts')).ids
|
|
expect(ids).toContain('file')
|
|
expect(ids).toContain('fileBack')
|
|
expect(ids).toContain('filePathBack')
|
|
|
|
const params = await paramNames('textract_analyze_id', 'textract.ts')
|
|
expect(params).toContain('file')
|
|
expect(params).toContain('fileBack')
|
|
expect(params).toContain('filePathBack')
|
|
})
|
|
|
|
it('keeps the Mistral parser file param, so its Input table is not empty', async () => {
|
|
await expect(paramNames('mistral_parser_v3', 'mistral_parse.ts')).resolves.toContain('file')
|
|
})
|
|
|
|
it('still drops resolver-derived hidden params with no user surface', async () => {
|
|
await expect(paramNames('jira_retrieve', 'jira.ts')).resolves.not.toContain('cloudId')
|
|
await expect(
|
|
paramNames('jsm_list_object_schemas', 'jira_service_management.ts')
|
|
).resolves.not.toContain('cloudId')
|
|
|
|
const salesforce = await paramNames('salesforce_query', 'salesforce.ts')
|
|
expect(salesforce).not.toContain('idToken')
|
|
expect(salesforce).not.toContain('instanceUrl')
|
|
|
|
await expect(paramNames('netsuite_execute_suiteql', 'netsuite.ts')).resolves.not.toContain(
|
|
'instanceUrl'
|
|
)
|
|
await expect(paramNames('snowflake_execute_sql', 'snowflake.ts')).resolves.not.toContain(
|
|
'domain'
|
|
)
|
|
await expect(paramNames('pipedrive_get_deal', 'pipedrive.ts')).resolves.not.toContain(
|
|
'authStyle'
|
|
)
|
|
await expect(paramNames('zoho_desk_list_tickets', 'zoho-desk.ts')).resolves.not.toContain(
|
|
'apiDomain'
|
|
)
|
|
})
|
|
|
|
it('finds the real mapper past a decoy params key that is not a mapper', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(`
|
|
subBlocks: [{ id: 'operation' }],
|
|
tools: {
|
|
config: {
|
|
params: (GitHubBlock.tools?.config as any)?.params,
|
|
params: (params) => ({ renamed: params.original }),
|
|
},
|
|
},
|
|
`)
|
|
expect(ids).toContain('renamed')
|
|
})
|
|
|
|
it('reads an async mapper body', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(`
|
|
subBlocks: [{ id: 'operation' }],
|
|
tools: {
|
|
config: {
|
|
params: async (params) => ({ renamed: params.original }),
|
|
},
|
|
},
|
|
`)
|
|
expect(ids).toContain('renamed')
|
|
})
|
|
|
|
it('ignores a commented-out mapper assignment', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(`
|
|
subBlocks: [{ id: 'operation' }],
|
|
tools: {
|
|
config: {
|
|
params: (params) => {
|
|
const result: Record<string, unknown> = {}
|
|
// result.commentedOut = params.nope
|
|
result.realOne = params.yes
|
|
return result
|
|
},
|
|
},
|
|
},
|
|
`)
|
|
expect(ids).toContain('realOne')
|
|
expect(ids).not.toContain('commentedOut')
|
|
})
|
|
})
|
|
|
|
describe('an unreadable subBlocks array', () => {
|
|
/**
|
|
* Every shape ships in the tree today (`SlackV2Block`, `VideoGeneratorV3Block`, the
|
|
* `COMMON_SUBBLOCKS` spread and a backtick id), and each one used to end the run for the
|
|
* whole repository unless the block happened to spread a base whose fields were readable.
|
|
*/
|
|
const unreadable: [string, string][] = [
|
|
['a bare identifier', 'subBlocks: myFields,'],
|
|
['a helper call', 'subBlocks: withFalAIModelOptions(Base.subBlocks, MODELS),'],
|
|
['a spread of an opaque constant', 'subBlocks: [...COMMON_SUBBLOCKS],'],
|
|
['a backtick id', 'subBlocks: [{ id: `operation` }],'],
|
|
]
|
|
|
|
it.each(unreadable)('reports %s as UNKNOWN instead of throwing', (_label, source) => {
|
|
const supplied = extractBlockSuppliedParamIds(source, 'Widget')
|
|
|
|
expect(supplied.ids).toBeNull()
|
|
expect(supplied.parseError).toMatch(/Widget/)
|
|
})
|
|
|
|
it('still collects the mapper-written ids when only the subBlocks scan failed', () => {
|
|
const supplied = extractBlockSuppliedParamIds(
|
|
`
|
|
subBlocks: myFields,
|
|
tools: {
|
|
config: {
|
|
params: (params) => ({ renamed: params.original }),
|
|
},
|
|
},
|
|
`,
|
|
'Widget'
|
|
)
|
|
|
|
expect(supplied.ids).toBeNull()
|
|
expect(supplied.mapperIds).toContain('renamed')
|
|
})
|
|
|
|
const syntheticBlock = (name: string, body: string) => `
|
|
import type { BlockConfig } from '@/blocks/types'
|
|
|
|
export const ${name}Block: BlockConfig = {
|
|
type: '${name.toLowerCase()}',
|
|
name: '${name}',
|
|
description: 'A synthetic block',
|
|
tools: { access: ['${name.toLowerCase()}_do'] },
|
|
${body}
|
|
}
|
|
`
|
|
|
|
it('leaves userSettableParamIds UNKNOWN on the block config it produces', () => {
|
|
const [unknownConfig] = extractAllBlockConfigs(syntheticBlock('Opaque', 'subBlocks: myFields,'))
|
|
expect(unknownConfig.userSettableParamIds).toBeNull()
|
|
|
|
const [readableConfig] = extractAllBlockConfigs(
|
|
syntheticBlock('Readable', `subBlocks: [{ id: 'query' }],`)
|
|
)
|
|
expect(readableConfig.userSettableParamIds).toEqual(['query'])
|
|
})
|
|
|
|
/**
|
|
* The whole point of the UNKNOWN state: `[]` asserts the block supplies nothing and strips
|
|
* every hidden param, so the two must not be spelled the same way.
|
|
*/
|
|
it('disables the hidden-param filter, where an empty list applies it', async () => {
|
|
const unfiltered = await getToolInfo('jira_retrieve', null)
|
|
expect(unfiltered?.params.map((param) => param.name)).toContain('cloudId')
|
|
|
|
const filtered = await getToolInfo('jira_retrieve', [])
|
|
expect(filtered?.params.map((param) => param.name)).not.toContain('cloudId')
|
|
})
|
|
|
|
it('defaults to not filtering when no param ids are passed at all', async () => {
|
|
const info = await getToolInfo('jira_retrieve')
|
|
expect(info?.params.map((param) => param.name)).toContain('cloudId')
|
|
})
|
|
})
|
|
|
|
describe('mapper param shapes', () => {
|
|
const mapperBlock = (body: string) => `
|
|
subBlocks: [{ id: 'doc' }],
|
|
tools: {
|
|
config: {
|
|
params: (params) => ${body},
|
|
},
|
|
},
|
|
`
|
|
|
|
it('reads a shorthand property', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(
|
|
mapperBlock('{\n const file = params.doc\n return { file }\n}')
|
|
)
|
|
expect(ids).toContain('doc')
|
|
expect(ids).toContain('file')
|
|
})
|
|
|
|
it('reads a shorthand property alongside a spread and a named key', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(mapperBlock('({ ...rest, file, other: 1 })'))
|
|
expect(ids).toEqual(expect.arrayContaining(['file', 'other']))
|
|
expect(ids).not.toContain('rest')
|
|
})
|
|
|
|
it('reads a shorthand property listed after another shorthand', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(mapperBlock('({ first, file })'))
|
|
expect(ids).toEqual(expect.arrayContaining(['first', 'file']))
|
|
})
|
|
|
|
it('reads a computed string assignment', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(
|
|
mapperBlock(
|
|
"{\n const result: Record<string, unknown> = {}\n result['file'] = params.doc\n return result\n}"
|
|
)
|
|
)
|
|
expect(ids).toContain('file')
|
|
})
|
|
|
|
it('does not take a call argument list for a shorthand property', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(
|
|
mapperBlock('({ file: buildFile(alpha, beta, gamma) })')
|
|
)
|
|
expect(ids).toContain('file')
|
|
expect(ids).not.toContain('beta')
|
|
})
|
|
|
|
it('ignores a shorthand property inside a comment or a string', () => {
|
|
const { ids } = extractBlockSuppliedParamIds(
|
|
mapperBlock("({\n // { ghostComment }\n note: '{ ghostString }',\n file,\n})")
|
|
)
|
|
expect(ids).toContain('file')
|
|
expect(ids).not.toContain('ghostComment')
|
|
expect(ids).not.toContain('ghostString')
|
|
})
|
|
})
|
|
|
|
describe('a source the scanner cannot get through is reported, not swallowed', () => {
|
|
/**
|
|
* When `blankStringsAndComments` bails, both the `subBlocks` scan and the mapper scan come
|
|
* back empty for the same reason. Reported as a plain `ids: null` that is indistinguishable
|
|
* from a spread-only `subBlocks` array, the block's mapper renames are dropped in silence.
|
|
*/
|
|
const unterminated = `subBlocks: [{ id: 'a' }],
|
|
tools: { config: { params: (p) => ({ renamedByMapper: p.a }) } },
|
|
longDescription: 'never closed`
|
|
|
|
it('sets parseError so the caller warns', () => {
|
|
const supplied = extractBlockSuppliedParamIds(unterminated, 'GhostBlock')
|
|
|
|
expect(supplied.parseError).not.toBeNull()
|
|
expect(supplied.parseError).toMatch(/GhostBlock: source ends inside an unterminated/)
|
|
expect(supplied.ids).toBeNull()
|
|
})
|
|
|
|
it('still reports null with no parseError for a spread-only subBlocks array', () => {
|
|
const supplied = extractBlockSuppliedParamIds(
|
|
"subBlocks: [...NotionBlock.subBlocks], tools: { config: { params: (p) => ({ renamedByMapper: p.a }) } },",
|
|
'SpreadBlock'
|
|
)
|
|
|
|
expect(supplied.parseError).toBeNull()
|
|
expect(supplied.ids).toBeNull()
|
|
expect(supplied.mapperIds).toContain('renamedByMapper')
|
|
})
|
|
})
|
|
|
|
describe('the generated catalog ordering is locale-independent', () => {
|
|
/**
|
|
* `localeCompare` with no locale argument uses the runtime default, which varies with `LANG`
|
|
* and the ICU build. Against the real catalog names, `tr-TR` (dotted/dotless I), `lt-LT`,
|
|
* `cs-CZ` (the `ch` digraph) and `et-EE` each reorder the array, so a contributor on one of
|
|
* those locales would regenerate a different `integrations.json` and fail CI with no obvious
|
|
* cause. Every `localeCompare` in the generator must therefore name its locale as a literal;
|
|
* a bare `localeCompare()`, `localeCompare(b)` or a locale read from a variable all fall
|
|
* back to the default, so the arguments are matched whole rather than pattern-matched.
|
|
*
|
|
* A source grep is the only assertion that can catch an unpinned comparator. CI runs under
|
|
* an `en-US` default, where an unpinned `localeCompare` returns exactly what the pinned one
|
|
* does, so no behavioural comparison against real catalog names discriminates there; and
|
|
* comparing the committed `integrations.json` against the comparator that produced it agrees
|
|
* by construction whatever the comparator does. Both of those were asserted here and were
|
|
* removed for claiming a guarantee they did not hold.
|
|
*/
|
|
it('leaves no unpinned localeCompare in the generator', () => {
|
|
const source = fs.readFileSync(path.join(__dirname, 'generate-docs.ts'), 'utf-8')
|
|
|
|
const calls = [...source.matchAll(/\blocaleCompare\(([^)]*)\)/g)].map(([, args]) => args)
|
|
|
|
expect(calls.length).toBeGreaterThan(0)
|
|
for (const args of calls) expect(args).toMatch(/,\s*'[a-zA-Z-]+'\s*$/)
|
|
})
|
|
})
|
|
|
|
describe('the scanner survives regex literals in a block config', () => {
|
|
/**
|
|
* `blankStringsAndComments` used to be a single regex with no concept of a regex literal, so
|
|
* `/don't/` opened a phantom string that swallowed the following subBlocks, and a character
|
|
* class like `/[}]/` closed the enclosing object early. Both returned a short list with no
|
|
* warning — a confident wrong answer, which is the one outcome the filter must never produce.
|
|
*/
|
|
it('does not let an apostrophe inside a regex swallow later subBlocks', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
"subBlocks: [{ id: 'a', condition: (v) => /don't/.test(v) }, { id: 'b' }],"
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('does not let a brace inside a character class close the object early', () => {
|
|
const ids = extractUserSettableParamIds("subBlocks: [{ id: 'a', v: /[}]/ }, { id: 'b' }],")
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('still reads a division as arithmetic rather than a regex', () => {
|
|
const ids = extractUserSettableParamIds('subBlocks: [{ id: "a", n: total / 2 }, { id: "b" }],')
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('does not mistake a protocol slash inside a string for a comment', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
"subBlocks: [{ id: 'a', url: 'https://example.com/x' }, { id: 'b' }],"
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('reports UNKNOWN rather than guessing when a literal never terminates', () => {
|
|
expect(extractUserSettableParamIds("subBlocks: [{ id: 'a }],")).toBeNull()
|
|
})
|
|
|
|
/**
|
|
* A `/` directly after a division operator is an operand position, so it opens a regex.
|
|
* Without `'/'` in `REGEX_ALLOWED_AFTER` the third slash of `x / y / /re/` lexes as a
|
|
* second division, the character class is left in the structural view and its `}` closes
|
|
* the object early — a short list with no warning.
|
|
*/
|
|
it('reads a regex that follows a division operator', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
"subBlocks: [{ id: 'a', v: x / y / /[}]/.source }, { id: 'b' }],"
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
/**
|
|
* `'+'` and `'-'` are in `REGEX_ALLOWED_AFTER` for the binary operators, so the previous
|
|
* significant character alone reads the `/` after a postfix `i++` as opening a regex. The
|
|
* phantom regex then runs to the end of the input and the scan reports the block unreadable.
|
|
*/
|
|
it('still reads a division after a postfix increment or decrement', () => {
|
|
for (const op of ['++', '--']) {
|
|
const ids = extractUserSettableParamIds(
|
|
`subBlocks: [{ id: 'a', n: (i) => i${op} / 2 }, { id: 'b' }],`
|
|
)
|
|
|
|
expect(ids, op).toEqual(['a', 'b'])
|
|
}
|
|
})
|
|
|
|
/**
|
|
* The shape Prettier produces when a `.match()` argument does not fit on one line, as in
|
|
* `blocks/table.ts` and `blocks/table_v2.ts`. A newline is recorded as the previous
|
|
* significant character rather than skipped, so the `(` does not carry the decision — only
|
|
* the `'\n'` entry in `REGEX_ALLOWED_AFTER` keeps this lexing as a regex.
|
|
*/
|
|
it('reads a regex that a formatter has wrapped onto its own line', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
["subBlocks: [{ id: 'a', v: (s) => s.match(", ' /[}]/', ") }, { id: 'b' }],"].join('\n')
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
})
|
|
|
|
describe('the scanner reads a regex that opens in keyword position', () => {
|
|
/**
|
|
* The scanner chose regex-vs-division from the previous significant character alone, so a
|
|
* regex in operand position was lexed as a division off the keyword's last letter and its
|
|
* body was left in the structural view — a brace inside it then closed the object early.
|
|
*/
|
|
it('does not let a brace inside a regex after return close the object early', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
"subBlocks: [{ id: 'a', condition: (v) => { return /}/.test(v) } }, { id: 'b' }],"
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('treats every operand-position keyword as opening a regex', () => {
|
|
const keywords = [
|
|
'return',
|
|
'typeof',
|
|
'case',
|
|
'in',
|
|
'of',
|
|
'new',
|
|
'delete',
|
|
'void',
|
|
'instanceof',
|
|
'do',
|
|
'else',
|
|
'yield',
|
|
'await',
|
|
]
|
|
|
|
for (const keyword of keywords) {
|
|
const ids = extractUserSettableParamIds(
|
|
`subBlocks: [{ id: 'a', v: (x) => ${keyword} /}/.source }, { id: 'b' }],`
|
|
)
|
|
|
|
expect(ids, keyword).toEqual(['a', 'b'])
|
|
}
|
|
})
|
|
|
|
/**
|
|
* The fixture leaves an odd number of `/` on the line, so a mis-lexed regex runs on to the
|
|
* end of the input rather than closing on a second slash. A self-cancelling pair like
|
|
* `counts.in / 2, m: preturn / 2` passes with the guard removed, because the phantom regex
|
|
* spans only `2, m: preturn ` and blanks nothing structural.
|
|
*/
|
|
it('still reads a division after a property or an identifier that merely ends in a keyword', () => {
|
|
expect(
|
|
extractUserSettableParamIds("subBlocks: [{ id: 'a', n: counts.in / 2 }, { id: 'b' }],")
|
|
).toEqual(['a', 'b'])
|
|
expect(
|
|
extractUserSettableParamIds("subBlocks: [{ id: 'a', n: preturn / 2 }, { id: 'b' }],")
|
|
).toEqual(['a', 'b'])
|
|
})
|
|
})
|
|
|
|
describe('template interpolation is lexed rather than brace-counted', () => {
|
|
/**
|
|
* The `${}` depth counter was not string-aware, so a brace inside a quoted expression
|
|
* miscounted, the closing backtick was never found and the whole block was reported
|
|
* unreadable — which silently stops filtering resolver-derived hidden params for it.
|
|
*/
|
|
it('does not lose the closing backtick to an opening brace inside a quoted expression', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
"subBlocks: [{ id: 'a', label: `${format('{')}` }, { id: 'b' }],"
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('does not let a closing brace inside a quoted expression end the interpolation', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
'subBlocks: [{ id: \'a\', label: `${format("}") + "{"}` }, { id: \'b\' }],'
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('lexes a regex, a comment and a nested template inside the expression', () => {
|
|
const ids = extractUserSettableParamIds(
|
|
"subBlocks: [{ id: 'a', label: `${/[{]/.source /* { */ + `${'{'}`}` }, { id: 'b' }],"
|
|
)
|
|
|
|
expect(ids).toEqual(['a', 'b'])
|
|
})
|
|
})
|