mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
* feat: let the import wizard reuse an existing workspace resource The project import wizard always opened the create-resource drawer, so a workspace that already had, say, an SMTP resource still ended up with a second one. Step 4 now offers a choice: fill in a new resource as before, or pick an existing one of the same type. Picking an existing resource rewrites the deployed items to point at it and then deletes the imported stub. The rewrite covers scripts, flows, apps, raw apps and every workspace trigger kind, and holds two rules: it writes nothing unless every referrer can be rewritten, and it only touches items under the target folder. Raw apps re-upload the bundle shipped in the project export instead of rebuilding it, and the retarget refuses when the deployed sources have moved on since the import — that bundle was built from the export's sources, so re-uploading it over edited sources would revert them. Adds `update` to the trigger-kind table for the eleven kinds whose service takes a plain config body; schedule keeps its own branch because updateSchedule takes a different shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * feat: only ask about resources the project actually points at A project declares one resource per `resource-<type>` input schema as well as one per `$res:` reference, so an app that pins `f/calendly/google_calendar` for a script whose schema says `resource-gcal` ships an unreferenced `f/calendly/gcal` alongside it. Step 4 listed both and asked you to fill in each. Only the referenced ones have to hold a credential for the project to work. The rest are still created — a standalone run picks from them in the argument picker — but they no longer reach the checklist, and `resourceCount` counts the same set so the wizard does not offer a fourth step that has nothing on it. Across the twelve published hub projects this drops 9 of 19 rows, including three non-credential input shapes in `typeform`. Also fixes a miss in the retarget: a trigger holds its resource as a bare path in its own `*_resource_path` field rather than as a `$res:` token, so a token-only scan left it pointing at a stub that was then deleted. Detection now mirrors `rewriteTriggerConfig` through a shared `referencesResourcePath`, which matches the parsed structure rather than its serialization — keeping `f/proj/db` out of `$res:f/proj/db_prod` as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: refuse a resource retarget the scan or the rewriters cannot cover Uncompiled trigger features 404 on their list route; that is the instance not having the kind, not a listing that failed, so it no longer blocks every retarget on a stock build. The `listSearch*` endpoints cap server-side with no ordering and no pagination, so a full page is refused rather than read as the whole workspace. An item that names the resource path outside a `$res:` token is refused at plan time — no rewriter relocates it — and the trigger row keeps its own `script_path` so a runnable sharing the path is not repointed. A raw app whose sources the export cannot yield carries no entry at all, so the refusal its comment promises actually fires. The reused row offers text instead of a button that leads to a deleted resource. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * refactor: let an incomplete scan keep the stub instead of refusing the retarget The scan behind "nothing is written unless every referrer can be rewritten" cannot be proven complete: the listings come back capped, a trigger kind can fail to list, and a reference can sit where no rewriter reaches. Gating the whole run on that claim made every such case a refusal. Rewriting an item onto the chosen resource is safe on its own — the item resolves whether or not the stub survives — so only the delete needs the claim. `planRetarget` now answers with the referrers it can move plus the gaps it cannot account for, `applyRetarget` always moves the first set, and a gap keeps the stub rather than stopping the run. A referrer outside the project's folder is one of those gaps: the listings are workspace-wide, so it is seen for free, it stays the user's own, and its existence is why the stub stays. The outcome carries what moved and why the stub was kept, so the row settles to the chosen resource either way and says when the placeholder is still there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: preserve a retargeted item's deployed identity, and send back its own bundle Every write here edits a deployed item in place, but none of them said so. Without `preserve_on_behalf_of` the backend replaces the item's stored run identity with whoever opened the wizard, and `updatePolicy(next, undefined)` rebuilt an app's policy from nothing — dropping its sandbox rules and forcing `execution_mode: publisher`, which puts a viewer app on the publisher's identity even though the backend would otherwise have kept the deployed mode. The policy is now recomputed from the deployed one, which is what the triggerables rekeying actually needs. The raw-app bundle no longer comes from the project export. The browser can read a deployed bundle back — mint the app's public secret and fetch `/apps/get_data/v/{secret}.{ext}`, the same route the Hub publish reads — so the bundle sent back is the deployed one whoever last edited it. That removes `ExportedAppFiles`, its plumbing through the setup step, `rawSourcesDiverged`, and the two raw-app gaps: an app "edited since the import" is no longer a case that exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * perf: carry the trigger row from the scan into its write `rewriteTrigger` listed the whole kind again to find the row it had just read, once per trigger — and for schedules a listing is itself a listing plus a detail fetch per row. The scan already holds the row, so the referrer carries it. Pins two properties that nothing covered: the trigger update body leaves `enabled` out, so pointing a trigger at a credential cannot also start it; and a write that fails partway keeps the stub while reporting what had already moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: keep unfilled resources out of the reuse chooser The chooser offered every resource of the row's type except the ones this import created, so a stub left behind by an earlier import of the same project showed up as a credential to reuse. Pointing a project at another project's empty placeholder is never the answer, and nothing downstream would have complained. Candidates are now read back and the unfilled ones dropped, using the same test the checklist uses to call one of the project's own resources blank. Past a cap they are all offered rather than costing a request each: a workspace with that many resources of the outstanding types is not the case this filters for. Also drops the chooser's promise that the imported placeholder is removed. That was true when the delete was unconditional; the stub is now kept whenever the scan cannot account for everything, and the row says which happened once it has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: move a retargeted item's bundle and identity, and see the paths it spells out Four gaps between what the retarget claimed and what it did. A trigger states its run identity as `permissioned_as`, not the `on_behalf_of` the other kinds use, and the backend keeps the row's value only when `preserve_permissioned_as` says so. Without the pair, a trigger created under a folder's `default_permissioned_as` started running as whoever picked the credential. A raw app's bundle is compiled from its sources, so a `$res:` a source spells out is baked into it. The import rewrites that copy — `retargetProjectExport` runs while `/bundle.js` is still one of `files` — but the retarget fetched the deployed bundle after that split and sent it back untouched, then deleted the stub the app still read. The fetched bundle is now rewritten too, and a path it names any other way keeps the stub instead. A script's content is one string, so the whole-string match that finds a bare path in a flow or an app could not see one written inside it. `getResource("f/…")` was invisible to both the scan, which then deleted the stub under it, and the step-4 filter, which dropped the row so nobody was asked to fill it. Trigger listings cap at the server's DEFAULT_PER_PAGE, which this table does not page past. A full page is now read the way a full `listSearch*` page is: as a listing that cannot account for the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: see a path a flow or app spells out, and name why an item did not move The script scan was taught to see a resource path written inside code; flows and apps were left on the whole-string test, which cannot. A flow whose inline module runs `getResource("f/proj/db")`, or a raw app whose source does, was neither rewritten nor recorded as a gap, so the stub was deleted while the deployed item still read it. Reachable from the wizard, because the step-4 filter does see such a reference and offers the row. Both branches now use the same test as the script branch, and gap rather than rewrite: the stub survives either way, so a `$res:` token in the same item still resolves, and rewriting half an item would only make the plan and the write disagree about what moved. Each rewriter now says why it left an item alone instead of answering yes or no, so a raw-app bundle that spells the path out is reported as a reference nothing could move rather than as a concurrent edit. Also corrects the resource-listing comment — `perPage` bounds the answer, the route does not default to 30 — and asks the askable-resource question against the export as published rather than the retargeted copy, so the step and the stepper that decides whether to offer it give one answer. A path spelled out in code is not retargeted, so only the raw export has its references and its resource paths agreeing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: a kept placeholder is still something to fill in Reuse marked the row done and replaced its action with static text even when the stub survived. A kept stub is empty and is still what every item the scan could not move reads, so the step reported "You're all set" over a project running on a placeholder, with no way back to filling it. Reachable from one hub project: a raw app whose source spells the resource path out gaps everything, nothing is rewritten, and the row went green anyway. Such a row now stays outstanding, keeps its button, says which path items still read, and re-checks on refresh so filling that placeholder in closes it. Flows and apps also went back to being rewritten as well as gapped, matching what the script branch already did — the reason given for skipping them was contradicted by that branch, and a comment merely naming the path was enough to strand an item's real `$res:` token on the stub. Two things had to become precise for that to hold. What counts as rewritable is now the presence of a `$res:` token rather than any reference, since a whole string equal to the path is the unreachable case, not a movable one. And the post-rewrite check reads tokens only: a path the item also spells out is the plan's gap to record, and re-reading it at write time reported one item twice, as both unmovable and changed underfoot. Writers now skip a write that would change nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: rewrite only the tokens, and let a filled placeholder close its row The import's flow and app rewriters also remap a runnable's own path on an exact match. That is right for the folder-wide map the import hands them, where every path is moving. Here the map holds one entry, a resource path — and scripts, flows and resources share a namespace, so a project shipping both a script and a resource named `smtp` had the step calling it repointed at the credential. Triggers were already guarded against exactly this; flows, apps and raw apps were not. All three now rewrite the serialized value, which moves the tokens and leaves every path alone. A kept placeholder that the user then fills in now closes its row: `stubKept` is cleared by the read that finds it filled, so the row stops saying items still need it while showing a green check beside "You're all set". A kept-stub row's button also goes straight to filling that placeholder rather than reopening the chooser. A second retarget from there can only be a no-op — every rewritable referrer is already off the stub — and it would have relabelled the row after moving nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: check staleness where it can be seen, and stop trusting a client-side licence The post-rewrite check could no longer fail: since the rewrite became token-only it ran over exactly what the check looked for, so it read as a guard while guarding nothing. The staleness it named is real — the plan classifies items from the search listings and each write re-reads its item by path — so the check now happens on that fresh read, and looks for the spelling no rewrite reaches. A referrer the plan already recorded as unreachable skips it: the stub survives either way, and re-reporting the same item would say it was both unmovable and changed underfoot. Trigger kinds are no longer skipped by the client-side licence store. That store is empty on an EE instance whose licence is unset or whose fetch failed, while the rows are still in the database and the routes still answer — and a kind skipped that way left no gap, so the stub went while an EE trigger still pointed at it. On CE those routes are not registered and the 404 branch already says so, from the server rather than from a store. `askableResources` now pairs the export's resources with the retargeted ones by position, the way `retargetProjectExport` maps them, instead of rebuilding the path by slicing a prefix. An external path the bundle pulled in lands at `f/<folder>/<name>` with a `_2` suffix on collision, which no slicing recovers — and the row would have gone missing from a checklist the stepper still counted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: a scan the caller is not shown all of cannot clear the stub for deletion The listings the scan reads run as the caller, and row-level security filters them inside the query. For anyone but a workspace admin that means an item they cannot read is not absent from the answer so much as invisible in it: it does not appear, and it does not count towards the full-page test that catches a truncated listing either. A colleague's private script referencing the stub is exactly that shape, so the scan reported a clean sweep and the stub was deleted out from under it, with nothing said. That is the one input to the completeness proof the destructive step rests on that was never checked. A caller who is not shown the whole workspace now records a gap like any other, so the rewrite still happens in full and the placeholder stays. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: ask whether this workspace's listings are complete, not a stale record's `UserExt` is per-workspace and outlives a workspace change, which is why it carries `workspace_id`. Reading `is_admin` off it without checking which workspace it describes answers for the wrong one. Step 4 is reachable by reload — it is built to be — and nothing on that path re-fetches the record, so it still describes the workspace the user came from. An admin of their own workspace importing into a shared one they are a plain member of got a clean scan over row-level-security-filtered listings, and the stub was deleted under a referrer they were never shown. The question is now asked of the target workspace, through a predicate that can be tested. An instance superadmin bypasses the policies everywhere, so that is asked separately rather than read off the same stale record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * style: format the wizard retarget files Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: leave a trigger's runnable references alone, and read the app kind rather than guess it A trigger's `on_failure`, `on_recovery`, `on_success` and `url` name a runnable, and `rewriteTriggerConfig` remaps one on an exact match — right for the folder-wide map the import hands it, wrong for a map holding a single resource path. A schedule whose error handler ran a script sharing that path had the handler pointed at the credential instead. The same reason `path` and `script_path` were already restored; only the two prefixed shapes it remaps are, so a field holding a `$res:` token still moves. The scan guessed raw from low-code by looking for `files` and `runnables`, because `list_search_apps` returns only the path and the value. Both writers re-read the app anyway, and that record carries `raw_app`, so the write now dispatches on it. A guess wrong in either direction was a deploy the backend refuses for changing an app's kind, which aborted the run at that referrer. Also drops the past-tense clauses from four test comments. Each already states the invariant it guards; the rest described iterations of this branch that no reader will have seen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj * fix: restore a trigger's bare runnable references too The prefixed spellings were put back after the rewrite; the bare ones were not. `dynamic_skip`, `error_handler_path` and a websocket initial message's `runnable_result.path` each hold a plain script path, which `rewriteTriggerConfig` remaps on a whole-string match — so a trigger whose error handler ran a script sharing the stub's path had that handler pointed at the credential. All of them now come back from the row, taken from what `triggerHandlerRefs` reads rather than enumerated by hand. A prefixed field is still restored only when it holds the runnable spelling, so a `$res:` token in one still moves; a bare field is a path and nothing else, so it is always restored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fuzkt6NqsqzvSYSVKpR3pj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
915 lines
31 KiB
TypeScript
915 lines
31 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
classifyPath,
|
|
extractScriptRefs,
|
|
extractFlowRefs,
|
|
extractAppRefs,
|
|
buildPathMap,
|
|
rewriteContent,
|
|
rewriteTriggerConfig,
|
|
rewriteFlowValue,
|
|
rewriteAppValue,
|
|
extractRawAppRefs,
|
|
rewriteRawAppContent,
|
|
buildProjectBundle,
|
|
retargetProjectExport,
|
|
collectExportVarPaths,
|
|
extractTriggerConfigResourceRefs,
|
|
extractVarRefsFromValue,
|
|
projectReferencesResource,
|
|
textHoldsBarePath,
|
|
type ProjectExport,
|
|
type FetchedItem,
|
|
type ItemRef
|
|
} from './projectBundle'
|
|
|
|
describe('classifyPath', () => {
|
|
it('internal for paths under the project folder', () => {
|
|
expect(classifyPath('f/proj/db', 'proj')).toBe('internal')
|
|
expect(classifyPath('f/proj', 'proj')).toBe('internal')
|
|
})
|
|
it('hub for hub paths', () => {
|
|
expect(classifyPath('hub/16043/discord/send', 'proj')).toBe('hub')
|
|
})
|
|
it('external for user and other folders', () => {
|
|
expect(classifyPath('u/admin/db', 'proj')).toBe('external')
|
|
expect(classifyPath('f/other/db', 'proj')).toBe('external')
|
|
})
|
|
it('does not treat a prefix-only match as internal', () => {
|
|
expect(classifyPath('f/project2/db', 'proj')).toBe('external')
|
|
})
|
|
})
|
|
|
|
describe('extractScriptRefs', () => {
|
|
it('finds $res: and res:// resource refs, deduped', () => {
|
|
const c = `const a = "$res:u/admin/db"; const b = "res://f/x/api"; const c2 = "$res:u/admin/db"`
|
|
expect(extractScriptRefs(c)).toEqual([
|
|
{ kind: 'resource', path: 'u/admin/db' },
|
|
{ kind: 'resource', path: 'f/x/api' }
|
|
])
|
|
})
|
|
it('returns nothing when no refs', () => {
|
|
expect(extractScriptRefs('export async function main() {}')).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('extractFlowRefs', () => {
|
|
it('finds inline-code, static-input, and script-path refs', () => {
|
|
const value = {
|
|
modules: [
|
|
{
|
|
id: 'a',
|
|
value: {
|
|
type: 'rawscript',
|
|
content: 'const db = "$res:u/admin/pg"',
|
|
input_transforms: {
|
|
other: { type: 'static', value: '$res:f/shared/api' },
|
|
expr1: { type: 'javascript', expr: 'flow_input.x' }
|
|
}
|
|
}
|
|
},
|
|
{
|
|
id: 'b',
|
|
value: {
|
|
type: 'branchone',
|
|
branches: [
|
|
{
|
|
modules: [
|
|
{ id: 'c', value: { type: 'script', path: 'u/admin/my_script' } },
|
|
{ id: 'd', value: { type: 'script', path: 'hub/123/x/y' } }
|
|
]
|
|
}
|
|
],
|
|
default: [{ id: 'e', value: { type: 'rawscript', content: 'no refs' } }]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
const refs = extractFlowRefs(value)
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'f/shared/api' })
|
|
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/my_script' })
|
|
expect(refs).toContainEqual({ kind: 'script', path: 'hub/123/x/y' })
|
|
// a javascript expr (flow_input) is not a hardcoded ref
|
|
expect(refs.filter((r) => r.path === 'flow_input.x')).toEqual([])
|
|
})
|
|
it('finds sub-flow refs from type: flow steps', () => {
|
|
const value = {
|
|
modules: [
|
|
{ id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } },
|
|
{ id: 'b', value: { type: 'flow', path: 'hub/9/x/y' } }
|
|
]
|
|
}
|
|
const refs = extractFlowRefs(value)
|
|
expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sub_flow' })
|
|
expect(refs).toContainEqual({ kind: 'flow', path: 'hub/9/x/y' })
|
|
})
|
|
})
|
|
|
|
describe('buildPathMap', () => {
|
|
it('reparents into the project folder keeping the leaf name', () => {
|
|
const m = buildPathMap(['u/admin/db', 'f/other/api'], 'proj')
|
|
expect(m.get('u/admin/db')).toBe('f/proj/db')
|
|
expect(m.get('f/other/api')).toBe('f/proj/api')
|
|
})
|
|
it('suffixes collisions deterministically', () => {
|
|
const m = buildPathMap(['u/alice/db', 'f/shared/db', 'u/bob/db'], 'proj')
|
|
// sorted: f/shared/db, u/alice/db, u/bob/db
|
|
expect(m.get('f/shared/db')).toBe('f/proj/db')
|
|
expect(m.get('u/alice/db')).toBe('f/proj/db_2')
|
|
expect(m.get('u/bob/db')).toBe('f/proj/db_3')
|
|
})
|
|
it('maps internal paths to themselves, preserving subfolder depth', () => {
|
|
const m = buildPathMap(['f/proj/api', 'f/proj/sub/deep/script'], 'proj')
|
|
expect(m.get('f/proj/api')).toBe('f/proj/api')
|
|
expect(m.get('f/proj/sub/deep/script')).toBe('f/proj/sub/deep/script')
|
|
})
|
|
it('does not flatten two internal items sharing a leaf name', () => {
|
|
const m = buildPathMap(['f/proj/a/x', 'f/proj/b/x'], 'proj')
|
|
expect(m.get('f/proj/a/x')).toBe('f/proj/a/x')
|
|
expect(m.get('f/proj/b/x')).toBe('f/proj/b/x')
|
|
})
|
|
it('relocates an external onto a suffix when its leaf collides with an internal path', () => {
|
|
const m = buildPathMap(['f/proj/db', 'u/admin/db'], 'proj')
|
|
expect(m.get('f/proj/db')).toBe('f/proj/db')
|
|
expect(m.get('u/admin/db')).toBe('f/proj/db_2')
|
|
})
|
|
})
|
|
|
|
describe('rewriteContent', () => {
|
|
it('rewrites mapped refs and leaves unmapped ones', () => {
|
|
const map = new Map([['u/admin/db', 'f/proj/db']])
|
|
expect(rewriteContent('x = "$res:u/admin/db"', map)).toBe('x = "$res:f/proj/db"')
|
|
expect(rewriteContent('x = "res://u/admin/db"', map)).toBe('x = "$res:f/proj/db"')
|
|
expect(rewriteContent('x = "$res:hub/1/a/b"', map)).toBe('x = "$res:hub/1/a/b"')
|
|
})
|
|
it('does not partial-match a longer path', () => {
|
|
const map = new Map([['u/admin/db', 'f/proj/db']])
|
|
// u/admin/db2 must not be rewritten by the u/admin/db entry
|
|
expect(rewriteContent('x = "$res:u/admin/db2"', map)).toBe('x = "$res:u/admin/db2"')
|
|
})
|
|
})
|
|
|
|
describe('rewriteTriggerConfig', () => {
|
|
const map = new Map([
|
|
['f/proj/kafka', 'f/target/kafka'],
|
|
['f/proj/script', 'f/target/script']
|
|
])
|
|
it('remaps plain resource path fields', () => {
|
|
expect(
|
|
rewriteTriggerConfig({ kafka_resource_path: 'f/proj/kafka', group_id: 'g1' }, map)
|
|
).toEqual({ kafka_resource_path: 'f/target/kafka', group_id: 'g1' })
|
|
})
|
|
it('remaps nested objects, arrays, and $res: tokens', () => {
|
|
expect(
|
|
rewriteTriggerConfig(
|
|
{
|
|
nested: { path: 'f/proj/script' },
|
|
list: ['f/proj/kafka', 'unrelated'],
|
|
code: 'x = "$res:f/proj/kafka"'
|
|
},
|
|
map
|
|
)
|
|
).toEqual({
|
|
nested: { path: 'f/target/script' },
|
|
list: ['f/target/kafka', 'unrelated'],
|
|
code: 'x = "$res:f/target/kafka"'
|
|
})
|
|
})
|
|
it('leaves non-matching strings and non-string values untouched', () => {
|
|
const config = { url: 'wss://example.com', port: 9092, enabled: true, extra: null }
|
|
expect(rewriteTriggerConfig(config, map)).toEqual(config)
|
|
})
|
|
})
|
|
|
|
describe('rewriteFlowValue', () => {
|
|
it('rewrites inline code, static inputs, and script paths; clones input', () => {
|
|
const map = new Map([
|
|
['u/admin/pg', 'f/proj/pg'],
|
|
['f/shared/api', 'f/proj/api'],
|
|
['u/admin/my_script', 'f/proj/my_script']
|
|
])
|
|
const value = {
|
|
modules: [
|
|
{
|
|
id: 'a',
|
|
value: {
|
|
type: 'rawscript',
|
|
content: 'const db = "$res:u/admin/pg"',
|
|
input_transforms: { other: { type: 'static', value: '$res:f/shared/api' } }
|
|
}
|
|
},
|
|
{ id: 'b', value: { type: 'script', path: 'u/admin/my_script' } },
|
|
{ id: 'c', value: { type: 'script', path: 'hub/1/keep/me' } }
|
|
]
|
|
}
|
|
const out = rewriteFlowValue(value, map)
|
|
expect(out.modules[0].value.content).toBe('const db = "$res:f/proj/pg"')
|
|
expect(out.modules[0].value.input_transforms.other.value).toBe('$res:f/proj/api')
|
|
expect(out.modules[1].value.path).toBe('f/proj/my_script')
|
|
expect(out.modules[2].value.path).toBe('hub/1/keep/me')
|
|
// original untouched (deep clone)
|
|
expect(value.modules[0].value.content).toBe('const db = "$res:u/admin/pg"')
|
|
})
|
|
})
|
|
|
|
// A trimmed app value: a runnable-by-path component, a hub runnable, a $res in an
|
|
// inline script, and incidental `f/...` text that must NOT be rewritten.
|
|
const appValue = () => ({
|
|
grid: [
|
|
{
|
|
data: {
|
|
componentInput: {
|
|
runnable: { type: 'runnableByPath', runType: 'script', path: 'u/admin/charts' }
|
|
}
|
|
}
|
|
},
|
|
{
|
|
data: {
|
|
componentInput: {
|
|
runnable: { type: 'runnableByPath', runType: 'flow', path: 'f/shared/sync' }
|
|
}
|
|
}
|
|
},
|
|
{
|
|
data: {
|
|
componentInput: {
|
|
runnable: { type: 'runnableByPath', runType: 'hubscript', path: 'hub/1/keep' }
|
|
}
|
|
}
|
|
}
|
|
],
|
|
hiddenInlineScripts: [
|
|
{ name: 'h', inlineScript: { content: 'x = "$res:u/admin/pg"', language: 'deno' } }
|
|
],
|
|
someLabel: 'see docs at f/shared/sync for details'
|
|
})
|
|
|
|
describe('extractAppRefs', () => {
|
|
it('extracts runnable-by-path scripts/flows and $res resources, skips hub', () => {
|
|
const refs = extractAppRefs(appValue())
|
|
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/charts' })
|
|
expect(refs).toContainEqual({ kind: 'flow', path: 'f/shared/sync' })
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
|
expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('rewriteAppValue', () => {
|
|
it('relocates runnable paths and $res, leaves hub refs and incidental text intact', () => {
|
|
const map = new Map([
|
|
['u/admin/charts', 'f/proj/charts'],
|
|
['f/shared/sync', 'f/proj/sync'],
|
|
['u/admin/pg', 'f/proj/pg']
|
|
])
|
|
const value = appValue()
|
|
const out = rewriteAppValue(value, map)
|
|
expect(out.grid[0].data.componentInput.runnable.path).toBe('f/proj/charts')
|
|
expect(out.grid[1].data.componentInput.runnable.path).toBe('f/proj/sync')
|
|
expect(out.grid[2].data.componentInput.runnable.path).toBe('hub/1/keep')
|
|
expect(out.hiddenInlineScripts[0].inlineScript.content).toBe('x = "$res:f/proj/pg"')
|
|
// incidental text untouched
|
|
expect(out.someLabel).toBe('see docs at f/shared/sync for details')
|
|
// original untouched (deep clone)
|
|
expect(value.grid[0].data.componentInput.runnable.path).toBe('u/admin/charts')
|
|
})
|
|
})
|
|
|
|
describe('raw app (value.raw JSON string)', () => {
|
|
const rawContent = () =>
|
|
JSON.stringify({
|
|
runnables: {
|
|
a: { type: 'path', runType: 'flow', path: 'u/admin/sync' },
|
|
b: { type: 'path', runType: 'script', path: 'f/shared/calc' },
|
|
c: { type: 'path', runType: 'hubscript', path: 'hub/1/keep' }
|
|
},
|
|
files: { '/bundle.js': 'const conn = "$res:u/admin/pg"' }
|
|
})
|
|
|
|
it('extractRawAppRefs sees nested runnables and $res, skips hub', () => {
|
|
const refs = extractRawAppRefs(rawContent())
|
|
expect(refs).toContainEqual({ kind: 'flow', path: 'u/admin/sync' })
|
|
expect(refs).toContainEqual({ kind: 'script', path: 'f/shared/calc' })
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
|
expect(refs.some((r) => r.path === 'hub/1/keep')).toBe(false)
|
|
})
|
|
|
|
it('rewriteRawAppContent relocates nested runnable paths and $res', () => {
|
|
const map = new Map([
|
|
['u/admin/sync', 'f/proj/sync'],
|
|
['f/shared/calc', 'f/proj/calc'],
|
|
['u/admin/pg', 'f/proj/pg']
|
|
])
|
|
const out = JSON.parse(rewriteRawAppContent(rawContent(), map))
|
|
expect(out.runnables.a.path).toBe('f/proj/sync')
|
|
expect(out.runnables.b.path).toBe('f/proj/calc')
|
|
expect(out.runnables.c.path).toBe('hub/1/keep')
|
|
expect(out.files['/bundle.js']).toBe('const conn = "$res:f/proj/pg"')
|
|
})
|
|
|
|
it('falls back to $res scan on non-JSON content', () => {
|
|
expect(extractRawAppRefs('x = "$res:u/admin/pg"')).toContainEqual({
|
|
kind: 'resource',
|
|
path: 'u/admin/pg'
|
|
})
|
|
expect(
|
|
rewriteRawAppContent('x = "$res:u/admin/pg"', new Map([['u/admin/pg', 'f/proj/pg']]))
|
|
).toBe('x = "$res:f/proj/pg"')
|
|
})
|
|
})
|
|
|
|
describe('buildProjectBundle', () => {
|
|
// A flow that calls an external script which itself hardcodes a resource.
|
|
const flow: FetchedItem = {
|
|
kind: 'flow',
|
|
path: 'u/admin/my_flow',
|
|
summary: 'Flow',
|
|
value: {
|
|
modules: [
|
|
{ id: 'a', value: { type: 'script', path: 'u/admin/helper' } },
|
|
{
|
|
id: 'b',
|
|
value: {
|
|
type: 'rawscript',
|
|
content: 'const x = "$res:f/shared/api"',
|
|
input_transforms: {}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
const helper: FetchedItem = {
|
|
kind: 'script',
|
|
path: 'u/admin/helper',
|
|
summary: 'Helper',
|
|
language: 'bun',
|
|
content: 'const db = "$res:u/admin/pg"; export async function main(){}'
|
|
}
|
|
|
|
const deps = {
|
|
fetchItem: async (ref: ItemRef) => {
|
|
if (ref.path === 'u/admin/my_flow') return flow
|
|
if (ref.path === 'u/admin/helper') return helper
|
|
return undefined
|
|
},
|
|
resolveResourceType: async (path: string) => {
|
|
if (path === 'u/admin/pg') return 'postgresql'
|
|
if (path === 'f/shared/api') return 'http_api'
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
it('pulls in referenced scripts + resources and rewrites everything under the folder', async () => {
|
|
const bundle = await buildProjectBundle(
|
|
[{ kind: 'flow', path: 'u/admin/my_flow' }],
|
|
'proj',
|
|
deps
|
|
)
|
|
|
|
// flow + transitively-pulled helper script are both bundled
|
|
const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i]))
|
|
expect(Object.keys(byPath).sort()).toEqual(['u/admin/helper', 'u/admin/my_flow'])
|
|
|
|
// items relocated under f/proj/
|
|
expect(byPath['u/admin/my_flow'].newPath).toBe('f/proj/my_flow')
|
|
expect(byPath['u/admin/helper'].newPath).toBe('f/proj/helper')
|
|
|
|
// flow's script-path ref rewritten to the helper's new path
|
|
expect(byPath['u/admin/my_flow'].value.modules[0].value.path).toBe('f/proj/helper')
|
|
// flow inline + helper code resource refs rewritten
|
|
expect(byPath['u/admin/my_flow'].value.modules[1].value.content).toBe(
|
|
'const x = "$res:f/proj/api"'
|
|
)
|
|
expect(byPath['u/admin/helper'].content).toContain('"$res:f/proj/pg"')
|
|
|
|
// resource stubs created at new paths with resolved types
|
|
const stubs = Object.fromEntries(bundle.resourceStubs.map((s) => [s.originalPath, s]))
|
|
expect(stubs['u/admin/pg'].newPath).toBe('f/proj/pg')
|
|
expect(stubs['u/admin/pg'].resource_type).toBe('postgresql')
|
|
expect(stubs['f/shared/api'].resource_type).toBe('http_api')
|
|
|
|
expect(bundle.unresolved).toEqual([])
|
|
})
|
|
|
|
it('pulls in a sub-flow referenced by a type: flow step and rewrites its path', async () => {
|
|
const parent: FetchedItem = {
|
|
kind: 'flow',
|
|
path: 'u/admin/parent_flow',
|
|
value: { modules: [{ id: 'a', value: { type: 'flow', path: 'u/admin/sub_flow' } }] }
|
|
}
|
|
const sub: FetchedItem = {
|
|
kind: 'flow',
|
|
path: 'u/admin/sub_flow',
|
|
value: {
|
|
modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/keep/me' } }]
|
|
}
|
|
}
|
|
const d = {
|
|
fetchItem: async (ref: ItemRef) => {
|
|
if (ref.path === 'u/admin/parent_flow') return parent
|
|
if (ref.path === 'u/admin/sub_flow') return sub
|
|
return undefined
|
|
},
|
|
resolveResourceType: async () => undefined
|
|
}
|
|
const bundle = await buildProjectBundle(
|
|
[{ kind: 'flow', path: 'u/admin/parent_flow' }],
|
|
'proj',
|
|
d
|
|
)
|
|
const byPath = Object.fromEntries(bundle.items.map((i) => [i.path, i]))
|
|
// both flows bundled
|
|
expect(Object.keys(byPath).sort()).toEqual(['u/admin/parent_flow', 'u/admin/sub_flow'])
|
|
// parent's type: flow ref rewritten to the sub-flow's new path
|
|
expect(byPath['u/admin/parent_flow'].value.modules[0].value.path).toBe('f/proj/sub_flow')
|
|
expect(byPath['u/admin/sub_flow'].newPath).toBe('f/proj/sub_flow')
|
|
// hub ref inside the sub-flow left untouched
|
|
expect(byPath['u/admin/sub_flow'].value.modules[0].value.path).toBe('hub/1/keep/me')
|
|
expect(bundle.unresolved).toEqual([])
|
|
})
|
|
|
|
it('leaves hub script references untouched and does not fetch them', async () => {
|
|
const hubFlow: FetchedItem = {
|
|
kind: 'flow',
|
|
path: 'u/admin/hub_flow',
|
|
value: { modules: [{ id: 'a', value: { type: 'script', path: 'hub/1/x/y' } }] }
|
|
}
|
|
const d = {
|
|
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/hub_flow' ? hubFlow : undefined),
|
|
resolveResourceType: async () => undefined
|
|
}
|
|
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/hub_flow' }], 'proj', d)
|
|
expect(bundle.items.map((i) => i.path)).toEqual(['u/admin/hub_flow'])
|
|
expect(bundle.items[0].value.modules[0].value.path).toBe('hub/1/x/y')
|
|
expect(bundle.unresolved).toEqual([])
|
|
})
|
|
|
|
it('reports a missing item and an unresolvable resource as unresolved', async () => {
|
|
const root: FetchedItem = {
|
|
kind: 'flow',
|
|
path: 'u/admin/root',
|
|
value: {
|
|
modules: [
|
|
{ id: 'a', value: { type: 'script', path: 'u/admin/gone' } },
|
|
{
|
|
id: 'b',
|
|
value: {
|
|
type: 'rawscript',
|
|
content: 'const x = "$res:u/admin/untyped"',
|
|
input_transforms: {}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
const d = {
|
|
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined),
|
|
resolveResourceType: async () => undefined
|
|
}
|
|
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d)
|
|
expect(bundle.unresolved.sort()).toEqual(['u/admin/gone', 'u/admin/untyped'])
|
|
})
|
|
|
|
it('relocates $var:/$jsonvar: refs into the slug when it differs from the source folder', async () => {
|
|
const flow: FetchedItem = {
|
|
kind: 'flow',
|
|
path: 'f/source_folder/main',
|
|
value: {
|
|
flow_env: { CFG: '$jsonvar:f/source_folder/cfg' },
|
|
modules: [
|
|
{
|
|
id: 'a',
|
|
value: {
|
|
type: 'rawscript',
|
|
// Whole-value ref is relocated; the inline literal is not.
|
|
content: 'return "$var:f/source_folder/key"',
|
|
input_transforms: { k: { type: 'static', value: '$var:f/source_folder/key' } }
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
const d = {
|
|
fetchItem: async (ref: ItemRef) => (ref.path === 'f/source_folder/main' ? flow : undefined),
|
|
resolveResourceType: async () => undefined
|
|
}
|
|
const bundle = await buildProjectBundle(
|
|
[{ kind: 'flow', path: 'f/source_folder/main' }],
|
|
'kit',
|
|
d
|
|
)
|
|
const v = bundle.items[0].value
|
|
expect(v.modules[0].value.input_transforms.k.value).toBe('$var:f/kit/key')
|
|
expect(v.flow_env.CFG).toBe('$jsonvar:f/kit/cfg')
|
|
// Inline code literal is untouched.
|
|
expect(v.modules[0].value.content).toBe('return "$var:f/source_folder/key"')
|
|
})
|
|
|
|
it('dedupes a path missing as both a script and a flow', async () => {
|
|
// A missing script + flow sharing a path each push the bare path once; the
|
|
// list must stay unique so a keyed UI render of it can't collide.
|
|
const root: FetchedItem = {
|
|
kind: 'flow',
|
|
path: 'u/admin/root',
|
|
value: {
|
|
modules: [
|
|
{ id: 'a', value: { type: 'script', path: 'u/admin/dup' } },
|
|
{ id: 'b', value: { type: 'flow', path: 'u/admin/dup' } }
|
|
]
|
|
}
|
|
}
|
|
const d = {
|
|
fetchItem: async (ref: ItemRef) => (ref.path === 'u/admin/root' ? root : undefined),
|
|
resolveResourceType: async () => undefined
|
|
}
|
|
const bundle = await buildProjectBundle([{ kind: 'flow', path: 'u/admin/root' }], 'proj', d)
|
|
expect(bundle.unresolved).toEqual(['u/admin/dup'])
|
|
})
|
|
})
|
|
|
|
describe('extractVarRefsFromValue', () => {
|
|
it('collects whole-value `$var:`/`$jsonvar:` refs, deduped, walking nested JSON', () => {
|
|
const value = {
|
|
flow_env: { API: '$var:u/admin/key' },
|
|
modules: [
|
|
{ value: { input_transforms: { a: { type: 'static', value: '$var:f/proj/token' } } } },
|
|
{ value: { input_transforms: { b: { type: 'static', value: '$jsonvar:u/admin/cfg' } } } },
|
|
{ value: { input_transforms: { c: { type: 'static', value: '$var:u/admin/key' } } } }
|
|
]
|
|
}
|
|
expect(extractVarRefsFromValue(value).sort()).toEqual([
|
|
'f/proj/token',
|
|
'u/admin/cfg',
|
|
'u/admin/key'
|
|
])
|
|
})
|
|
it('ignores a `$var:` token embedded in inline code (not a whole value)', () => {
|
|
// The worker only substitutes a value that *is* the reference, so an inline
|
|
// script literal must not be treated as a variable arg.
|
|
const value = {
|
|
modules: [{ value: { type: 'rawscript', content: 'return "$var:u/example/template"' } }]
|
|
}
|
|
expect(extractVarRefsFromValue(value)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('retargetProjectExport', () => {
|
|
const baseExport = (): ProjectExport => ({
|
|
project: { slug: 'proj', name: 'Proj', summary: '', readme: null },
|
|
scripts: [
|
|
{
|
|
path: 'f/proj/hello',
|
|
content: 'const r = "$res:f/proj/db"',
|
|
summary: 'hello'
|
|
}
|
|
],
|
|
flows: [
|
|
{
|
|
path: 'f/proj/main_flow',
|
|
value: {
|
|
modules: [
|
|
{ id: 'a', value: { type: 'script', path: 'f/proj/hello', input_transforms: {} } }
|
|
]
|
|
}
|
|
}
|
|
],
|
|
apps: [
|
|
{
|
|
path: 'f/proj/dashboard',
|
|
value: { grid: [{ data: { componentInput: { runnable: {} } } }] }
|
|
},
|
|
{
|
|
path: 'f/proj/rawapp',
|
|
app_type: 'raw',
|
|
value: { raw: JSON.stringify({ files: {}, runnables: {} }) }
|
|
}
|
|
],
|
|
resources: [{ path: 'f/proj/db', resource_type: 'postgresql' }],
|
|
triggers: [
|
|
{
|
|
path: 'f/proj/every_day',
|
|
kind: 'schedule',
|
|
runnable_path: 'f/proj/hello',
|
|
runnable_kind: 'script',
|
|
config: { schedule: '0 0 12 * * *' }
|
|
},
|
|
{
|
|
path: 'f/proj/kafka_in',
|
|
kind: 'kafka',
|
|
runnable_path: 'f/proj/hello',
|
|
runnable_kind: 'script',
|
|
config: { kafka_resource_path: 'f/proj/db' }
|
|
}
|
|
]
|
|
})
|
|
|
|
it('returns the bundle unchanged when the folder matches the slug', () => {
|
|
const bundle = baseExport()
|
|
expect(retargetProjectExport(bundle, 'proj', 'proj')).toBe(bundle)
|
|
})
|
|
|
|
it('relocates every item path and internal reference into the target folder', () => {
|
|
const out = retargetProjectExport(baseExport(), 'proj', 'dest')
|
|
expect(out.scripts[0].path).toBe('f/dest/hello')
|
|
expect(out.scripts[0].content).toContain('$res:f/dest/db')
|
|
expect(out.flows[0].path).toBe('f/dest/main_flow')
|
|
expect(out.flows[0].value.modules[0].value.path).toBe('f/dest/hello')
|
|
expect(out.apps.map((a) => a.path)).toEqual(['f/dest/dashboard', 'f/dest/rawapp'])
|
|
expect(out.resources[0].path).toBe('f/dest/db')
|
|
expect(out.triggers[0].path).toBe('f/dest/every_day')
|
|
expect(out.triggers[0].runnable_path).toBe('f/dest/hello')
|
|
// Plain-string resource path in a trigger config is remapped too.
|
|
expect(out.triggers[1].config.kafka_resource_path).toBe('f/dest/db')
|
|
})
|
|
|
|
it('leaves external and hub paths untouched', () => {
|
|
const bundle = baseExport()
|
|
bundle.scripts[0].content = 'const a = "$res:u/admin/db"; const b = "$res:hub/1/x"'
|
|
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
|
expect(out.scripts[0].content).toContain('$res:u/admin/db')
|
|
expect(out.scripts[0].content).toContain('$res:hub/1/x')
|
|
})
|
|
|
|
it('retargets internal $var:/$jsonvar: refs but leaves external ones', () => {
|
|
const bundle = baseExport()
|
|
bundle.flows[0].value.modules[0].value.input_transforms = {
|
|
key: { type: 'static', value: '$var:f/proj/api_key' },
|
|
ext: { type: 'static', value: '$var:u/admin/personal' }
|
|
}
|
|
bundle.flows[0].value.flow_env = { CFG: '$jsonvar:f/proj/cfg' }
|
|
bundle.triggers[1].config.queue_url = '$var:f/proj/sqs'
|
|
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
|
const it = out.flows[0].value.modules[0].value.input_transforms
|
|
expect(it.key.value).toBe('$var:f/dest/api_key')
|
|
expect(it.ext.value).toBe('$var:u/admin/personal')
|
|
expect(out.flows[0].value.flow_env.CFG).toBe('$jsonvar:f/dest/cfg')
|
|
expect(out.triggers[1].config.queue_url).toBe('$var:f/dest/sqs')
|
|
})
|
|
|
|
it('leaves an inert $var: literal embedded in inline code unchanged', () => {
|
|
const bundle = baseExport()
|
|
// Same path as a real runtime ref, but here it is a literal inside code: it
|
|
// must not be rewritten even once the path enters the retarget map.
|
|
bundle.flows[0].value.modules[0].value = {
|
|
type: 'rawscript',
|
|
content: 'return "$var:f/proj/api_key"',
|
|
input_transforms: { real: { type: 'static', value: '$var:f/proj/api_key' } }
|
|
}
|
|
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
|
const mod = out.flows[0].value.modules[0].value
|
|
expect(mod.content).toBe('return "$var:f/proj/api_key"')
|
|
expect(mod.input_transforms.real.value).toBe('$var:f/dest/api_key')
|
|
})
|
|
})
|
|
|
|
describe('collectExportVarPaths', () => {
|
|
it('gathers variable refs from flows, apps, and triggers (deduped)', () => {
|
|
const bundle: ProjectExport = {
|
|
project: { slug: 'proj', name: 'P', summary: '', readme: null },
|
|
scripts: [],
|
|
flows: [{ path: 'f/proj/f', value: { flow_env: { A: '$var:f/proj/a' }, modules: [] } }],
|
|
apps: [
|
|
{
|
|
path: 'f/proj/raw',
|
|
app_type: 'raw',
|
|
value: { raw: JSON.stringify({ runnables: { r: { fields: { x: '$var:u/admin/b' } } } }) }
|
|
}
|
|
],
|
|
triggers: [{ path: 'f/proj/t', kind: 'sqs', config: { queue_url: '$jsonvar:f/proj/a' } }],
|
|
resources: []
|
|
}
|
|
expect(collectExportVarPaths(bundle).sort()).toEqual(['f/proj/a', 'u/admin/b'])
|
|
})
|
|
})
|
|
|
|
describe('trigger handler relocation', () => {
|
|
it('rewriteTriggerConfig remaps script/- and flow/-prefixed handler refs', () => {
|
|
const map = new Map([
|
|
['u/admin/handler', 'f/proj/handler'],
|
|
['u/admin/recovery_flow', 'f/proj/recovery_flow']
|
|
])
|
|
const out = rewriteTriggerConfig(
|
|
{
|
|
error_handler_path: 'u/admin/handler',
|
|
on_failure: 'script/u/admin/handler',
|
|
on_recovery: 'flow/u/admin/recovery_flow',
|
|
on_success: 'script/u/admin/unmapped'
|
|
},
|
|
map
|
|
)
|
|
expect(out.error_handler_path).toBe('f/proj/handler')
|
|
expect(out.on_failure).toBe('script/f/proj/handler')
|
|
expect(out.on_recovery).toBe('flow/f/proj/recovery_flow')
|
|
expect(out.on_success).toBe('script/u/admin/unmapped')
|
|
})
|
|
|
|
it('remaps $script:/$flow: only in the url field, never in literal payloads', () => {
|
|
const map = new Map([['u/admin/builder', 'f/proj/builder']])
|
|
const out = rewriteTriggerConfig(
|
|
{
|
|
url: '$script:u/admin/builder',
|
|
initial_messages: [{ raw_message: '$script:u/admin/builder' }]
|
|
},
|
|
map
|
|
)
|
|
expect(out.url).toBe('$script:f/proj/builder')
|
|
expect(out.initial_messages[0].raw_message).toBe('$script:u/admin/builder')
|
|
})
|
|
|
|
it('leaves literal handler-shaped strings in args untouched', () => {
|
|
const map = new Map([['f/proj/handler', 'f/dest/handler']])
|
|
const out = rewriteTriggerConfig(
|
|
{
|
|
on_failure: 'script/f/proj/handler',
|
|
args: { note: 'script/f/proj/handler' }
|
|
},
|
|
map
|
|
)
|
|
expect(out.on_failure).toBe('script/f/dest/handler')
|
|
expect(out.args.note).toBe('script/f/proj/handler')
|
|
})
|
|
|
|
it('leaves nested url keys untouched, rewriting only the top-level websocket url', () => {
|
|
const map = new Map([['u/admin/builder', 'f/proj/builder']])
|
|
const out = rewriteTriggerConfig(
|
|
{
|
|
url: '$script:u/admin/builder',
|
|
args: { url: '$script:u/admin/builder' }
|
|
},
|
|
map
|
|
)
|
|
expect(out.url).toBe('$script:f/proj/builder')
|
|
expect(out.args.url).toBe('$script:u/admin/builder')
|
|
})
|
|
|
|
it('extracts and relocates $res refs nested in static input transform JSON', () => {
|
|
const value = {
|
|
modules: [
|
|
{
|
|
id: 'a',
|
|
value: {
|
|
type: 'script',
|
|
path: 'f/proj/step',
|
|
input_transforms: {
|
|
provider: { type: 'static', value: { resource: '$res:u/admin/openai' } },
|
|
note: { type: 'static', value: 'plain text' }
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
const refs = extractFlowRefs(value)
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/openai' })
|
|
const out = rewriteFlowValue(value, new Map([['u/admin/openai', 'f/proj/openai']]))
|
|
const it0 = out.modules[0].value.input_transforms
|
|
expect(it0.provider.value).toEqual({ resource: '$res:f/proj/openai' })
|
|
expect(typeof it0.note.value).toBe('string')
|
|
})
|
|
|
|
it('retargetProjectExport remaps trigger error handlers with the bundle', () => {
|
|
const bundle: ProjectExport = {
|
|
project: { slug: 'proj', name: 'P', summary: '', readme: null },
|
|
scripts: [{ path: 'f/proj/handler', content: '' }],
|
|
flows: [],
|
|
apps: [],
|
|
resources: [],
|
|
triggers: [
|
|
{
|
|
path: 'f/proj/sched',
|
|
kind: 'schedule',
|
|
runnable_path: 'f/proj/handler',
|
|
runnable_kind: 'script',
|
|
config: { schedule: '0 0 * * * *', on_failure: 'script/f/proj/handler' }
|
|
},
|
|
{
|
|
path: 'f/proj/mq',
|
|
kind: 'mqtt',
|
|
runnable_path: 'f/proj/handler',
|
|
runnable_kind: 'script',
|
|
config: { error_handler_path: 'f/proj/handler' }
|
|
}
|
|
]
|
|
}
|
|
const out = retargetProjectExport(bundle, 'proj', 'dest')
|
|
expect(out.triggers[0].config.on_failure).toBe('script/f/dest/handler')
|
|
expect(out.triggers[1].config.error_handler_path).toBe('f/dest/handler')
|
|
})
|
|
})
|
|
|
|
describe('extractTriggerConfigResourceRefs', () => {
|
|
it('collects $res: tokens nested anywhere in a trigger config', () => {
|
|
expect(
|
|
extractTriggerConfigResourceRefs({
|
|
schedule: '0 0 * * * *',
|
|
args: { channel: '$res:u/admin/slack' },
|
|
on_failure_extra_args: { db: 'res://f/other/pg' },
|
|
error_handler_args: { nested: { deep: '$res:u/admin/slack' } }
|
|
})
|
|
).toEqual(['u/admin/slack', 'f/other/pg'])
|
|
})
|
|
})
|
|
|
|
describe('flow_env and preprocessor_module', () => {
|
|
const flowValue = {
|
|
modules: [],
|
|
preprocessor_module: {
|
|
id: 'pre',
|
|
value: { type: 'script', path: 'u/admin/preproc', input_transforms: {} }
|
|
},
|
|
flow_env: { SLACK: '$res:u/admin/slack', PLAIN: 'not-a-ref' }
|
|
}
|
|
|
|
it('walks nested children of the failure module', () => {
|
|
const refs = extractFlowRefs({
|
|
modules: [],
|
|
failure_module: {
|
|
id: 'failure',
|
|
value: {
|
|
type: 'forloopflow',
|
|
modules: [
|
|
{ id: 'f-a', value: { type: 'script', path: 'u/admin/cleanup', input_transforms: {} } }
|
|
]
|
|
}
|
|
}
|
|
})
|
|
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/cleanup' })
|
|
})
|
|
|
|
it('extractFlowRefs sees preprocessor scripts and flow_env resources', () => {
|
|
const refs = extractFlowRefs(flowValue)
|
|
expect(refs).toContainEqual({ kind: 'script', path: 'u/admin/preproc' })
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/slack' })
|
|
})
|
|
|
|
it('sees and relocates $res refs nested inside JSON flow_env values', () => {
|
|
const value = {
|
|
modules: [],
|
|
flow_env: { CFG: { db: '$res:u/admin/pg', opts: ['res://u/admin/s3'] } }
|
|
}
|
|
const refs = extractFlowRefs(value)
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/pg' })
|
|
expect(refs).toContainEqual({ kind: 'resource', path: 'u/admin/s3' })
|
|
const map = new Map([
|
|
['u/admin/pg', 'f/proj/pg'],
|
|
['u/admin/s3', 'f/proj/s3']
|
|
])
|
|
const out = rewriteFlowValue(value, map)
|
|
expect(out.flow_env.CFG.db).toBe('$res:f/proj/pg')
|
|
expect(out.flow_env.CFG.opts[0]).toBe('$res:f/proj/s3')
|
|
})
|
|
|
|
it('rewriteFlowValue relocates both', () => {
|
|
const map = new Map([
|
|
['u/admin/preproc', 'f/proj/preproc'],
|
|
['u/admin/slack', 'f/proj/slack']
|
|
])
|
|
const out = rewriteFlowValue(flowValue, map)
|
|
expect(out.preprocessor_module.value.path).toBe('f/proj/preproc')
|
|
expect(out.flow_env.SLACK).toBe('$res:f/proj/slack')
|
|
expect(out.flow_env.PLAIN).toBe('not-a-ref')
|
|
})
|
|
})
|
|
|
|
describe('projectReferencesResource', () => {
|
|
/**
|
|
* A project declares one resource per `resource-<type>` input schema as well as one per
|
|
* `$res:` reference, so an app pinning `f/proj/google_calendar` for a script whose schema
|
|
* says `resource-gcal` ships both it and an unreferenced `f/proj/gcal`. Only the pinned
|
|
* one has to hold a credential for the project to work.
|
|
*/
|
|
const bundle = {
|
|
project: { slug: 'proj', name: 'Proj', summary: '', readme: null },
|
|
scripts: [{ path: 'f/proj/send', content: 'const c = "$res:f/proj/google_calendar"' }],
|
|
flows: [],
|
|
apps: [],
|
|
resources: [
|
|
{ path: 'f/proj/gcal', resource_type: 'gcal' },
|
|
{ path: 'f/proj/google_calendar', resource_type: 'gcal' },
|
|
{ path: 'f/proj/db', resource_type: 'postgresql' }
|
|
],
|
|
triggers: [{ path: 'f/proj/ingest', config: { postgres_resource_path: 'f/proj/db' } }]
|
|
} as unknown as ProjectExport
|
|
|
|
it("sees a $res: token and a trigger's bare path, and not a stub nothing points at", () => {
|
|
expect(projectReferencesResource(bundle, 'f/proj/google_calendar')).toBe(true)
|
|
expect(projectReferencesResource(bundle, 'f/proj/db')).toBe(true)
|
|
// Declared only because a script's input schema names the type.
|
|
expect(projectReferencesResource(bundle, 'f/proj/gcal')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('textHoldsBarePath', () => {
|
|
// Gates three deletion decisions, and its two directions cost differently: a false yes
|
|
// keeps a stub nobody needed, a false no deletes one something still reads.
|
|
const P = 'f/proj/db'
|
|
it.each([
|
|
['a token only', `const c = "$res:${P}"`, false],
|
|
['a path written in code', `await getResource("${P}")`, true],
|
|
['both spellings', `"$res:${P}"; getResource("${P}")`, true],
|
|
['a longer path that starts the same', `await getResource("${P}_prod")`, false],
|
|
['the same path inside a longer token', `"$res:${P}_prod"`, false]
|
|
])('%s', (_label, text, expected) => {
|
|
expect(textHoldsBarePath(text, P)).toBe(expected)
|
|
})
|
|
})
|