Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Routing guard for the `'component'` trigger scope: an `event-component-loaded`
// workflow must be generated ONLY into the component it is bound to (matched by
// the emitted component name, with a normalized-compare and componentId
// fallback) — never into pages, never into unrelated components. This is the
// fix for the original bug where a component workflow authored with page scope
// was stripped from the component and silently fanned out to every page.

import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'

const componentLoadedWorkflow = (config: Record<string, unknown>) => ({
id: 'wf-comp-loaded',
name: 'Auto-open Sidebar Group',
trigger: {
type: 'event-component-loaded',
nodeId: 'trigger-component-loaded',
scope: 'component',
config,
},
nodes: [
{
id: 'update-1',
type: 'state-update-local-state',
config: { property: 'sidebarGroupOpen', value: 'guilds' },
stepNumber: 1,
label: 'Set group',
},
],
edges: [{ id: 'e1', source: 'trigger-component-loaded', target: 'update-1' }],
})

const jsxComponentChunk = () => ({
type: 'chunk-type-ast',
name: 'jsx-component',
content: {
type: 'VariableDeclaration',
declarations: [
{
type: 'VariableDeclarator',
init: {
type: 'ArrowFunctionExpression',
body: {
type: 'BlockStatement',
body: [{ type: 'ReturnStatement', argument: null }],
},
},
},
],
},
})

const buildStructure = (uidl: Record<string, unknown>, workflow: any): any => ({
uidl: {
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
stateDefinitions: { sidebarGroupOpen: { type: 'string', defaultValue: '' } },
...uidl,
},
chunks: [jsxComponentChunk()],
options: {
workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} },
},
dependencies: {},
})

const getWorkflowModule = async (
uidl: Record<string, unknown>,
workflow: any,
isPage: boolean
): Promise<string | null> => {
const plugin = createNextWorkflowPlugin({ isPage })
const structure = buildStructure(uidl, workflow)
await plugin(structure as any)
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
return moduleChunk ? String(moduleChunk.content) : null
}

describe('component-scope trigger routing', () => {
it('generates the workflow into the component matching componentName', async () => {
const code = await getWorkflowModule(
{ name: 'Navigation' },
componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }),
false
)
expect(code).not.toBeNull()
expect(code).toContain('Component loaded')
expect(code).toContain("componentName: 'Navigation'")
})

it('matches case/separator-insensitively (generator may re-case the name)', async () => {
const code = await getWorkflowModule(
{ name: 'CookieConsent' },
componentLoadedWorkflow({ componentName: 'Cookie Consent' }),
false
)
expect(code).not.toBeNull()
expect(code).toContain('Component loaded')
})

it('falls back to componentId when the name does not match', async () => {
const code = await getWorkflowModule(
{ name: 'Navigation', outputOptions: { fileName: 'navigation' } },
componentLoadedWorkflow({ componentId: 'navigation', componentName: 'Renamed Later' }),
false
)
expect(code).not.toBeNull()
expect(code).toContain('Component loaded')
})

it('does NOT generate into an unrelated component', async () => {
const code = await getWorkflowModule(
{ name: 'Footer' },
componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }),
false
)
expect(code).toBeNull()
})

it('does NOT generate into pages', async () => {
const code = await getWorkflowModule(
{ name: 'Navigation', outputOptions: { pageId: 'page-1', fileName: 'home' } },
componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }),
true
)
expect(code).toBeNull()
})

it('matches nothing when both componentId and componentName are missing', async () => {
const code = await getWorkflowModule({ name: 'Navigation' }, componentLoadedWorkflow({}), false)
expect(code).toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// `event-page-loaded` scoping guard: a page-loaded workflow with NO page
// scoping (no pageId, no selectedPages, no allPages) must be SKIPPED, not
// injected into every page. Silent all-pages fan-out masked mis-scoped
// workflows whose state target lives in a component (the setter no-ops on
// pages). Explicit `allPages: true` is the supported way to run everywhere.

import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'

const pageLoadedWorkflow = (config: Record<string, unknown>) => ({
id: 'wf-page-loaded',
name: 'Page Init',
trigger: {
type: 'event-page-loaded',
nodeId: 'trigger-page-loaded',
scope: 'page',
config,
},
nodes: [
{
id: 'update-1',
type: 'state-update-local-state',
config: { property: 'ready', value: 'true' },
stepNumber: 1,
label: 'Set ready',
},
],
edges: [{ id: 'e1', source: 'trigger-page-loaded', target: 'update-1' }],
})

const jsxComponentChunk = () => ({
type: 'chunk-type-ast',
name: 'jsx-component',
content: {
type: 'VariableDeclaration',
declarations: [
{
type: 'VariableDeclarator',
init: {
type: 'ArrowFunctionExpression',
body: {
type: 'BlockStatement',
body: [{ type: 'ReturnStatement', argument: null }],
},
},
},
],
},
})

const getWorkflowModule = async (workflow: any, pageId: string): Promise<string | null> => {
const plugin = createNextWorkflowPlugin({ isPage: true })
const structure: any = {
uidl: {
name: 'Page',
outputOptions: { pageId, fileName: pageId },
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
stateDefinitions: { ready: { type: 'string', defaultValue: '' } },
},
chunks: [jsxComponentChunk()],
options: {
workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} },
},
dependencies: {},
}
await plugin(structure)
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
return moduleChunk ? String(moduleChunk.content) : null
}

describe('event-page-loaded scoping', () => {
it('SKIPS a workflow with no page scoping at all (empty config)', async () => {
const code = await getWorkflowModule(pageLoadedWorkflow({}), 'page-1')
expect(code).toBeNull()
})

it('generates into every page when allPages is true', async () => {
const codeA = await getWorkflowModule(pageLoadedWorkflow({ allPages: true }), 'page-1')
const codeB = await getWorkflowModule(pageLoadedWorkflow({ allPages: true }), 'page-2')
expect(codeA).toContain('Page loaded')
expect(codeB).toContain('Page loaded')
})

it('generates only into the matching page when pageId is set', async () => {
const match = await getWorkflowModule(pageLoadedWorkflow({ pageId: 'page-1' }), 'page-1')
const mismatch = await getWorkflowModule(pageLoadedWorkflow({ pageId: 'page-1' }), 'page-2')
expect(match).toContain('Page loaded')
expect(mismatch).toBeNull()
})

it('generates into pages listed in selectedPages', async () => {
const config = { selectedPages: [{ id: 'page-1' }, { id: 'page-3' }] }
const inList = await getWorkflowModule(pageLoadedWorkflow(config), 'page-3')
const notInList = await getWorkflowModule(pageLoadedWorkflow(config), 'page-2')
expect(inList).toContain('Page loaded')
expect(notInList).toBeNull()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// `event-route-changed` codegen: component-scoped triggers register a
// `Router.events` listener inside the owning component's lifecycle effect
// (with an `.off` cleanup so remounts don't leak listeners); unbound triggers
// are global-scoped and register in the `_app`-mounted global workflows hook,
// which must then import Router itself.

import { createNextWorkflowPlugin } from '../src/workflow-component-plugin'
import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'

const routeChangedWorkflow = (scope: string, config: Record<string, unknown>) => ({
id: 'wf-route-changed',
name: 'Track Route',
trigger: {
type: 'event-route-changed',
nodeId: 'trigger-route-changed',
scope,
config,
},
nodes: [
{
id: 'update-1',
type: 'state-update-local-state',
config: { property: 'currentRoute', value: '' },
stepNumber: 1,
label: 'Set route',
},
],
edges: [{ id: 'e1', source: 'trigger-route-changed', target: 'update-1' }],
})

const jsxComponentChunk = () => ({
type: 'chunk-type-ast',
name: 'jsx-component',
content: {
type: 'VariableDeclaration',
declarations: [
{
type: 'VariableDeclarator',
init: {
type: 'ArrowFunctionExpression',
body: {
type: 'BlockStatement',
body: [{ type: 'ReturnStatement', argument: null }],
},
},
},
],
},
})

describe('event-route-changed — component scope', () => {
it('registers Router.events with an .off cleanup in the component lifecycle', async () => {
const workflow = routeChangedWorkflow('component', { componentName: 'Navigation' })
const plugin = createNextWorkflowPlugin({ isPage: false })
const structure: any = {
uidl: {
name: 'Navigation',
node: { type: 'element', content: { elementType: 'container', name: 'Container' } },
stateDefinitions: { currentRoute: { type: 'string', defaultValue: '' } },
},
chunks: [jsxComponentChunk()],
options: {
workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} },
},
dependencies: {},
}
await plugin(structure)
const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module')
expect(moduleChunk).toBeDefined()
const code = String(moduleChunk.content)
expect(code).toContain("Router.events.on('routeChangeComplete'")
expect(code).toContain("Router.events.off('routeChangeComplete'")
expect(code).toContain('cleanups.push')
expect(code).toContain('previousUrl')
})
})

describe('event-route-changed — global scope', () => {
const plugin = new NextWorkflowProjectPlugin()
// generateGlobalWorkflowsHook is private; call it directly for a unit test.
const code = (plugin as any).generateGlobalWorkflowsHook([
routeChangedWorkflow('global', {}),
]) as string

it('registers Router.events with an .off cleanup in the global hook', () => {
expect(code).toContain("Router.events.on('routeChangeComplete'")
expect(code).toContain("Router.events.off('routeChangeComplete'")
expect(code).toContain('previousUrl')
})

it('imports Router in the generated global-workflows file', () => {
expect(code).toContain("import Router from 'next/router';")
})

it('does NOT import Router when no route-changed workflow exists', () => {
const withoutRoute = (plugin as any).generateGlobalWorkflowsHook([
{
id: 'wf-other',
trigger: { type: 'event-user-logged-in', config: {} },
nodes: [],
edges: [],
},
]) as string
expect(withoutRoute).not.toContain("import Router from 'next/router';")
})
})
Loading
Loading