@@ -13,6 +13,127 @@ import {
1313 waitForInitialRender ,
1414} from '../helpers/app-test-helpers.js'
1515
16+ const renameWorkspaceTab = async (
17+ page : import ( '@playwright/test' ) . Page ,
18+ {
19+ from,
20+ to,
21+ } : {
22+ from : string
23+ to : string
24+ } ,
25+ ) => {
26+ await page . getByRole ( 'button' , { name : `Rename tab ${ from } ` } ) . click ( )
27+ const renameInput = page . getByLabel ( `Rename ${ from } ` )
28+ await renameInput . fill ( to )
29+ await renameInput . press ( 'Enter' )
30+ }
31+
32+ const renameWorkspaceTabFromCandidates = async (
33+ page : import ( '@playwright/test' ) . Page ,
34+ {
35+ fromCandidates,
36+ to,
37+ } : {
38+ fromCandidates : string [ ]
39+ to : string
40+ } ,
41+ ) => {
42+ for ( const from of fromCandidates ) {
43+ const button = page . getByRole ( 'button' , { name : `Rename tab ${ from } ` } )
44+ if ( ( await button . count ( ) ) === 0 ) {
45+ continue
46+ }
47+
48+ await button . click ( )
49+ const renameInput = page . getByLabel ( `Rename ${ from } ` )
50+ await renameInput . fill ( to )
51+ await renameInput . press ( 'Enter' )
52+ return
53+ }
54+
55+ throw new Error (
56+ `Could not find a rename target from candidates: ${ fromCandidates . join ( ', ' ) } ` ,
57+ )
58+ }
59+
60+ const readLatestWorkspaceSnapshot = async ( page : import ( '@playwright/test' ) . Page ) => {
61+ return page . evaluate ( async ( ) => {
62+ const dbName = 'knighted-develop-workspaces'
63+ const storeName = 'prWorkspaces'
64+
65+ const openDb = await new Promise < IDBDatabase | null > ( resolve => {
66+ try {
67+ const request = indexedDB . open ( dbName )
68+ request . onsuccess = ( ) => resolve ( request . result )
69+ request . onerror = ( ) => resolve ( null )
70+ } catch {
71+ resolve ( null )
72+ }
73+ } )
74+
75+ if ( ! openDb ) {
76+ return null
77+ }
78+
79+ const records = await new Promise < Array < Record < string , unknown > > > ( resolve => {
80+ try {
81+ const transaction = openDb . transaction ( storeName , 'readonly' )
82+ const store = transaction . objectStore ( storeName )
83+ const request = store . getAll ( )
84+ request . onsuccess = ( ) => {
85+ const value = Array . isArray ( request . result ) ? request . result : [ ]
86+ resolve ( value as Array < Record < string , unknown > > )
87+ }
88+ request . onerror = ( ) => resolve ( [ ] )
89+ } catch {
90+ resolve ( [ ] )
91+ }
92+ } )
93+
94+ openDb . close ( )
95+
96+ if ( ! Array . isArray ( records ) || records . length === 0 ) {
97+ return null
98+ }
99+
100+ const sorted = [ ...records ] . sort ( ( a , b ) => {
101+ const first =
102+ typeof a . lastModified === 'number' && Number . isFinite ( a . lastModified )
103+ ? a . lastModified
104+ : 0
105+ const second =
106+ typeof b . lastModified === 'number' && Number . isFinite ( b . lastModified )
107+ ? b . lastModified
108+ : 0
109+ return second - first
110+ } )
111+
112+ const latest = sorted [ 0 ] ?? { }
113+ const tabs = Array . isArray ( latest . tabs ) ? latest . tabs : [ ]
114+ const primaryStylesTab = tabs . find ( tab => {
115+ if ( ! tab || typeof tab !== 'object' ) {
116+ return false
117+ }
118+
119+ const tabRecord = tab as Record < string , unknown >
120+ const language = typeof tabRecord . language === 'string' ? tabRecord . language : ''
121+ const path = typeof tabRecord . path === 'string' ? tabRecord . path : ''
122+ const name = typeof tabRecord . name === 'string' ? tabRecord . name : ''
123+ const isStyleLanguage = [ 'css' , 'less' , 'sass' , 'module' ] . includes ( language )
124+ const styleIdentity = `${ path } ${ name } ` . toLowerCase ( )
125+ const looksLikeStyle = / \. ( c s s | l e s s | s a s s | s c s s ) \b / . test ( styleIdentity )
126+ return isStyleLanguage && looksLikeStyle
127+ } ) as Record < string , unknown > | undefined
128+
129+ return {
130+ renderMode : typeof latest . renderMode === 'string' ? latest . renderMode : '' ,
131+ styleLanguage :
132+ typeof primaryStylesTab ?. language === 'string' ? primaryStylesTab . language : '' ,
133+ }
134+ } )
135+ }
136+
16137test . beforeEach ( async ( { page } ) => {
17138 await resetWorkbenchStorage ( page )
18139} )
@@ -31,6 +152,95 @@ test('renders in react mode with css modules', async ({ page }) => {
31152 await expectPreviewHasRenderedContent ( page )
32153} )
33154
155+ test ( 'css module imports expose class map for module tabs' , async ( { page } ) => {
156+ await waitForInitialRender ( page )
157+
158+ await ensurePanelToolsVisible ( page , 'component' )
159+
160+ await renameWorkspaceTab ( page , {
161+ from : 'app.css' ,
162+ to : 'app.module.css' ,
163+ } )
164+
165+ await setWorkspaceTabSource ( page , {
166+ fileName : 'app.module.css' ,
167+ kind : 'styles' ,
168+ source : [
169+ '.list {' ,
170+ ' display: grid;' ,
171+ '}' ,
172+ '' ,
173+ '.item {' ,
174+ ' color: rgb(10, 20, 30);' ,
175+ '}' ,
176+ ] . join ( '\n' ) ,
177+ } )
178+
179+ await addWorkspaceTab ( page , { type : 'script' } )
180+ await renameWorkspaceTab ( page , {
181+ from : 'module.tsx' ,
182+ to : 'list.tsx' ,
183+ } )
184+ await setWorkspaceTabSource ( page , {
185+ fileName : 'list.tsx' ,
186+ source : [
187+ "import styles from '../styles/app.module.css'" ,
188+ "import { Item } from './item'" ,
189+ '' ,
190+ 'type ListProps = {' ,
191+ ' items: string[]' ,
192+ '}' ,
193+ '' ,
194+ 'export const List = ({ items }: ListProps) => (' ,
195+ ' <ul className={styles.list}>' ,
196+ ' {items.map(item => (' ,
197+ ' <Item key={item} value={item} />' ,
198+ ' ))}' ,
199+ ' </ul>' ,
200+ ')' ,
201+ ] . join ( '\n' ) ,
202+ } )
203+
204+ await addWorkspaceTab ( page , { type : 'script' } )
205+ await renameWorkspaceTabFromCandidates ( page , {
206+ fromCandidates : [ 'module-2.tsx' , 'module.tsx' , 'module-1.tsx' ] ,
207+ to : 'item.tsx' ,
208+ } )
209+ await setWorkspaceTabSource ( page , {
210+ fileName : 'item.tsx' ,
211+ source : [
212+ "import styles from '../styles/app.module.css'" ,
213+ '' ,
214+ 'type ItemProps = {' ,
215+ ' value: string' ,
216+ '}' ,
217+ '' ,
218+ 'export const Item = ({ value }: ItemProps) => (' ,
219+ ' <li className={styles.item}>{value}</li>' ,
220+ ')' ,
221+ ] . join ( '\n' ) ,
222+ } )
223+
224+ await setComponentEditorSource (
225+ page ,
226+ [
227+ "import { List } from './list'" ,
228+ '' ,
229+ "const items = ['one', 'two']" ,
230+ '' ,
231+ 'const App = () => <List items={items} />' ,
232+ ] . join ( '\n' ) ,
233+ )
234+
235+ await expect ( page . getByRole ( 'status' , { name : 'App status' } ) ) . toHaveText ( 'Rendered' )
236+ await expect ( page . locator ( '#preview-host pre' ) ) . toHaveCount ( 0 )
237+ await expect ( getPreviewFrame ( page ) . getByRole ( 'listitem' ) ) . toHaveCount ( 2 )
238+ await expect ( getPreviewFrame ( page ) . getByRole ( 'listitem' ) . first ( ) ) . toHaveCSS (
239+ 'color' ,
240+ 'rgb(10, 20, 30)' ,
241+ )
242+ } )
243+
34244test ( 'preview styles require explicit import from entry graph' , async ( { page } ) => {
35245 await waitForInitialRender ( page )
36246
@@ -384,6 +594,29 @@ test('editing-transient missing reference runtime errors are suppressed', async
384594 await expect ( page . getByRole ( 'status' , { name : 'App status' } ) ) . not . toHaveText ( 'Error' )
385595} )
386596
597+ test ( 'missing component identifiers in App render as runtime errors' , async ( {
598+ page,
599+ } ) => {
600+ await waitForInitialRender ( page )
601+ await ensurePanelToolsVisible ( page , 'component' )
602+ await page . getByRole ( 'combobox' , { name : 'Render mode' } ) . selectOption ( 'react' )
603+
604+ await setComponentEditorSource (
605+ page ,
606+ [
607+ 'const App = () => (' ,
608+ ' <List>' ,
609+ ' <Item value="one" />' ,
610+ ' </List>' ,
611+ ')' ,
612+ ] . join ( '\n' ) ,
613+ )
614+
615+ await expect ( page . getByRole ( 'status' , { name : 'App status' } ) ) . toHaveText ( 'Error' )
616+ await expect ( page . locator ( '#preview-host pre' ) ) . toContainText ( '[runtime]' )
617+ await expect ( page . locator ( '#preview-host pre' ) ) . toContainText ( 'List is not defined' )
618+ } )
619+
387620test ( 'preview iframe sandbox isolates parent origin access' , async ( { page } ) => {
388621 await waitForInitialRender ( page )
389622
@@ -706,6 +939,10 @@ test('persists render mode across reload', async ({ page }) => {
706939 await page . getByRole ( 'combobox' , { name : 'Render mode' } ) . selectOption ( 'react' )
707940 await expect ( page . getByRole ( 'status' , { name : 'App status' } ) ) . toHaveText ( 'Rendered' )
708941
942+ await expect
943+ . poll ( async ( ) => ( await readLatestWorkspaceSnapshot ( page ) ) ?. renderMode ?? '' )
944+ . toBe ( 'react' )
945+
709946 await page . reload ( )
710947 await waitForInitialRender ( page )
711948 await ensurePanelToolsVisible ( page , 'component' )
@@ -721,6 +958,10 @@ test('persists style mode across reload', async ({ page }) => {
721958 await expect ( page . locator ( '#style-mode' ) ) . toHaveValue ( 'sass' )
722959 await expect ( page . getByRole ( 'status' , { name : 'App status' } ) ) . toHaveText ( 'Rendered' )
723960
961+ await expect
962+ . poll ( async ( ) => ( await readLatestWorkspaceSnapshot ( page ) ) ?. styleLanguage ?? '' )
963+ . toBe ( 'sass' )
964+
724965 await page . reload ( )
725966 await waitForInitialRender ( page )
726967
0 commit comments