@@ -1605,6 +1605,186 @@ describe("resource invalidation", () => {
16051605 } )
16061606} )
16071607
1608+ describe ( "stable semantic node IDs" , ( ) => {
1609+ it ( "inspectScreen includes semanticId for all node types" , ( ) => {
1610+ const screenDef = screen ( "TestScreen" , $ => {
1611+ const email = $ . state . text ( "email" )
1612+ const emailAsk = $ . ask ( "Email" , email )
1613+ const login = $ . act ( "Log in" )
1614+ $ . resource ( "team" , {
1615+ load : async ( ) => "data" ,
1616+ autoLoad : false ,
1617+ } )
1618+ $ . flow ( "login" ) . startsWith ( emailAsk ) . then ( login )
1619+ $ . surface ( "main" ) . contains ( emailAsk , login )
1620+ } )
1621+
1622+ const inspected = inspectScreen ( screenDef )
1623+ expect ( inspected . semanticId ) . toBe ( "screen:test-screen" )
1624+ expect ( inspected . asks [ 0 ] ?. semanticId ) . toBe ( "ask:email" )
1625+ expect ( inspected . acts [ 0 ] ?. semanticId ) . toBe ( "action:log-in" )
1626+ expect ( inspected . flows [ 0 ] ?. semanticId ) . toBe ( "flow:login" )
1627+ expect ( inspected . surfaces [ 0 ] ?. semanticId ) . toBe ( "surface:main" )
1628+ } )
1629+
1630+ it ( "calling inspectScreen twice on the same screen returns the same IDs" , ( ) => {
1631+ const screenDef = screen ( "LoginScreen" , $ => {
1632+ const email = $ . state . text ( "email" )
1633+ const emailAsk = $ . ask ( "Email" , email )
1634+ const login = $ . act ( "Log in" )
1635+ $ . surface ( "main" ) . contains ( emailAsk , login )
1636+ } )
1637+
1638+ const first = inspectScreen ( screenDef )
1639+ const second = inspectScreen ( screenDef )
1640+
1641+ expect ( first . semanticId ) . toBe ( second . semanticId )
1642+ expect ( first . asks [ 0 ] ?. semanticId ) . toBe ( second . asks [ 0 ] ?. semanticId )
1643+ expect ( first . acts [ 0 ] ?. semanticId ) . toBe ( second . acts [ 0 ] ?. semanticId )
1644+ } )
1645+
1646+ it ( "creating an unrelated screen before the target screen does not change semantic IDs" , ( ) => {
1647+ screen ( "UnrelatedScreen" , $ => {
1648+ $ . ask ( "Name" , $ . state . text ( "name" ) )
1649+ $ . act ( "Save" )
1650+ $ . surface ( "main" ) . contains ( )
1651+ } )
1652+
1653+ const screenDef = screen ( "LoginScreen" , $ => {
1654+ const email = $ . state . text ( "email" )
1655+ const emailAsk = $ . ask ( "Email" , email )
1656+ const login = $ . act ( "Log in" )
1657+ $ . surface ( "main" ) . contains ( emailAsk , login )
1658+ } )
1659+
1660+ const inspected = inspectScreen ( screenDef )
1661+ expect ( inspected . semanticId ) . toBe ( "screen:login-screen" )
1662+ expect ( inspected . asks [ 0 ] ?. semanticId ) . toBe ( "ask:email" )
1663+ expect ( inspected . acts [ 0 ] ?. semanticId ) . toBe ( "action:log-in" )
1664+ } )
1665+
1666+ it ( "duplicate labels get deterministic suffixed semantic IDs" , ( ) => {
1667+ const screenDef = screen ( "Duplicates" , $ => {
1668+ const name1 = $ . state . text ( "name1" )
1669+ const name2 = $ . state . text ( "name2" )
1670+ const ask1 = $ . ask ( "Email" , name1 )
1671+ const ask2 = $ . ask ( "Email" , name2 )
1672+ const act1 = $ . act ( "Save" )
1673+ const act2 = $ . act ( "Save" )
1674+ $ . surface ( "main" ) . contains ( ask1 , ask2 , act1 , act2 )
1675+ } )
1676+
1677+ const inspected = inspectScreen ( screenDef )
1678+ expect ( inspected . asks ) . toHaveLength ( 2 )
1679+ expect ( inspected . asks [ 0 ] ?. semanticId ) . toBe ( "ask:email" )
1680+ expect ( inspected . asks [ 1 ] ?. semanticId ) . toBe ( "ask:email-2" )
1681+ expect ( inspected . acts ) . toHaveLength ( 2 )
1682+ expect ( inspected . acts [ 0 ] ?. semanticId ) . toBe ( "action:save" )
1683+ expect ( inspected . acts [ 1 ] ?. semanticId ) . toBe ( "action:save-2" )
1684+ } )
1685+
1686+ it ( "diagnostics include semanticNodeId alongside nodeId" , ( ) => {
1687+ const screenDef = screen ( "DiagSemanticId" , $ => {
1688+ const pwd = $ . state . text ( "password" )
1689+ const pwdAsk = $ . ask ( "Password" , pwd ) . asSecret ( )
1690+ $ . surface ( "main" ) . contains ( pwdAsk )
1691+ } )
1692+
1693+ const inspected = inspectScreen ( screenDef )
1694+ const secretDiag = inspected . diagnostics . find ( d => d . code === "secret-ask-not-private" )
1695+ expect ( secretDiag ) . toBeDefined ( )
1696+ expect ( secretDiag ?. nodeId ) . toBe ( "ask_password" )
1697+ expect ( secretDiag ?. semanticNodeId ) . toBe ( "ask:password" )
1698+ } )
1699+
1700+ it ( "resource semanticId is stable and independent of runtime state" , async ( ) => {
1701+ const screenDef = screen ( "ResourceStable" , $ => {
1702+ $ . resource ( "team" , {
1703+ load : async ( ) => "data" ,
1704+ autoLoad : false ,
1705+ } )
1706+ } )
1707+
1708+ const runtime = createScreenRuntime ( screenDef )
1709+ await runtime . start ( )
1710+ const inspected = inspectScreen ( screenDef , runtime . resources )
1711+ expect ( inspected . resources [ 0 ] ?. semanticId ) . toBe ( "resource:team" )
1712+ expect ( inspected . resources [ 0 ] ?. id ) . toBe ( "resource_team" )
1713+ } )
1714+
1715+ it ( "punctuation is normalized in semantic IDs" , ( ) => {
1716+ const screenDef = screen ( "PunctuationTest" , $ => {
1717+ const email = $ . state . text ( "email" )
1718+ const emailAsk = $ . ask ( "Email!" , email )
1719+ const login = $ . act ( "Save!" )
1720+ $ . surface ( "main" ) . contains ( emailAsk , login )
1721+ } )
1722+
1723+ const inspected = inspectScreen ( screenDef )
1724+ expect ( inspected . asks [ 0 ] ?. semanticId ) . toBe ( "ask:email" )
1725+ expect ( inspected . acts [ 0 ] ?. semanticId ) . toBe ( "action:save" )
1726+ } )
1727+
1728+ it ( "duplicate normalized labels suffix deterministically" , ( ) => {
1729+ const screenDef = screen ( "NormalizedDupes" , $ => {
1730+ const s1 = $ . state . text ( "s1" )
1731+ const s2 = $ . state . text ( "s2" )
1732+ const ask1 = $ . ask ( "Email" , s1 )
1733+ const ask2 = $ . ask ( "Email!" , s2 )
1734+ const act1 = $ . act ( "Save" )
1735+ const act2 = $ . act ( "Save!" )
1736+ const act3 = $ . act ( "Save!!" )
1737+ $ . surface ( "main" ) . contains ( ask1 , ask2 , act1 , act2 , act3 )
1738+ } )
1739+
1740+ const inspected = inspectScreen ( screenDef )
1741+ expect ( inspected . asks ) . toHaveLength ( 2 )
1742+ expect ( inspected . asks [ 0 ] ?. semanticId ) . toBe ( "ask:email" )
1743+ expect ( inspected . asks [ 1 ] ?. semanticId ) . toBe ( "ask:email-2" )
1744+ expect ( inspected . acts ) . toHaveLength ( 3 )
1745+ expect ( inspected . acts [ 0 ] ?. semanticId ) . toBe ( "action:save" )
1746+ expect ( inspected . acts [ 1 ] ?. semanticId ) . toBe ( "action:save-2" )
1747+ expect ( inspected . acts [ 2 ] ?. semanticId ) . toBe ( "action:save-3" )
1748+ } )
1749+
1750+ it ( "symbol-only labels use kind-local order fallback" , ( ) => {
1751+ const screenDef = screen ( "SymbolFallback" , $ => {
1752+ const s1 = $ . state . text ( "s1" )
1753+ const s2 = $ . state . text ( "s2" )
1754+ const act1 = $ . act ( "!!!" )
1755+ const act2 = $ . act ( "???" )
1756+ const ask1 = $ . ask ( "***" , s1 )
1757+ const ask2 = $ . ask ( "@@@" , s2 )
1758+ $ . surface ( "main" ) . contains ( act1 , act2 , ask1 , ask2 )
1759+ } )
1760+
1761+ const inspected = inspectScreen ( screenDef )
1762+ expect ( inspected . acts ) . toHaveLength ( 2 )
1763+ expect ( inspected . acts [ 0 ] ?. semanticId ) . toBe ( "action:1" )
1764+ expect ( inspected . acts [ 1 ] ?. semanticId ) . toBe ( "action:2" )
1765+ expect ( inspected . asks ) . toHaveLength ( 2 )
1766+ expect ( inspected . asks [ 0 ] ?. semanticId ) . toBe ( "ask:1" )
1767+ expect ( inspected . asks [ 1 ] ?. semanticId ) . toBe ( "ask:2" )
1768+ } )
1769+
1770+ it ( "diagnostics semanticNodeId uses new slugging logic" , ( ) => {
1771+ const screenDef = screen ( "DiagSlugged" , $ => {
1772+ const pwd = $ . state . text ( "password" )
1773+ const pwdAsk = $ . ask ( "Password!" , pwd ) . asSecret ( )
1774+ const login = $ . act ( "Log in!" )
1775+ . primary ( )
1776+ . when ( true )
1777+ $ . surface ( "main" ) . contains ( pwdAsk , login )
1778+ } )
1779+
1780+ const inspected = inspectScreen ( screenDef )
1781+ const secretDiag = inspected . diagnostics . find ( d => d . code === "secret-ask-not-private" )
1782+ expect ( secretDiag ) . toBeDefined ( )
1783+ expect ( secretDiag ?. nodeId ) . toBe ( "ask_password!" )
1784+ expect ( secretDiag ?. semanticNodeId ) . toBe ( "ask:password" )
1785+ } )
1786+ } )
1787+
16081788// Type-only test helpers — these are verified during pnpm typecheck
16091789type _TypeTestAppServices = {
16101790 analytics : { track ( event : "login_clicked" ) : void }
0 commit comments