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,105 @@
/**
* A UIDL state name is DATA — it mirrors a database column, and a WoW character
* sheet has a column called `class`. The generator turned that straight into a
* JS binding:
*
* const [class, setClass] = useState("")
*
* which is a SyntaxError, so the prettier post-processor threw and `packProject`
* aborted — every page of the project failed to generate, not just this one.
*
* These tests exercise the reserved name in every position a state can appear
* (declaration, text binding, attribute binding, conditional, event setter).
* `createReactComponentGenerator()` runs the prettier post-processor, so a
* generation that RESOLVES is itself proof that the emitted module parses —
* a missed emission site would reject here.
*/

import { createReactComponentGenerator } from '../../src'
import { GeneratedFile } from '@teleporthq/teleport-types'
import {
component,
definition,
staticNode,
dynamicNode,
elementNode,
conditionalNode,
} from '@teleporthq/teleport-uidl-builders'

const generator = createReactComponentGenerator()

const JS_FILE = 'js'
const findFileByType = (files: GeneratedFile[], type: string = JS_FILE) =>
files.find((file) => file.fileType === type)

const reservedStateUidl = component(
'Character Sheet',
elementNode('container', {}, [
// Text binding.
elementNode('text', {}, [dynamicNode('state', 'class')]),
// Attribute binding.
elementNode('input', { value: dynamicNode('state', 'class') }, []),
// Conditional.
conditionalNode(
dynamicNode('state', 'class'),
elementNode('text', {}, [staticNode('Class chosen')]),
true
),
// Event setter.
elementNode('button', {}, [staticNode('Reset')], null, null, {
click: [{ type: 'stateChange', modifies: 'class', newState: '' }],
}),
// A neighbouring ordinary state must be untouched by the fix.
elementNode('text', {}, [dynamicNode('state', 'realm')]),
]),
{},
{
class: definition('string', ''),
realm: definition('string', ''),
}
)

describe('Component with a RESERVED-WORD state name', () => {
it('generates a module that parses (prettier would reject `const [class, …]`)', async () => {
const result = await generator.generateComponent(reservedStateUidl)
expect(findFileByType(result.files, JS_FILE)).toBeDefined()
})

it('declares the state under a bindable identifier', async () => {
const { content } = findFileByType(
(await generator.generateComponent(reservedStateUidl)).files,
JS_FILE
)
expect(content).toContain('const [class_, setClass] = useState')
expect(content).not.toContain('const [class,')
})

it('reads the state through the SAME identifier it declared', async () => {
const { content } = findFileByType(
(await generator.generateComponent(reservedStateUidl)).files,
JS_FILE
)
// Text + attribute + conditional all bind the sanitised name.
// (`value` is emitted as React's `defaultValue` by the attribute mapper.)
expect(content).toContain('{class_}')
expect(content).toContain('defaultValue={class_}')
expect(content).toContain('{class_ && ')
})

it('calls the setter for the state-change event', async () => {
const { content } = findFileByType(
(await generator.generateComponent(reservedStateUidl)).files,
JS_FILE
)
expect(content).toContain('setClass(')
})

it('leaves an ordinary neighbouring state byte-identical', async () => {
const { content } = findFileByType(
(await generator.generateComponent(reservedStateUidl)).files,
JS_FILE
)
expect(content).toContain('const [realm, setRealm] = useState')
expect(content).toContain('{realm}')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as types from '@babel/types'
import generate from '@babel/generator'
import { parseStringWithTemplateExpressions } from '../../src/utils/ast-utils'

/** The generated source for a template literal, e.g. "`translateX(${x}px)`". */
const render = (input: string): string =>
generate(parseStringWithTemplateExpressions(input) as types.Node).code

describe('parseStringWithTemplateExpressions', () => {
it('converts a well-formed binding and strips the state prefix', () => {
expect(render(`translate({{ enemy.x || '0' }}px)`)).toBe(`\`translate(\${enemy.x || '0'}px)\``)
expect(render(`translateX({{ -(state.cameraX || 0) }}px)`)).toBe(
`\`translateX(\${-(cameraX || 0)}px)\``
)
})

it('handles several bindings in one value', () => {
expect(render(`translate({{ ctx.x }}px, {{ ctx.y }}px)`)).toBe(
`\`translate(\${ctx.x}px, \${ctx.y}px)\``
)
})

it('re-closes a CSS function the binding genuinely swallowed', () => {
// The unit is inferred from the one already present.
expect(render(`translate({{ x }}px, {{ y`)).toBe(`\`translate(\${x}px, \${y}px)\``)
})

it('infers deg for rotate and no unit for scale', () => {
expect(render(`rotate({{ a`)).toBe(`\`rotate(\${a}deg)\``)
expect(render(`scale({{ s`)).toBe(`\`scale(\${s})\``)
})
})

/**
* Run 021fa45a — an upstream "repair" turned `{{ -(state.cameraX || 0) }}` into
* `{{ -(state.cameraX || '' }}`, dropping the closing paren. This function then
* counted parentheses across the WHOLE string, mistook the imbalance inside the
* interpolation for an unclosed CSS function, appended `px)` to the end, and
* handed Babel `` `translateX(${-(cameraX || ''}px) }px)` ``. The SyntaxError
* escaped through the inline-style caller (which has no try/catch) and killed
* the entire project build.
*/
describe('parseStringWithTemplateExpressions — a malformed binding must not break the build', () => {
const BROKEN = `translateX({{ -(state.cameraX || '' }}px) }`

it('does not throw on the exact UIDL value that killed the build', () => {
expect(() => parseStringWithTemplateExpressions(BROKEN)).not.toThrow()
})

it('repairs it INSIDE the interpolation, where the paren was missing', () => {
expect(render(BROKEN)).toBe(`\`translateX(\${-(cameraX || '')}px) }\``)
})

it('no longer appends a fabricated unit and paren to the end', () => {
expect(render(BROKEN)).not.toContain('px)`')
})

it('closes an unterminated quote inside a binding', () => {
expect(render(`{{ label || 'Guest }}`)).toBe(`\`\${label || 'Guest'}\``)
})

it('degrades to the declared literal fallback when nothing can be parsed', () => {
// A stray closer cannot be balanced without guessing, so the binding is
// dropped and its literal fallback kept — one value degrades, nothing throws.
const result = render(`{{ name) || 'Guest' }}`)
expect(result).toBe('`Guest`')
})

it('degrades to empty text when a broken binding declared no fallback', () => {
expect(render(`{{ a) }}`)).toBe('``')
})

it('always returns a TemplateLiteral, even on the degraded path', () => {
expect(types.isTemplateLiteral(parseStringWithTemplateExpressions(`{{ a) }}`))).toBe(true)
})

it('is idempotent on an already-repaired value', () => {
const once = render(BROKEN)
// Feeding the generated form back in (minus the backticks) is stable.
expect(render(once.slice(1, -1))).toBe(once)
})
})
118 changes: 118 additions & 0 deletions packages/teleport-plugin-common/__tests__/utils/route-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
parseDynamicPathSegments,
pathHasDynamicSegment,
isDynamicRoute,
} from '../../src/utils/route-utils'

describe('parseDynamicPathSegments', () => {
it('splits a Next.js bracket segment into static text plus a param name', () => {
// The shape every details page's `navLink` actually carries. Before this
// was understood, the literal text "[id]" shipped inside canonical URLs,
// og:url and sitemap entries.
expect(parseDynamicPathSegments('/event-details/[id]')).toEqual({
staticParts: ['/event-details/', ''],
paramNames: ['id'],
})
})

it('still splits the template-literal form', () => {
expect(parseDynamicPathSegments('/news/' + '$' + '{' + 'slug}')).toEqual({
staticParts: ['/news/', ''],
paramNames: ['slug'],
})
})

it('handles a full absolute URL, keeping the origin in the static text', () => {
expect(parseDynamicPathSegments('https://example.com/rsvp-event/[id]')).toEqual({
staticParts: ['https://example.com/rsvp-event/', ''],
paramNames: ['id'],
})
})

it('splits several parameters and keeps the text between them', () => {
expect(parseDynamicPathSegments('/g/[guildId]/e/[eventId]/rsvp')).toEqual({
staticParts: ['/g/', '/e/', '/rsvp'],
paramNames: ['guildId', 'eventId'],
})
})

it('mixes both spellings in one path', () => {
expect(parseDynamicPathSegments('/a/[id]/b/' + '$' + '{' + 'slug}')).toEqual({
staticParts: ['/a/', '/b/', ''],
paramNames: ['id', 'slug'],
})
})

it('reports no parameters for a plain static path', () => {
expect(parseDynamicPathSegments('https://example.com/add-guild')).toEqual({
staticParts: ['https://example.com/add-guild'],
paramNames: [],
})
})

it('always returns exactly one more static part than parameters', () => {
for (const path of ['/a', '/a/[id]', '/a/[id]/b/[other]', '/' + '$' + '{' + 'x}']) {
const { staticParts, paramNames } = parseDynamicPathSegments(path)
expect(staticParts.length).toBe(paramNames.length + 1)
}
})

it('rebuilding staticParts + parameters reproduces the original path', () => {
const path = '/g/[guildId]/e/[eventId]/rsvp'
const { staticParts, paramNames } = parseDynamicPathSegments(path)
const rebuilt = staticParts.reduce(
(acc, part, i) => acc + part + (i < paramNames.length ? `[${paramNames[i]}]` : ''),
''
)
expect(rebuilt).toBe(path)
})

it('ignores a bracket that is not a whole path segment', () => {
// A literal bracket inside a segment or a query string is content, not a
// route parameter — turning it into router.query would corrupt the URL.
expect(parseDynamicPathSegments('/report[2024]/summary').paramNames).toEqual([])
expect(parseDynamicPathSegments('/search?tags[]=a').paramNames).toEqual([])
expect(parseDynamicPathSegments('/a/[id]x/b').paramNames).toEqual([])
})

it('ignores Next catch-all segments, whose value is an array', () => {
// `router.query.slug` is a string[] for a catch-all; interpolating it into
// a URL would emit a comma-joined path. Leaving it alone is the honest
// outcome until a caller needs a real join.
expect(parseDynamicPathSegments('/docs/[...slug]').paramNames).toEqual([])
expect(parseDynamicPathSegments('/docs/[[...slug]]').paramNames).toEqual([])
})

it('ends a segment at anything that cannot be part of a path', () => {
// The same template appears inside a serialized JSON-LD document, where the
// URL ends at the closing quote rather than at a slash.
const embedded = '{"item":"https://example.com/event-details/[id]"}'
const { paramNames } = parseDynamicPathSegments(embedded)
expect(paramNames).toEqual(['id'])
})

it('is total for empty input', () => {
expect(parseDynamicPathSegments('')).toEqual({ staticParts: [''], paramNames: [] })
})
})

describe('pathHasDynamicSegment', () => {
it('is true only for a whole-segment bracket parameter', () => {
expect(pathHasDynamicSegment('/add-character/[id]')).toBe(true)
expect(pathHasDynamicSegment('https://example.com/profile/[id]')).toBe(true)
expect(pathHasDynamicSegment('/add-guild')).toBe(false)
expect(pathHasDynamicSegment('/report[2024]')).toBe(false)
expect(pathHasDynamicSegment('')).toBe(false)
expect(pathHasDynamicSegment(undefined as unknown as string)).toBe(false)
})
})

describe('isDynamicRoute', () => {
it('reads the component output options, not a URL', () => {
expect(
isDynamicRoute({ outputOptions: { folderPath: ['event-details'], fileName: '[id]' } })
).toBe(true)
expect(isDynamicRoute({ outputOptions: { folderPath: [], fileName: 'add-guild' } })).toBe(false)
expect(isDynamicRoute({})).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
balanceExpression,
countUnclosedStaticParens,
isBalancedExpression,
scanExpression,
} from '../../src/utils/template-expression-balance'

describe('scanExpression', () => {
it('reports a clean expression as fully closed', () => {
const scan = scanExpression('-(cameraX || 0)')
expect(scan.unclosed).toEqual([])
expect(scan.strayCloserCount).toBe(0)
expect(scan.unterminatedQuote).toBeNull()
})

it('never lets a bracket inside a string affect nesting', () => {
expect(isBalancedExpression(`name || '(unclosed'`)).toBe(true)
expect(isBalancedExpression(`name || ')'`)).toBe(true)
})

it('honours escapes inside a string', () => {
expect(isBalancedExpression(`name || 'it\\'s fine'`)).toBe(true)
})

it('reports an unterminated quote', () => {
expect(scanExpression(`name || 'oops`).unterminatedQuote).toBe("'")
})

it('counts a closer that never had an opener as stray', () => {
expect(scanExpression('0)').strayCloserCount).toBe(1)
})

it('treats a mismatched closer as stray rather than guessing', () => {
expect(scanExpression('fn(a]').strayCloserCount).toBe(1)
})
})

describe('balanceExpression', () => {
it('closes the paren the upstream repair dropped', () => {
expect(balanceExpression(`-(cameraX || ''`)).toBe(`-(cameraX || '')`)
})

it('closes an unterminated quote BEFORE the brackets around it', () => {
expect(balanceExpression(`fn(x, 'label`)).toBe(`fn(x, 'label')`)
})

it('closes nested brackets outermost-last', () => {
expect(balanceExpression('fn(items[0')).toBe('fn(items[0])')
})

it('leaves a balanced expression byte-for-byte alone', () => {
for (const expr of [`-(cameraX || 0)`, `a.b`, `items[0].name`, `fn(a, 'b')`]) {
expect(balanceExpression(expr)).toBe(expr)
}
})

it('refuses to guess where a missing opener belonged', () => {
expect(balanceExpression('cameraX)')).toBe('cameraX)')
})
})

describe('countUnclosedStaticParens', () => {
it('ignores parentheses inside an interpolation', () => {
// The whole point: the `(` in the expression is not a CSS function call.
expect(countUnclosedStaticParens(`translateX(\${-(cameraX || 0)}px)`)).toBe(0)
})

it('counts a genuinely unclosed CSS function', () => {
expect(countUnclosedStaticParens(`translate(\${x}px, \${y}px`)).toBe(1)
})

it('does not go negative on extra closers', () => {
expect(countUnclosedStaticParens(`\${x}px))`)).toBe(0)
})

it('skips a brace inside a string inside an interpolation', () => {
expect(countUnclosedStaticParens(`translateX(${'${'}fn('}')${'}'}px)`)).toBe(0)
})

it('tolerates an unterminated interpolation', () => {
expect(countUnclosedStaticParens(`translateX(\${-(cameraX`)).toBe(1)
})
})
Loading
Loading