From 551c11055fbe06009f6fc07a38237ad97932c591 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 11 Feb 2026 15:06:45 +0900 Subject: [PATCH 1/8] Improve dev --- .../src/__tests__/coordinator.test.ts | 353 ++++++++++++++ .../src/__tests__/css-loader.test.ts | 53 +++ .../next-plugin/src/__tests__/loader.test.ts | 442 ++++++++++++++++++ .../next-plugin/src/__tests__/plugin.test.ts | 97 ++-- packages/next-plugin/src/coordinator.ts | 196 ++++++++ packages/next-plugin/src/css-loader.ts | 8 + packages/next-plugin/src/loader.ts | 348 ++++++++------ packages/next-plugin/src/plugin.ts | 34 +- 8 files changed, 1363 insertions(+), 168 deletions(-) create mode 100644 packages/next-plugin/src/__tests__/coordinator.test.ts create mode 100644 packages/next-plugin/src/coordinator.ts diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts new file mode 100644 index 00000000..dad43b31 --- /dev/null +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -0,0 +1,353 @@ +import * as fs from 'node:fs' +import { request } from 'node:http' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' + +import { + type CoordinatorOptions, + resetCoordinator, + startCoordinator, +} from '../coordinator' + +let codeExtractSpy: ReturnType +let getCssSpy: ReturnType +let exportSheetSpy: ReturnType +let exportClassMapSpy: ReturnType +let exportFileMapSpy: ReturnType +let writeFileSpy: ReturnType +let writeFileSyncSpy: ReturnType + +const tmpDir = join(process.cwd(), '.tmp-coordinator-test') + +function makeOptions( + overrides: Partial = {}, +): CoordinatorOptions { + return { + package: '@devup-ui/react', + cssDir: join(tmpDir, 'css'), + singleCss: false, + sheetFile: join(tmpDir, 'sheet.json'), + classMapFile: join(tmpDir, 'classMap.json'), + fileMapFile: join(tmpDir, 'fileMap.json'), + importAliases: {}, + coordinatorPortFile: join(tmpDir, 'coordinator.port'), + ...overrides, + } +} + +function httpRequest( + port: number, + method: string, + path: string, + body?: string, +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = request( + { + hostname: '127.0.0.1', + port, + path, + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf-8'), + }) + }) + }, + ) + req.on('error', reject) + if (body) req.write(body) + req.end() + }) +} + +beforeEach(() => { + codeExtractSpy = spyOn(wasm, 'codeExtract') + getCssSpy = spyOn(wasm, 'getCss') + exportSheetSpy = spyOn(wasm, 'exportSheet') + exportClassMapSpy = spyOn(wasm, 'exportClassMap') + exportFileMapSpy = spyOn(wasm, 'exportFileMap') + writeFileSpy = spyOn(fs, 'writeFile').mockImplementation( + (_path: any, _data: any, _encOrCb: any, maybeCb?: any) => { + const cb = typeof _encOrCb === 'function' ? _encOrCb : maybeCb + if (cb) cb(null) + }, + ) + writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) +}) + +afterEach(() => { + resetCoordinator() + codeExtractSpy.mockRestore() + getCssSpy.mockRestore() + exportSheetSpy.mockRestore() + exportClassMapSpy.mockRestore() + exportFileMapSpy.mockRestore() + writeFileSpy.mockRestore() + writeFileSyncSpy.mockRestore() +}) + +describe('coordinator', () => { + it('should start and respond to /health', async () => { + const options = makeOptions() + const coordinator = startCoordinator(options) + + // Wait for server to start and write port file + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest(port, 'GET', '/health') + expect(res.status).toBe(200) + expect(res.body).toBe('ok') + + coordinator.close() + }) + + it('should handle /extract endpoint', async () => { + codeExtractSpy.mockReturnValue({ + code: 'transformed code', + map: '{"version":3}', + cssFile: 'devup-ui-1.css', + updatedBaseStyle: true, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockImplementation( + (fileNum: number | null, _importMainCss: boolean) => { + if (fileNum === null) return 'base-css' + return `file-css-${fileNum}` + }, + ) + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + const data = JSON.parse(res.body) + expect(data.code).toBe('transformed code') + expect(data.map).toBe('{"version":3}') + expect(data.cssFile).toBe('devup-ui-1.css') + expect(data.updatedBaseStyle).toBe(true) + + // Verify WASM was called + expect(codeExtractSpy).toHaveBeenCalledTimes(1) + + // Verify files were written (base CSS + per-file CSS + sheet + classmap + filemap) + expect(writeFileSpy).toHaveBeenCalledTimes(5) + + coordinator.close() + }) + + it('should handle /extract when no CSS file produced', async () => { + codeExtractSpy.mockReturnValue({ + code: 'no style code', + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/plain.ts', + code: 'const x = 1', + resourcePath: join(process.cwd(), 'src', 'plain.ts'), + }), + ) + + expect(res.status).toBe(200) + const data = JSON.parse(res.body) + expect(data.code).toBe('no style code') + expect(data.cssFile).toBeUndefined() + + // No file writes expected (no CSS, no updatedBaseStyle) + expect(writeFileSpy).not.toHaveBeenCalled() + + coordinator.close() + }) + + it('should handle /extract errors', async () => { + codeExtractSpy.mockImplementation(() => { + throw new Error('extraction failed') + }) + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/bad.tsx', + code: 'invalid', + resourcePath: join(process.cwd(), 'src', 'bad.tsx'), + }), + ) + + expect(res.status).toBe(500) + const data = JSON.parse(res.body) + expect(data.error).toBe('extraction failed') + + coordinator.close() + }) + + it('should handle /css endpoint', async () => { + getCssSpy.mockReturnValue('css-content') + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'GET', + '/css?fileNum=3&importMainCss=true', + ) + + expect(res.status).toBe(200) + expect(res.body).toBe('css-content') + expect(getCssSpy).toHaveBeenCalledWith(3, true) + + coordinator.close() + }) + + it('should handle /css endpoint without fileNum', async () => { + getCssSpy.mockReturnValue('base-css') + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest(port, 'GET', '/css?importMainCss=false') + + expect(res.status).toBe(200) + expect(res.body).toBe('base-css') + expect(getCssSpy).toHaveBeenCalledWith(null, false) + + coordinator.close() + }) + + it('should return 404 for unknown routes', async () => { + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest(port, 'GET', '/unknown') + + expect(res.status).toBe(404) + expect(res.body).toBe('Not Found') + + coordinator.close() + }) + + it('should write port file on startup', async () => { + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + expect(writeFileSyncSpy).toHaveBeenCalledWith( + options.coordinatorPortFile, + expect.any(String), + 'utf-8', + ) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + expect(port).toBeGreaterThan(0) + + coordinator.close() + }) + + it('should close cleanly', async () => { + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + coordinator.close() + + // Server should be closed - double close should be safe + coordinator.close() + }) + + it('should be reset via resetCoordinator while server is active', async () => { + const options = makeOptions() + startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + // resetCoordinator should close the active server + resetCoordinator() + + // Calling again should be safe (server is already null) + resetCoordinator() + }) +}) diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 65eb174f..3a8b3a30 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -159,4 +159,57 @@ describe('devupUICssLoader', () => { // Should call registerTheme with empty object when theme is missing expect(registerThemeSpy).toHaveBeenCalledWith({}) }) + + it('should pass through source as-is in coordinator mode', () => { + const callback = mock() + const addContextDependency = mock() + devupUICssLoader.bind({ + callback, + addContextDependency, + getOptions: () => ({ + ...defaultOptions, + watch: true, + coordinatorPortFile: 'some/coordinator.port', + }), + resourcePath: 'devup-ui-1.css', + } as any)(Buffer.from('css content'), 'existing-map', 'meta') + + // Source should be returned as-is + expect(callback).toHaveBeenCalledWith( + null, + Buffer.from('css content'), + 'existing-map', + 'meta', + ) + + // NO WASM functions should be called + expect(getCssSpy).not.toHaveBeenCalled() + expect(importSheetSpy).not.toHaveBeenCalled() + expect(importClassMapSpy).not.toHaveBeenCalled() + expect(importFileMapSpy).not.toHaveBeenCalled() + expect(registerThemeSpy).not.toHaveBeenCalled() + }) + + it('should NOT be in coordinator mode when coordinatorPortFile is set but watch is false', () => { + const callback = mock() + const addContextDependency = mock() + devupUICssLoader.bind({ + callback, + addContextDependency, + getOptions: () => ({ + ...defaultOptions, + watch: false, + coordinatorPortFile: 'some/coordinator.port', + }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from('data'), '') + + // In non-watch mode, should use normal path (source returned as-is for non-watch) + expect(callback).toHaveBeenCalledWith( + null, + Buffer.from('data'), + '', + undefined, + ) + }) }) diff --git a/packages/next-plugin/src/__tests__/loader.test.ts b/packages/next-plugin/src/__tests__/loader.test.ts index 073a4712..0f8b631d 100644 --- a/packages/next-plugin/src/__tests__/loader.test.ts +++ b/packages/next-plugin/src/__tests__/loader.test.ts @@ -1,5 +1,6 @@ import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' +import * as http from 'node:http' import { join } from 'node:path' import * as wasm from '@devup-ui/wasm' @@ -388,4 +389,445 @@ describe('devupUILoader', () => { // In build mode (watch=false), no CSS files should be written expect(writeFileSpy).not.toHaveBeenCalled() }) + + describe('coordinator mode', () => { + it('should delegate to coordinator via HTTP when coordinatorPortFile exists', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('12345') + + const responseBody = JSON.stringify({ + code: 'coordinator code', + map: '{"version":3}', + cssFile: 'devup-ui-1.css', + updatedBaseStyle: true, + }) + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, callback?: any) => { + // Simulate a response + const fakeRes = { + statusCode: 200, + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') { + handler(Buffer.from(responseBody)) + } + if (event === 'end') { + handler() + } + return fakeRes + }), + } + if (callback) callback(fakeRes) + return { + on: mock(() => ({})), + write: mock(), + end: mock(), + } as any + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(t as any)(Buffer.from('source code'), 'src/App.tsx') + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith(null, 'coordinator code', { + version: 3, + }) + }) + + // Verify HTTP request was made + expect(requestSpy).toHaveBeenCalledTimes(1) + const reqOptions = requestSpy.mock.calls[0]![0] as Record + expect(reqOptions.hostname).toBe('127.0.0.1') + expect(reqOptions.port).toBe(12345) + expect(reqOptions.path).toBe('/extract') + expect(reqOptions.method).toBe('POST') + + // Verify NO WASM functions were called + expect(codeExtractSpy).not.toHaveBeenCalled() + + requestSpy.mockRestore() + }) + + it('should handle coordinator HTTP error', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('12345') + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, _callback?: any) => { + const fakeReq = { + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'error') { + // Trigger error asynchronously + setTimeout(() => handler(new Error('connection refused')), 0) + } + return fakeReq + }), + write: mock(), + end: mock(), + } + return fakeReq as any + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(t as any)(Buffer.from('source code'), 'src/App.tsx') + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith(expect.any(Error)) + }) + + requestSpy.mockRestore() + }) + + it('should handle coordinator non-200 response', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('12345') + + const responseBody = JSON.stringify({ + error: 'extraction failed on server', + }) + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, callback?: any) => { + const fakeRes = { + statusCode: 500, + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') handler(Buffer.from(responseBody)) + if (event === 'end') handler() + return fakeRes + }), + } + if (callback) callback(fakeRes) + return { + on: mock(() => ({})), + write: mock(), + end: mock(), + } as any + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(t as any)(Buffer.from('source code'), 'src/App.tsx') + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + new Error('extraction failed on server'), + ) + }) + + requestSpy.mockRestore() + }) + + it('should handle malformed coordinator response', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('12345') + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, callback?: any) => { + const fakeRes = { + statusCode: 200, + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') handler(Buffer.from('not json')) + if (event === 'end') handler() + return fakeRes + }), + } + if (callback) callback(fakeRes) + return { + on: mock(() => ({})), + write: mock(), + end: mock(), + } as any + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(t as any)(Buffer.from('source code'), 'src/App.tsx') + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith(expect.any(Error)) + }) + + requestSpy.mockRestore() + }) + + it('should retry and error when coordinatorPortFile never appears', async () => { + // existsSync always returns false — port file never appears + existsSyncSpy.mockReturnValue(false) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + watch: true, + singleCss: true, + coordinatorPortFile: 'nonexistent.port', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: 'fallback.tsx', + addDependency: mock(), + } + + devupUILoader.bind(t as any)(Buffer.from('code'), 'fallback.tsx') + + // Retries 20 times × 50ms = 1s max, then calls back with error + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + new Error('Coordinator port file not found'), + ) + }, 3000) + + // WASM should NOT be used — coordinator mode does not fall back + expect(codeExtractSpy).not.toHaveBeenCalled() + }) + + it('should retry and succeed when coordinatorPortFile appears after delay', async () => { + // First few calls: port file doesn't exist, then it appears + let callCount = 0 + existsSyncSpy.mockImplementation((path: string) => { + if (path === 'coordinator.port') { + callCount++ + return callCount > 3 // Appears on 4th check + } + return false + }) + readFileSyncSpy.mockReturnValue('12345') + + const responseBody = JSON.stringify({ + code: 'coordinator code', + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + }) + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, callback?: any) => { + const fakeRes = { + statusCode: 200, + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') handler(Buffer.from(responseBody)) + if (event === 'end') handler() + return fakeRes + }), + } + if (callback) callback(fakeRes) + return { + on: mock(() => ({})), + write: mock(), + end: mock(), + } as any + }, + ) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(t as any)(Buffer.from('code'), 'src/App.tsx') + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + null, + 'coordinator code', + null, + ) + }) + + expect(requestSpy).toHaveBeenCalledTimes(1) + requestSpy.mockRestore() + }) + + it('should handle error when reading coordinator port file fails', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockImplementation((path: string) => { + if (path === 'coordinator.port') { + throw new Error('EACCES: permission denied') + } + return '{}' + }) + + const asyncCallback = mock() + const t = { + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(asyncCallback), + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + addDependency: mock(), + } + + devupUILoader.bind(t as any)(Buffer.from('source code'), 'src/App.tsx') + + await waitFor(() => { + expect(asyncCallback).toHaveBeenCalledWith( + new Error('EACCES: permission denied'), + ) + }) + }) + + it('should cache the coordinator port', async () => { + existsSyncSpy.mockReturnValue(true) + readFileSyncSpy.mockReturnValue('54321') + + const responseBody = JSON.stringify({ + code: 'code', + map: undefined, + cssFile: undefined, + updatedBaseStyle: false, + }) + + const requestSpy = spyOn(http, 'request').mockImplementation( + (_options: any, callback?: any) => { + const fakeRes = { + statusCode: 200, + on: mock((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') handler(Buffer.from(responseBody)) + if (event === 'end') handler() + return fakeRes + }), + } + if (callback) callback(fakeRes) + return { + on: mock(() => ({})), + write: mock(), + end: mock(), + } as any + }, + ) + + const makeContext = () => ({ + getOptions: () => ({ + package: 'package', + cssDir: 'cssDir', + sheetFile: 'sheetFile', + classMapFile: 'classMapFile', + fileMapFile: 'fileMapFile', + themeFile: 'themeFile', + watch: true, + singleCss: true, + coordinatorPortFile: 'coordinator.port', + }), + async: mock().mockReturnValue(mock()), + resourcePath: join(process.cwd(), 'src', 'test.tsx'), + addDependency: mock(), + }) + + // First call reads port from file + devupUILoader.bind(makeContext() as any)(Buffer.from('code'), 'test.tsx') + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledTimes(1) + }) + + // Second call should use cached port (readFileSync called only once for port) + devupUILoader.bind(makeContext() as any)(Buffer.from('code'), 'test.tsx') + await waitFor(() => { + expect(requestSpy).toHaveBeenCalledTimes(2) + }) + + // readFileSync should only be called once for the port file + // (existsSync is called each time, but readFileSync for port file only once due to caching) + const portReads = readFileSyncSpy.mock.calls.filter( + (call: unknown[]) => call[0] === 'coordinator.port', + ) + expect(portReads.length).toBe(1) + + requestSpy.mockRestore() + }) + }) }) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 6498b23c..0bb5d532 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -13,6 +13,7 @@ import { spyOn, } from 'bun:test' +import * as coordinatorModule from '../coordinator' import { DevupUI } from '../plugin' import * as preloadModule from '../preload' @@ -30,6 +31,7 @@ let exportClassMapSpy: ReturnType let exportFileMapSpy: ReturnType let devupUIWebpackPluginSpy: ReturnType let preloadSpy: ReturnType +let startCoordinatorSpy: ReturnType let originalEnv: NodeJS.ProcessEnv let originalFetch: typeof global.fetch @@ -66,6 +68,10 @@ beforeEach(() => { 'DevupUIWebpackPlugin', ).mockImplementation(mock() as never) preloadSpy = spyOn(preloadModule, 'preload').mockReturnValue(undefined) + startCoordinatorSpy = spyOn( + coordinatorModule, + 'startCoordinator', + ).mockReturnValue({ close: mock() as () => void }) originalEnv = { ...process.env } originalFetch = global.fetch @@ -91,6 +97,7 @@ afterEach(() => { exportFileMapSpy.mockRestore() devupUIWebpackPluginSpy.mockRestore() preloadSpy.mockRestore() + startCoordinatorSpy.mockRestore() }) describe('DevupUINextPlugin', () => { @@ -170,6 +177,7 @@ describe('DevupUINextPlugin', () => { loader: '@devup-ui/next-plugin/css-loader', options: { watch: false, + coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), @@ -195,6 +203,7 @@ describe('DevupUINextPlugin', () => { options: { package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), + coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), @@ -252,6 +261,7 @@ describe('DevupUINextPlugin', () => { loader: '@devup-ui/next-plugin/css-loader', options: { watch: false, + coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), @@ -289,6 +299,7 @@ describe('DevupUINextPlugin', () => { options: { package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), + coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), @@ -342,6 +353,7 @@ describe('DevupUINextPlugin', () => { loader: '@devup-ui/next-plugin/css-loader', options: { watch: false, + coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), @@ -379,6 +391,7 @@ describe('DevupUINextPlugin', () => { options: { package: '@devup-ui/react', cssDir: resolve('df', 'devup-ui'), + coordinatorPortFile: join('df', 'coordinator.port'), sheetFile: join('df', 'sheet.json'), classMapFile: join('df', 'classMap.json'), fileMapFile: join('df', 'fileMap.json'), @@ -530,10 +543,9 @@ describe('DevupUINextPlugin', () => { DevupUI({}, { prefix: 'my-prefix' }) expect(setPrefixSpy).toHaveBeenCalledWith('my-prefix') }) - it('should handle debugPort fetch failure in development mode', async () => { + it('should start coordinator in development mode', async () => { process.env.TURBOPACK = '1' ;(process.env as any).NODE_ENV = 'development' - process.env.PORT = '3000' existsSyncSpy .mockReturnValueOnce(true) .mockReturnValueOnce(true) @@ -541,36 +553,65 @@ describe('DevupUINextPlugin', () => { .mockReturnValueOnce(false) writeFileSyncSpy.mockReturnValue(undefined) - // Mock process.exit to prevent actual exit - const originalExit = process.exit - const exitSpy = mock() - process.exit = exitSpy as any - - // Mock process.debugPort - process.debugPort = 9229 - - // Mock fetch globally before calling DevupUI - const fetchMock = mock((url: string | URL) => { - const urlString = typeof url === 'string' ? url : url.toString() - if (urlString.includes('9229')) { - return Promise.reject(new Error('Connection refused')) - } - return Promise.resolve({} as Response) + const closeMock = mock() as () => void + startCoordinatorSpy.mockReturnValue({ close: closeMock }) + + const exitHandlers: (() => void)[] = [] + const processOnSpy = spyOn(process, 'on').mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === 'exit') exitHandlers.push(handler as () => void) + return process + }, + ) + + DevupUI({}) + + // Verify coordinator was started with correct options + expect(startCoordinatorSpy).toHaveBeenCalledWith({ + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + singleCss: false, + sheetFile: join('df', 'sheet.json'), + classMapFile: join('df', 'classMap.json'), + fileMapFile: join('df', 'fileMap.json'), + importAliases: { + '@emotion/styled': 'styled', + '@vanilla-extract/css': null, + 'styled-components': 'styled', + }, + coordinatorPortFile: join('df', 'coordinator.port'), }) - global.fetch = fetchMock as any - try { - DevupUI({}) + // Verify initial CSS file is written + expect(writeFileSyncSpy).toHaveBeenCalledWith( + join(resolve('df', 'devup-ui'), 'devup-ui.css'), + '', + ) - // Wait for the fetch promise to reject and setTimeout to fire (500ms in plugin.ts + buffer) - await new Promise((resolve) => setTimeout(resolve, 600)) + // Verify exit handler was registered and calls coordinator.close() + expect(exitHandlers.length).toBeGreaterThan(0) + exitHandlers[0]!() + expect(closeMock).toHaveBeenCalledTimes(1) - // Verify process.exit was called with code 77 - expect(exitSpy).toHaveBeenCalledWith(77) - } finally { - // Restore - process.exit = originalExit - } + // Verify --inspect-brk env vars are NOT set + expect(process.env.TURBOPACK_DEBUG_JS).toBeUndefined() + expect(process.env.NODE_OPTIONS ?? '').not.toContain('--inspect-brk') + + processOnSpy.mockRestore() + }) + it('should not start coordinator in production mode', async () => { + ;(process.env as any).NODE_ENV = 'production' + process.env.TURBOPACK = '1' + existsSyncSpy + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + + DevupUI({}) + + // Coordinator should NOT be started in production mode + expect(startCoordinatorSpy).not.toHaveBeenCalled() }) }) }) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts new file mode 100644 index 00000000..2711c506 --- /dev/null +++ b/packages/next-plugin/src/coordinator.ts @@ -0,0 +1,196 @@ +import { unlinkSync, writeFile, writeFileSync } from 'node:fs' +import { createServer, type IncomingMessage, type Server } from 'node:http' +import { basename, dirname, join, relative } from 'node:path' + +import { + codeExtract, + exportClassMap, + exportFileMap, + exportSheet, + getCss, +} from '@devup-ui/wasm' + +export interface CoordinatorOptions { + package: string + cssDir: string + singleCss: boolean + sheetFile: string + classMapFile: string + fileMapFile: string + importAliases: Record + coordinatorPortFile: string +} + +function getFileNumFromCssFile(cssFile: string): number | null { + if (cssFile.endsWith('devup-ui.css')) return null + return parseInt(cssFile.split('devup-ui-')[1].split('.')[0]) +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer) => chunks.push(chunk)) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))) + req.on('error', reject) + }) +} + +let server: Server | null = null + +export function startCoordinator(options: CoordinatorOptions): { + close: () => void +} { + const { + package: libPackage, + cssDir, + singleCss, + sheetFile, + classMapFile, + fileMapFile, + importAliases, + coordinatorPortFile, + } = options + + server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`) + + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('ok') + return + } + + if (req.method === 'GET' && url.pathname === '/css') { + const fileNumParam = url.searchParams.get('fileNum') + const importMainCss = url.searchParams.get('importMainCss') === 'true' + const fileNum = fileNumParam != null ? parseInt(fileNumParam) : undefined + res.writeHead(200, { 'Content-Type': 'text/css' }) + res.end(getCss(fileNum ?? null, importMainCss)) + return + } + + if (req.method === 'POST' && url.pathname === '/extract') { + try { + const body = JSON.parse(await readBody(req)) + const { filename, code, resourcePath } = body as { + filename: string + code: string + resourcePath: string + } + + let relCssDir = relative(dirname(resourcePath), cssDir).replaceAll( + '\\', + '/', + ) + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + + const result = codeExtract( + filename, + code, + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ) + + const promises: Promise[] = [] + + if (result.updatedBaseStyle) { + promises.push( + new Promise((resolve, reject) => + writeFile( + join(cssDir, 'devup-ui.css'), + getCss(null, false), + 'utf-8', + (err) => (err ? reject(err) : resolve()), + ), + ), + ) + } + + if (result.cssFile) { + const fileNum = getFileNumFromCssFile(result.cssFile) + promises.push( + new Promise((resolve, reject) => + writeFile( + join(cssDir, basename(result.cssFile!)), + getCss(fileNum, true), + 'utf-8', + (err) => (err ? reject(err) : resolve()), + ), + ), + new Promise((resolve, reject) => + writeFile(sheetFile, exportSheet(), 'utf-8', (err) => + err ? reject(err) : resolve(), + ), + ), + new Promise((resolve, reject) => + writeFile(classMapFile, exportClassMap(), 'utf-8', (err) => + err ? reject(err) : resolve(), + ), + ), + new Promise((resolve, reject) => + writeFile(fileMapFile, exportFileMap(), 'utf-8', (err) => + err ? reject(err) : resolve(), + ), + ), + ) + } + + await Promise.all(promises) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + code: result.code, + map: result.map, + cssFile: result.cssFile, + updatedBaseStyle: result.updatedBaseStyle, + }), + ) + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + ) + } + return + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + }) + + server.listen(0, '127.0.0.1', () => { + const addr = server!.address() + if (addr && typeof addr !== 'string') { + writeFileSync(coordinatorPortFile, String(addr.port), 'utf-8') + } + }) + + return { + close: () => { + if (server) { + server.close() + server = null + try { + unlinkSync(coordinatorPortFile) + } catch { + // ignore if already deleted + } + } + }, + } +} + +/** @internal Reset coordinator state for testing purposes only */ +export const resetCoordinator = () => { + if (server) { + server.close() + server = null + } +} diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index 59ff5bdd..fb49d179 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -17,6 +17,7 @@ function getFileNumByFilename(filename: string) { export interface DevupUICssLoaderOptions { // turbo watch: boolean + coordinatorPortFile?: string sheetFile: string classMapFile: string fileMapFile: string @@ -33,6 +34,7 @@ const devupUICssLoader: RawLoaderDefinitionFunction = function (source, map, meta) { const { watch, + coordinatorPortFile, sheetFile, classMapFile, fileMapFile, @@ -43,6 +45,12 @@ const devupUICssLoader: RawLoaderDefinitionFunction = defaultSheet, } = this.getOptions() + // Coordinator mode: CSS already written by coordinator, pass through as-is + if (coordinatorPortFile && watch) { + this.callback(null, source, map, meta) + return + } + if (!init) { init = true if (watch) { diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index cc7d2ba3..f10e1b21 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -1,130 +1,218 @@ -import { existsSync, readFileSync } from 'node:fs' -import { writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative } from 'node:path' - -import { - codeExtract, - exportClassMap, - exportFileMap, - exportSheet, - getCss, - importClassMap, - importFileMap, - importSheet, - registerTheme, -} from '@devup-ui/wasm' -import type { RawLoaderDefinitionFunction } from 'webpack' - -export interface DevupUILoaderOptions { - package: string - cssDir: string - sheetFile: string - classMapFile: string - fileMapFile: string - themeFile: string - watch: boolean - singleCss: boolean - // turbo - theme?: object - defaultSheet: object - defaultClassMap: object - defaultFileMap: object - importAliases?: Record -} -let init = false - -const devupUILoader: RawLoaderDefinitionFunction = - function (source) { - const { - watch, - package: libPackage, - cssDir, - sheetFile, - classMapFile, - fileMapFile, - themeFile, - singleCss, - theme, - defaultClassMap, - defaultFileMap, - defaultSheet, - importAliases = {}, - } = this.getOptions() - - const promises: Promise[] = [] - if (!init) { - init = true - if (watch) { - // restart loader issue - // loader should read files when they exist in watch mode - if (existsSync(sheetFile)) - importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) - if (existsSync(classMapFile)) - importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) - if (existsSync(fileMapFile)) - importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) - if (existsSync(themeFile)) - registerTheme( - JSON.parse(readFileSync(themeFile, 'utf-8'))?.['theme'] ?? {}, - ) - } else { - importFileMap(defaultFileMap) - importClassMap(defaultClassMap) - importSheet(defaultSheet) - registerTheme(theme) - } - } - - const callback = this.async() - try { - const id = this.resourcePath - let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') - - const relativePath = relative(process.cwd(), id) - - if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - const { code, map, cssFile, updatedBaseStyle } = codeExtract( - relativePath, - source.toString(), - libPackage, - relCssDir, - singleCss, - false, - true, - importAliases, - ) - const sourceMap = map ? JSON.parse(map) : null - if (updatedBaseStyle && watch) { - // update base style - promises.push( - writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), - ) - } - if (cssFile && watch) { - // don't write file when build - promises.push( - writeFile( - join(cssDir, basename(cssFile!)), - `/* ${this.resourcePath} ${Date.now()} */`, - ), - writeFile(sheetFile, exportSheet()), - writeFile(classMapFile, exportClassMap()), - writeFile(fileMapFile, exportFileMap()), - ) - } - Promise.all(promises) - .catch(console.error) - .finally(() => callback(null, code, sourceMap)) - } catch (error) { - Promise.all(promises) - .catch(console.error) - .finally(() => callback(error as Error)) - } - return - } -export default devupUILoader - -/** @internal Reset init state for testing purposes only */ -export const resetInit = () => { - init = false -} +import { existsSync, readFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { Agent, request } from 'node:http' +import { basename, dirname, join, relative } from 'node:path' + +import { + codeExtract, + exportClassMap, + exportFileMap, + exportSheet, + getCss, + importClassMap, + importFileMap, + importSheet, + registerTheme, +} from '@devup-ui/wasm' +import type { RawLoaderDefinitionFunction } from 'webpack' + +export interface DevupUILoaderOptions { + package: string + cssDir: string + sheetFile: string + classMapFile: string + fileMapFile: string + themeFile: string + watch: boolean + singleCss: boolean + coordinatorPortFile?: string + // turbo + theme?: object + defaultSheet: object + defaultClassMap: object + defaultFileMap: object + importAliases?: Record +} +let init = false + +let cachedPort: number | null = null +const keepAliveAgent = new Agent({ keepAlive: true }) + +function readCoordinatorPort(portFile: string): number { + if (cachedPort !== null) return cachedPort + cachedPort = parseInt(readFileSync(portFile, 'utf-8').trim()) + return cachedPort +} + +function coordinatorExtract( + port: number, + body: string, + callback: ( + err: Error | null, + content?: string, + sourceMap?: object | null, + ) => void, +): void { + const req = request( + { + hostname: '127.0.0.1', + port, + path: '/extract', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + agent: keepAliveAgent, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + try { + const data = JSON.parse(Buffer.concat(chunks).toString('utf-8')) + if (res.statusCode !== 200) { + callback(new Error(data.error ?? 'Coordinator error')) + return + } + const sourceMap = data.map ? JSON.parse(data.map) : null + callback(null, data.code, sourceMap) + } catch (e) { + callback(e instanceof Error ? e : new Error(String(e))) + } + }) + }, + ) + req.on('error', (err) => callback(err)) + req.write(body) + req.end() +} + +const devupUILoader: RawLoaderDefinitionFunction = + function (source) { + const { + watch, + package: libPackage, + cssDir, + sheetFile, + classMapFile, + fileMapFile, + themeFile, + singleCss, + coordinatorPortFile, + theme, + defaultClassMap, + defaultFileMap, + defaultSheet, + importAliases = {}, + } = this.getOptions() + + // Coordinator mode: delegate to HTTP server in dev mode + if (coordinatorPortFile && watch) { + const callback = this.async() + const tryCoordinator = (retries: number) => { + if (!existsSync(coordinatorPortFile)) { + if (retries > 0) { + setTimeout(() => tryCoordinator(retries - 1), 50) + return + } + // Port file never appeared — fall through to error + callback(new Error('Coordinator port file not found')) + return + } + try { + const port = readCoordinatorPort(coordinatorPortFile) + const relativePath = relative(process.cwd(), this.resourcePath) + const body = JSON.stringify({ + filename: relativePath, + code: source.toString(), + resourcePath: this.resourcePath, + }) + coordinatorExtract(port, body, (err, content, sourceMap) => { + if (err) return callback(err) + callback(null, content, sourceMap as Parameters[2]) + }) + } catch (error) { + callback(error as Error) + } + } + tryCoordinator(20) // 20 retries × 50ms = 1s max wait + return + } + + // Non-coordinator mode: local WASM extraction + const promises: Promise[] = [] + if (!init) { + init = true + if (watch) { + // restart loader issue + // loader should read files when they exist in watch mode + if (existsSync(sheetFile)) + importSheet(JSON.parse(readFileSync(sheetFile, 'utf-8'))) + if (existsSync(classMapFile)) + importClassMap(JSON.parse(readFileSync(classMapFile, 'utf-8'))) + if (existsSync(fileMapFile)) + importFileMap(JSON.parse(readFileSync(fileMapFile, 'utf-8'))) + if (existsSync(themeFile)) + registerTheme( + JSON.parse(readFileSync(themeFile, 'utf-8'))?.['theme'] ?? {}, + ) + } else { + importFileMap(defaultFileMap) + importClassMap(defaultClassMap) + importSheet(defaultSheet) + registerTheme(theme) + } + } + + const callback = this.async() + try { + const id = this.resourcePath + let relCssDir = relative(dirname(id), cssDir).replaceAll('\\', '/') + + const relativePath = relative(process.cwd(), id) + + if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` + const { code, map, cssFile, updatedBaseStyle } = codeExtract( + relativePath, + source.toString(), + libPackage, + relCssDir, + singleCss, + false, + true, + importAliases, + ) + const sourceMap = map ? JSON.parse(map) : null + if (updatedBaseStyle && watch) { + // update base style + promises.push( + writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'), + ) + } + if (cssFile && watch) { + // don't write file when build + promises.push( + writeFile( + join(cssDir, basename(cssFile!)), + `/* ${this.resourcePath} ${Date.now()} */`, + ), + writeFile(sheetFile, exportSheet()), + writeFile(classMapFile, exportClassMap()), + writeFile(fileMapFile, exportFileMap()), + ) + } + Promise.all(promises) + .catch(console.error) + .finally(() => callback(null, code, sourceMap)) + } catch (error) { + Promise.all(promises) + .catch(console.error) + .finally(() => callback(error as Error)) + } + return + } +export default devupUILoader + +/** @internal Reset init state for testing purposes only */ +export const resetInit = () => { + init = false + cachedPort = null +} diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index bc4d7a6e..2a693d3b 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -18,6 +18,7 @@ import { } from '@devup-ui/webpack-plugin' import { type NextConfig } from 'next' +import { startCoordinator } from './coordinator' import { preload } from './preload' type DevupUiNextPluginOptions = Omit< @@ -92,19 +93,30 @@ export function DevupUI( .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, ) + const coordinatorPortFile = join(distDir, 'coordinator.port') + if (process.env.NODE_ENV !== 'production') { - // check if debugger is attached - fetch('http://localhost:' + process.env.PORT) - fetch('http://localhost:' + process.debugPort).catch(() => { - setTimeout(() => { - process.exit(77) - }, 500) - }) - process.env.TURBOPACK_DEBUG_JS = '*' - process.env.NODE_OPTIONS ??= '' - process.env.NODE_OPTIONS += ' --inspect-brk' // create devup-ui.css file writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) + + const coordinator = startCoordinator({ + package: libPackage, + cssDir, + singleCss, + sheetFile, + classMapFile, + fileMapFile, + importAliases: importAliases as unknown as Record< + string, + string | null + >, + coordinatorPortFile, + }) + + // Cleanup on exit + process.on('exit', () => { + coordinator.close() + }) } else { // build preload( @@ -135,6 +147,7 @@ export function DevupUI( loader: '@devup-ui/next-plugin/css-loader', options: { watch: process.env.NODE_ENV === 'development', + coordinatorPortFile, sheetFile, classMapFile, fileMapFile, @@ -153,6 +166,7 @@ export function DevupUI( options: { package: libPackage, cssDir, + coordinatorPortFile, sheetFile, classMapFile, fileMapFile, From f27c83225164edee7bff217f5e70988c40c7999f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 11 Feb 2026 18:16:34 +0900 Subject: [PATCH 2/8] Fix build issue --- bun.lock | 1 - packages/next-plugin/package.json | 3 +- .../src/__tests__/coordinator.test.ts | 216 ++++++++++++++ .../src/__tests__/css-loader.test.ts | 275 +++++++++++++++--- .../next-plugin/src/__tests__/plugin.test.ts | 48 +-- packages/next-plugin/src/coordinator.ts | 59 +++- packages/next-plugin/src/css-loader.ts | 88 +++++- packages/next-plugin/src/loader.ts | 4 +- packages/next-plugin/src/plugin.ts | 48 +-- packages/next-plugin/src/preload.ts | 77 ----- 10 files changed, 630 insertions(+), 189 deletions(-) delete mode 100644 packages/next-plugin/src/preload.ts diff --git a/bun.lock b/bun.lock index 4abdf780..8c7ae436 100644 --- a/bun.lock +++ b/bun.lock @@ -438,7 +438,6 @@ "@devup-ui/wasm": "workspace:^", "@devup-ui/webpack-plugin": "workspace:^", "next": "^16.1", - "tinyglobby": "^0.2", }, "devDependencies": { "@types/webpack": "^5.28", diff --git a/packages/next-plugin/package.json b/packages/next-plugin/package.json index 05d48da3..884273c2 100644 --- a/packages/next-plugin/package.json +++ b/packages/next-plugin/package.json @@ -55,8 +55,7 @@ "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/webpack-plugin": "workspace:^", "next": "^16.1", - "@devup-ui/wasm": "workspace:^", - "tinyglobby": "^0.2" + "@devup-ui/wasm": "workspace:^" }, "devDependencies": { "typescript": "^5.9", diff --git a/packages/next-plugin/src/__tests__/coordinator.test.ts b/packages/next-plugin/src/__tests__/coordinator.test.ts index dad43b31..ec089658 100644 --- a/packages/next-plugin/src/__tests__/coordinator.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator.test.ts @@ -175,6 +175,139 @@ describe('coordinator', () => { coordinator.close() }) + it('should rewrite per-file CSS imports when singleCss=false', async () => { + codeExtractSpy.mockReturnValue({ + code: 'import "./../../df/devup-ui/devup-ui-79.css";\nimport "./../../df/devup-ui/devup-ui-3.css";\nconst x = 1;', + map: '{"version":3}', + cssFile: 'devup-ui-79.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('file-css') + exportSheetSpy.mockReturnValue('{}') + exportClassMapSpy.mockReturnValue('{}') + exportFileMapSpy.mockReturnValue('{}') + + const options = makeOptions({ singleCss: false }) + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + const data = JSON.parse(res.body) + // Verify imports were rewritten from devup-ui-N.css to devup-ui.css?fileNum=N + expect(data.code).toContain('devup-ui.css?fileNum=79') + expect(data.code).toContain('devup-ui.css?fileNum=3') + expect(data.code).not.toContain('devup-ui-79.css') + expect(data.code).not.toContain('devup-ui-3.css') + + coordinator.close() + }) + + it('should NOT rewrite CSS imports when singleCss=true', async () => { + codeExtractSpy.mockReturnValue({ + code: 'import "./../../df/devup-ui/devup-ui.css";\nconst x = 1;', + map: undefined, + cssFile: 'devup-ui.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('all-styles') + exportSheetSpy.mockReturnValue('{}') + exportClassMapSpy.mockReturnValue('{}') + exportFileMapSpy.mockReturnValue('{}') + + const options = makeOptions({ singleCss: true }) + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + const data = JSON.parse(res.body) + // singleCss=true: no rewriting should happen, devup-ui.css stays as-is + expect(data.code).toContain('devup-ui.css') + expect(data.code).not.toContain('?fileNum=') + + coordinator.close() + }) + + it('should handle /extract in singleCss mode (cssFile is devup-ui.css)', async () => { + codeExtractSpy.mockReturnValue({ + code: 'single css code', + map: undefined, + cssFile: 'devup-ui.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('all-styles') + exportSheetSpy.mockReturnValue('sheet-json') + exportClassMapSpy.mockReturnValue('classmap-json') + exportFileMapSpy.mockReturnValue('filemap-json') + + const options = makeOptions({ singleCss: true }) + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + const res = await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/App.tsx', + code: 'const x = ', + resourcePath: join(process.cwd(), 'src', 'App.tsx'), + }), + ) + + expect(res.status).toBe(200) + const data = JSON.parse(res.body) + expect(data.code).toBe('single css code') + expect(data.cssFile).toBe('devup-ui.css') + + // Verify getCss was called with (null, true) for the CSS file write + expect(getCssSpy).toHaveBeenCalledWith(null, true) + + // Verify files were written (CSS + sheet + classmap + filemap, no base style update) + expect(writeFileSpy).toHaveBeenCalledTimes(4) + + coordinator.close() + }) + it('should handle /extract when no CSS file produced', async () => { codeExtractSpy.mockReturnValue({ code: 'no style code', @@ -290,6 +423,89 @@ describe('coordinator', () => { coordinator.close() }) + it('should handle /css with waitForIdle after extractions complete', async () => { + codeExtractSpy.mockReturnValue({ + code: 'code', + map: undefined, + cssFile: 'devup-ui.css', + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + }) + getCssSpy.mockReturnValue('complete-css') + exportSheetSpy.mockReturnValue('{}') + exportClassMapSpy.mockReturnValue('{}') + exportFileMapSpy.mockReturnValue('{}') + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + // Do an extraction first so totalExtractions > 0 + await httpRequest( + port, + 'POST', + '/extract', + JSON.stringify({ + filename: 'src/A.tsx', + code: 'code', + resourcePath: join(process.cwd(), 'src', 'A.tsx'), + }), + ) + + // Now request CSS with waitForIdle — should resolve after idle threshold + const res = await httpRequest( + port, + 'GET', + '/css?importMainCss=false&waitForIdle=true', + ) + + expect(res.status).toBe(200) + expect(res.body).toBe('complete-css') + + coordinator.close() + }) + + it('should handle /css with waitForIdle timeout when no extractions happen', async () => { + getCssSpy.mockReturnValue('timeout-css') + + const options = makeOptions() + const coordinator = startCoordinator(options) + + await new Promise((r) => setTimeout(r, 100)) + + const portStr = (writeFileSyncSpy.mock.calls[0] as [string, string])[1] + const port = parseInt(portStr) + + // Mock Date.now to simulate time passing beyond MAX_WAIT_MS (30s) + let callCount = 0 + const dateNowSpy = spyOn(Date, 'now').mockImplementation(() => { + callCount++ + // First call is `const start = Date.now()` → return 0 + // Subsequent calls return past MAX_WAIT_MS threshold + if (callCount <= 1) return 0 + return 31_000 + }) + + // Request CSS with waitForIdle=true but no extractions ever happen + // (totalExtractions === 0), so it should timeout and return CSS anyway + const res = await httpRequest( + port, + 'GET', + '/css?importMainCss=false&waitForIdle=true', + ) + + expect(res.status).toBe(200) + expect(res.body).toBe('timeout-css') + + dateNowSpy.mockRestore() + coordinator.close() + }) + it('should return 404 for unknown routes', async () => { const options = makeOptions() const coordinator = startCoordinator(options) diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index 3a8b3a30..c267b2c6 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs' +import * as http from 'node:http' import * as wasm from '@devup-ui/wasm' import { @@ -160,56 +161,252 @@ describe('devupUICssLoader', () => { expect(registerThemeSpy).toHaveBeenCalledWith({}) }) - it('should pass through source as-is in coordinator mode', () => { - const callback = mock() - const addContextDependency = mock() - devupUICssLoader.bind({ - callback, - addContextDependency, - getOptions: () => ({ - ...defaultOptions, - watch: true, - coordinatorPortFile: 'some/coordinator.port', - }), - resourcePath: 'devup-ui-1.css', - } as any)(Buffer.from('css content'), 'existing-map', 'meta') - - // Source should be returned as-is - expect(callback).toHaveBeenCalledWith( - null, - Buffer.from('css content'), - 'existing-map', - 'meta', + it('should fetch CSS from coordinator in coordinator mode', async () => { + // Start a mock coordinator server + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/css' }) + res.end('.a{color:red}') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as { port: number }).port + const portFile = `test-coordinator-${port}.port` + + existsSyncSpy.mockImplementation((p: unknown) => p === portFile) + readFileSyncSpy.mockImplementation((p: unknown) => + p === portFile ? String(port) : '{}', ) + const result = await new Promise((resolve, reject) => { + const asyncCallback = mock((err: Error | null, content?: string) => { + if (err) reject(err) + else resolve(content!) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + watch: true, + coordinatorPortFile: portFile, + }), + resourcePath: 'devup-ui-1.css', + } as any)(Buffer.from('stale content'), 'existing-map', 'meta') + }) + + expect(result).toBe('.a{color:red}') + // NO WASM functions should be called expect(getCssSpy).not.toHaveBeenCalled() expect(importSheetSpy).not.toHaveBeenCalled() expect(importClassMapSpy).not.toHaveBeenCalled() expect(importFileMapSpy).not.toHaveBeenCalled() expect(registerThemeSpy).not.toHaveBeenCalled() + + server.close() }) - it('should NOT be in coordinator mode when coordinatorPortFile is set but watch is false', () => { - const callback = mock() - const addContextDependency = mock() - devupUICssLoader.bind({ - callback, - addContextDependency, - getOptions: () => ({ - ...defaultOptions, - watch: false, - coordinatorPortFile: 'some/coordinator.port', - }), - resourcePath: 'devup-ui.css', - } as any)(Buffer.from('data'), '') + it('should fetch CSS from coordinator for devup-ui.css (singleCss)', async () => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`) + expect(url.searchParams.get('importMainCss')).toBe('false') + res.writeHead(200, { 'Content-Type': 'text/css' }) + res.end('.full{display:flex}') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as { port: number }).port + const portFile = `test-coordinator-${port}.port` - // In non-watch mode, should use normal path (source returned as-is for non-watch) - expect(callback).toHaveBeenCalledWith( - null, - Buffer.from('data'), - '', - undefined, + existsSyncSpy.mockImplementation((p: unknown) => p === portFile) + readFileSyncSpy.mockImplementation((p: unknown) => + p === portFile ? String(port) : '{}', + ) + + const result = await new Promise((resolve, reject) => { + const asyncCallback = mock((err: Error | null, content?: string) => { + if (err) reject(err) + else resolve(content!) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + watch: false, + coordinatorPortFile: portFile, + }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from('stale'), '', '') + }) + + expect(result).toBe('.full{display:flex}') + expect(getCssSpy).not.toHaveBeenCalled() + expect(importSheetSpy).not.toHaveBeenCalled() + + server.close() + }) + + it('should fetch CSS from coordinator with ?fileNum query in resourcePath', async () => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`) + // Should receive fileNum=79 and importMainCss=true + expect(url.searchParams.get('fileNum')).toBe('79') + expect(url.searchParams.get('importMainCss')).toBe('true') + res.writeHead(200, { 'Content-Type': 'text/css' }) + res.end('.file79{color:blue}') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as { port: number }).port + const portFile = `test-coordinator-query-${port}.port` + + existsSyncSpy.mockImplementation((p: unknown) => p === portFile) + readFileSyncSpy.mockImplementation((p: unknown) => + p === portFile ? String(port) : '{}', + ) + + const result = await new Promise((resolve, reject) => { + const asyncCallback = mock((err: Error | null, content?: string) => { + if (err) reject(err) + else resolve(content!) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + watch: true, + coordinatorPortFile: portFile, + }), + // Turbopack embeds query in resourcePath + resourcePath: '/path/to/df/devup-ui/devup-ui.css?fileNum=79', + } as any)(Buffer.from('stale content'), 'existing-map', 'meta') + }) + + expect(result).toBe('.file79{color:blue}') + expect(getCssSpy).not.toHaveBeenCalled() + + server.close() + }) + + it('should fetch CSS from coordinator with ?fileNum in resourceQuery', async () => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host}`) + expect(url.searchParams.get('fileNum')).toBe('3') + expect(url.searchParams.get('importMainCss')).toBe('true') + res.writeHead(200, { 'Content-Type': 'text/css' }) + res.end('.file3{color:green}') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as { port: number }).port + const portFile = `test-coordinator-rq-${port}.port` + + existsSyncSpy.mockImplementation((p: unknown) => p === portFile) + readFileSyncSpy.mockImplementation((p: unknown) => + p === portFile ? String(port) : '{}', + ) + + const result = await new Promise((resolve, reject) => { + const asyncCallback = mock((err: Error | null, content?: string) => { + if (err) reject(err) + else resolve(content!) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + watch: false, + coordinatorPortFile: portFile, + }), + // resourcePath without query, query in separate property + resourcePath: '/path/to/df/devup-ui/devup-ui.css', + resourceQuery: '?fileNum=3', + } as any)(Buffer.from('stale'), '', '') + }) + + expect(result).toBe('.file3{color:green}') + expect(getCssSpy).not.toHaveBeenCalled() + + server.close() + }) + + it('should error when coordinator port file never appears', async () => { + existsSyncSpy.mockReturnValue(false) + + const error = await new Promise((resolve) => { + const asyncCallback = mock((err: Error | null) => { + if (err) resolve(err) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + coordinatorPortFile: 'nonexistent.port', + }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from(''), '', '') + }) + + expect(error.message).toBe('Coordinator port file not found') + }) + + it('should error when coordinator returns non-200 status', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(500, { 'Content-Type': 'text/plain' }) + res.end('Internal Server Error') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as { port: number }).port + const portFile = `test-coordinator-err-${port}.port` + + existsSyncSpy.mockImplementation((p: unknown) => p === portFile) + readFileSyncSpy.mockImplementation((p: unknown) => + p === portFile ? String(port) : '{}', ) + + const error = await new Promise((resolve) => { + const asyncCallback = mock((err: Error | null) => { + if (err) resolve(err) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + coordinatorPortFile: portFile, + }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from(''), '', '') + }) + + expect(error.message).toBe('Coordinator CSS error: 500') + server.close() + }) + + it('should error when coordinator connection fails', async () => { + const portFile = `test-coordinator-conn-err.port` + + existsSyncSpy.mockImplementation((p: unknown) => p === portFile) + // Use a port that nothing listens on + readFileSyncSpy.mockImplementation((p: unknown) => + p === portFile ? '19999' : '{}', + ) + + const error = await new Promise((resolve) => { + const asyncCallback = mock((err: Error | null) => { + if (err) resolve(err) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + coordinatorPortFile: portFile, + }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from(''), '', '') + }) + + expect(error).toBeInstanceOf(Error) }) }) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 0bb5d532..3f483d21 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -15,7 +15,6 @@ import { import * as coordinatorModule from '../coordinator' import { DevupUI } from '../plugin' -import * as preloadModule from '../preload' let existsSyncSpy: ReturnType let mkdirSyncSpy: ReturnType @@ -30,7 +29,6 @@ let exportSheetSpy: ReturnType let exportClassMapSpy: ReturnType let exportFileMapSpy: ReturnType let devupUIWebpackPluginSpy: ReturnType -let preloadSpy: ReturnType let startCoordinatorSpy: ReturnType let originalEnv: NodeJS.ProcessEnv @@ -67,7 +65,6 @@ beforeEach(() => { webpackPluginModule, 'DevupUIWebpackPlugin', ).mockImplementation(mock() as never) - preloadSpy = spyOn(preloadModule, 'preload').mockReturnValue(undefined) startCoordinatorSpy = spyOn( coordinatorModule, 'startCoordinator', @@ -96,7 +93,6 @@ afterEach(() => { exportClassMapSpy.mockRestore() exportFileMapSpy.mockRestore() devupUIWebpackPluginSpy.mockRestore() - preloadSpy.mockRestore() startCoordinatorSpy.mockRestore() }) @@ -429,32 +425,34 @@ describe('DevupUINextPlugin', () => { '*', ) }) - it('should throw error if NODE_ENV is production', () => { + it('should start coordinator even in production mode', () => { ;(process.env as any).NODE_ENV = 'production' process.env.TURBOPACK = '1' - preloadSpy.mockReturnValue(undefined) + existsSyncSpy + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) const ret = DevupUI({}) expect(ret).toEqual({ turbopack: { rules: expect.any(Object), }, }) - expect(preloadSpy).toHaveBeenCalledWith( - new RegExp( - `(node_modules(?!.*(${['@devup-ui'] - .join('|') - .replaceAll('/', '[\\/\\\\_]')})([\\/\\\\.]|$)))|(.mdx.[tj]sx?$)`, - ), - '@devup-ui/react', - false, - expect.any(String), - [], - { + expect(startCoordinatorSpy).toHaveBeenCalledWith({ + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + singleCss: false, + sheetFile: join('df', 'sheet.json'), + classMapFile: join('df', 'classMap.json'), + fileMapFile: join('df', 'fileMap.json'), + importAliases: { '@emotion/styled': 'styled', '@vanilla-extract/css': null, 'styled-components': 'styled', }, - ) + coordinatorPortFile: join('df', 'coordinator.port'), + }) }) it('should create theme.d.ts file', async () => { process.env.TURBOPACK = '1' @@ -599,19 +597,5 @@ describe('DevupUINextPlugin', () => { processOnSpy.mockRestore() }) - it('should not start coordinator in production mode', async () => { - ;(process.env as any).NODE_ENV = 'production' - process.env.TURBOPACK = '1' - existsSyncSpy - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false) - - DevupUI({}) - - // Coordinator should NOT be started in production mode - expect(startCoordinatorSpy).not.toHaveBeenCalled() - }) }) }) diff --git a/packages/next-plugin/src/coordinator.ts b/packages/next-plugin/src/coordinator.ts index 2711c506..ab223804 100644 --- a/packages/next-plugin/src/coordinator.ts +++ b/packages/next-plugin/src/coordinator.ts @@ -37,6 +37,37 @@ function readBody(req: IncomingMessage): Promise { let server: Server | null = null +// Extraction tracking for waitForIdle +let activeExtractions = 0 +let totalExtractions = 0 +let lastCompletedAt = 0 +const IDLE_THRESHOLD_MS = 500 +const MAX_WAIT_MS = 30_000 + +function waitForIdle(): Promise { + const start = Date.now() + return new Promise((resolve) => { + const check = () => { + const now = Date.now() + if (now - start > MAX_WAIT_MS) { + // Timeout — return whatever CSS we have + resolve() + return + } + if ( + totalExtractions > 0 && + activeExtractions === 0 && + now - lastCompletedAt >= IDLE_THRESHOLD_MS + ) { + resolve() + return + } + setTimeout(check, 50) + } + check() + }) +} + export function startCoordinator(options: CoordinatorOptions): { close: () => void } { @@ -63,13 +94,20 @@ export function startCoordinator(options: CoordinatorOptions): { if (req.method === 'GET' && url.pathname === '/css') { const fileNumParam = url.searchParams.get('fileNum') const importMainCss = url.searchParams.get('importMainCss') === 'true' + const shouldWait = url.searchParams.get('waitForIdle') === 'true' const fileNum = fileNumParam != null ? parseInt(fileNumParam) : undefined + + if (shouldWait) { + await waitForIdle() + } + res.writeHead(200, { 'Content-Type': 'text/css' }) res.end(getCss(fileNum ?? null, importMainCss)) return } if (req.method === 'POST' && url.pathname === '/extract') { + activeExtractions++ try { const body = JSON.parse(await readBody(req)) const { filename, code, resourcePath } = body as { @@ -95,6 +133,18 @@ export function startCoordinator(options: CoordinatorOptions): { importAliases, ) + // When singleCss=false, rewrite per-file CSS imports so Turbopack can resolve them. + // Instead of importing "devup-ui-79.css" (which doesn't exist as a resolvable module), + // rewrite to "devup-ui.css?fileNum=79" — the placeholder file exists and the query + // makes each import a unique module for Turbopack. + let transformedCode = result.code + if (!singleCss && transformedCode) { + transformedCode = transformedCode.replace( + /devup-ui-(\d+)\.css/g, + 'devup-ui.css?fileNum=$1', + ) + } + const promises: Promise[] = [] if (result.updatedBaseStyle) { @@ -144,7 +194,7 @@ export function startCoordinator(options: CoordinatorOptions): { res.writeHead(200, { 'Content-Type': 'application/json' }) res.end( JSON.stringify({ - code: result.code, + code: transformedCode, map: result.map, cssFile: result.cssFile, updatedBaseStyle: result.updatedBaseStyle, @@ -157,6 +207,10 @@ export function startCoordinator(options: CoordinatorOptions): { error: error instanceof Error ? error.message : String(error), }), ) + } finally { + activeExtractions-- + totalExtractions++ + lastCompletedAt = Date.now() } return } @@ -193,4 +247,7 @@ export const resetCoordinator = () => { server.close() server = null } + activeExtractions = 0 + totalExtractions = 0 + lastCompletedAt = 0 } diff --git a/packages/next-plugin/src/css-loader.ts b/packages/next-plugin/src/css-loader.ts index fb49d179..96912602 100644 --- a/packages/next-plugin/src/css-loader.ts +++ b/packages/next-plugin/src/css-loader.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from 'node:fs' +import { Agent, request } from 'node:http' import { getCss, @@ -10,6 +11,10 @@ import { import type { RawLoaderDefinitionFunction } from 'webpack' function getFileNumByFilename(filename: string) { + // Handle query parameter format: devup-ui.css?fileNum=79 + // Turbopack may embed query params in resourcePath + const queryMatch = filename.match(/[?&]fileNum=(\d+)/) + if (queryMatch) return parseInt(queryMatch[1]) if (filename.endsWith('devup-ui.css')) return null return parseInt(filename.split('devup-ui-')[1].split('.')[0]) } @@ -29,6 +34,49 @@ export interface DevupUICssLoaderOptions { } let init = false +let cachedPort: number | null = null +const keepAliveAgent = new Agent({ keepAlive: true }) + +function readCoordinatorPort(portFile: string): number { + if (cachedPort !== null) return cachedPort + cachedPort = parseInt(readFileSync(portFile, 'utf-8').trim()) + return cachedPort +} + +function fetchCssFromCoordinator( + port: number, + fileNum: number | null, + importMainCss: boolean, + waitForIdle: boolean, + callback: (err: Error | null, css?: string) => void, +): void { + const params = new URLSearchParams() + if (fileNum != null) params.set('fileNum', String(fileNum)) + params.set('importMainCss', String(importMainCss)) + if (waitForIdle) params.set('waitForIdle', 'true') + const req = request( + { + hostname: '127.0.0.1', + port, + path: `/css?${params.toString()}`, + method: 'GET', + agent: keepAliveAgent, + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + if (res.statusCode !== 200) { + callback(new Error(`Coordinator CSS error: ${res.statusCode}`)) + return + } + callback(null, Buffer.concat(chunks).toString('utf-8')) + }) + }, + ) + req.on('error', (err) => callback(err)) + req.end() +} const devupUICssLoader: RawLoaderDefinitionFunction = function (source, map, meta) { @@ -45,9 +93,42 @@ const devupUICssLoader: RawLoaderDefinitionFunction = defaultSheet, } = this.getOptions() - // Coordinator mode: CSS already written by coordinator, pass through as-is - if (coordinatorPortFile && watch) { - this.callback(null, source, map, meta) + // Coordinator mode: fetch live CSS from coordinator (disk content is stale) + if (coordinatorPortFile) { + const callback = this.async() + // Check both resourcePath and resourceQuery for ?fileNum=N + // Turbopack may embed query in resourcePath or provide it via resourceQuery + const resourceQuery = + (this as unknown as { resourceQuery?: string }).resourceQuery ?? '' + const pathWithQuery = this.resourcePath + resourceQuery + const fileNum = getFileNumByFilename(pathWithQuery) + const importMainCss = fileNum !== null + const tryFetch = (retries: number) => { + if (!existsSync(coordinatorPortFile)) { + if (retries > 0) { + setTimeout(() => tryFetch(retries - 1), 50) + return + } + callback(new Error('Coordinator port file not found')) + return + } + try { + const port = readCoordinatorPort(coordinatorPortFile) + fetchCssFromCoordinator( + port, + fileNum, + importMainCss, + !watch, + (err, css) => { + if (err) return callback(err) + callback(null, css) + }, + ) + } catch (error) { + callback(error as Error) + } + } + tryFetch(20) return } @@ -86,4 +167,5 @@ export default devupUICssLoader /** @internal Reset init state for testing purposes only */ export const resetInit = () => { init = false + cachedPort = null } diff --git a/packages/next-plugin/src/loader.ts b/packages/next-plugin/src/loader.ts index f10e1b21..c5c908be 100644 --- a/packages/next-plugin/src/loader.ts +++ b/packages/next-plugin/src/loader.ts @@ -104,8 +104,8 @@ const devupUILoader: RawLoaderDefinitionFunction = importAliases = {}, } = this.getOptions() - // Coordinator mode: delegate to HTTP server in dev mode - if (coordinatorPortFile && watch) { + // Coordinator mode: delegate to HTTP server + if (coordinatorPortFile) { const callback = this.async() const tryCoordinator = (retries: number) => { if (!existsSync(coordinatorPortFile)) { diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index 2a693d3b..29bca263 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -19,7 +19,6 @@ import { import { type NextConfig } from 'next' import { startCoordinator } from './coordinator' -import { preload } from './preload' type DevupUiNextPluginOptions = Omit< Partial, @@ -95,39 +94,24 @@ export function DevupUI( const coordinatorPortFile = join(distDir, 'coordinator.port') - if (process.env.NODE_ENV !== 'production') { - // create devup-ui.css file - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) + // create devup-ui.css file + writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false)) - const coordinator = startCoordinator({ - package: libPackage, - cssDir, - singleCss, - sheetFile, - classMapFile, - fileMapFile, - importAliases: importAliases as unknown as Record< - string, - string | null - >, - coordinatorPortFile, - }) + const coordinator = startCoordinator({ + package: libPackage, + cssDir, + singleCss, + sheetFile, + classMapFile, + fileMapFile, + importAliases: importAliases as unknown as Record, + coordinatorPortFile, + }) - // Cleanup on exit - process.on('exit', () => { - coordinator.close() - }) - } else { - // build - preload( - excludeRegex, - libPackage, - singleCss, - cssDir, - include, - importAliases, - ) - } + // Cleanup on exit + process.on('exit', () => { + coordinator.close() + }) const defaultSheet = JSON.parse(exportSheet()) const defaultClassMap = JSON.parse(exportClassMap()) const defaultFileMap = JSON.parse(exportFileMap()) diff --git a/packages/next-plugin/src/preload.ts b/packages/next-plugin/src/preload.ts deleted file mode 100644 index 8cd77175..00000000 --- a/packages/next-plugin/src/preload.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { readFileSync, realpathSync, writeFileSync } from 'node:fs' -import { basename, dirname, join, relative } from 'node:path' - -import type { WasmImportAliases } from '@devup-ui/plugin-utils' -import { codeExtract, getCss } from '@devup-ui/wasm' -import { globSync } from 'tinyglobby' - -import { findTopPackageRoot } from './find-top-package-root' -import { getPackageName } from './get-package-name' -import { hasLocalPackage } from './has-localpackage' - -export function preload( - excludeRegex: RegExp, - libPackage: string, - singleCss: boolean, - cssDir: string, - include: string[], - importAliases: WasmImportAliases, - pwd = process.cwd(), - nested = false, -) { - if (include.length > 0 && hasLocalPackage()) { - const packageRoot = findTopPackageRoot() - const collected = globSync(['package.json', '!**/node_modules/**'], { - followSymbolicLinks: true, - absolute: true, - cwd: packageRoot, - }) - .filter((file) => include.includes(getPackageName(file))) - .map((file) => dirname(file)) - - for (const file of collected) { - preload( - excludeRegex, - libPackage, - singleCss, - cssDir, - include, - importAliases, - file, - true, - ) - } - if (nested) return - } - const collected = globSync(['**/*.tsx', '**/*.ts', '**/*.js', '**/*.mjs'], { - followSymbolicLinks: true, - absolute: true, - cwd: pwd, - }) - // fix multi core build issue - collected.sort() - for (const file of collected) { - const filePath = relative(process.cwd(), realpathSync(file)) - if ( - /\.(test(-d)?|d|spec)\.(tsx|ts|js|mjs)$/.test(filePath) || - /^(out|.next)[/\\]/.test(filePath) || - excludeRegex.test(filePath) - ) - continue - const { cssFile, css } = codeExtract( - filePath, - readFileSync(filePath, 'utf-8'), - libPackage, - cssDir, - singleCss, - false, - true, - importAliases, - ) - - if (cssFile) { - writeFileSync(join(cssDir, basename(cssFile!)), css ?? '', 'utf-8') - } - } - writeFileSync(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') -} From d3eb6c3d4055cd3eaf39b5817a2ef1bd2ff96e3c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 11 Feb 2026 18:35:06 +0900 Subject: [PATCH 3/8] Rm preload --- .../next-plugin/src/__tests__/preload.test.ts | 212 ------------------ 1 file changed, 212 deletions(-) delete mode 100644 packages/next-plugin/src/__tests__/preload.test.ts diff --git a/packages/next-plugin/src/__tests__/preload.test.ts b/packages/next-plugin/src/__tests__/preload.test.ts deleted file mode 100644 index cda381d9..00000000 --- a/packages/next-plugin/src/__tests__/preload.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import * as fs from 'node:fs' -import * as path from 'node:path' - -import * as wasm from '@devup-ui/wasm' -import { - afterEach, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from 'bun:test' -import * as tinyglobby from 'tinyglobby' - -import * as findTopPackageRootModule from '../find-top-package-root' -import * as getPackageNameModule from '../get-package-name' -import * as hasLocalPackageModule from '../has-localpackage' -import { preload } from '../preload' - -let readFileSyncSpy: ReturnType -let writeFileSyncSpy: ReturnType -let realpathSyncSpy: ReturnType -let globSyncSpy: ReturnType -let codeExtractSpy: ReturnType -let getCssSpy: ReturnType -let hasLocalPackageSpy: ReturnType -let findTopPackageRootSpy: ReturnType -let getPackageNameSpy: ReturnType - -beforeEach(() => { - readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('code') - writeFileSyncSpy = spyOn(fs, 'writeFileSync').mockReturnValue(undefined) - realpathSyncSpy = spyOn(fs, 'realpathSync').mockImplementation( - (p) => p as string, - ) - globSyncSpy = spyOn(tinyglobby, 'globSync').mockReturnValue([]) - codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue({ - code: 'extracted', - css: 'css', - cssFile: null, - map: null, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as any) - getCssSpy = spyOn(wasm, 'getCss').mockReturnValue('global css') - hasLocalPackageSpy = spyOn( - hasLocalPackageModule, - 'hasLocalPackage', - ).mockReturnValue(false) - findTopPackageRootSpy = spyOn( - findTopPackageRootModule, - 'findTopPackageRoot', - ).mockReturnValue('/root') - getPackageNameSpy = spyOn( - getPackageNameModule, - 'getPackageName', - ).mockReturnValue('test-pkg') -}) - -afterEach(() => { - readFileSyncSpy.mockRestore() - writeFileSyncSpy.mockRestore() - realpathSyncSpy.mockRestore() - globSyncSpy.mockRestore() - codeExtractSpy.mockRestore() - getCssSpy.mockRestore() - hasLocalPackageSpy.mockRestore() - findTopPackageRootSpy.mockRestore() - getPackageNameSpy.mockRestore() -}) - -describe('preload', () => { - it('should process files and write devup-ui.css', () => { - globSyncSpy.mockReturnValue(['/project/src/index.tsx']) - - preload(/node_modules/, '@devup-ui/react', true, '/css', [], {}, '/project') - - expect(globSyncSpy).toHaveBeenCalled() - expect(codeExtractSpy).toHaveBeenCalled() - expect(writeFileSyncSpy).toHaveBeenCalledWith( - path.join('/css', 'devup-ui.css'), - 'global css', - 'utf-8', - ) - }) - - it('should skip test files', () => { - globSyncSpy.mockReturnValue([ - '/project/src/index.test.tsx', - '/project/src/index.spec.ts', - '/project/src/index.test-d.ts', - '/project/src/types.d.ts', - ]) - - preload(/node_modules/, '@devup-ui/react', true, '/css', [], {}, '/project') - - expect(codeExtractSpy).not.toHaveBeenCalled() - }) - - it('should skip out and .next directories', () => { - const cwd = process.cwd() - realpathSyncSpy.mockImplementation((p) => p as string) - globSyncSpy.mockReturnValue([ - path.join(cwd, 'out/index.tsx'), - path.join(cwd, '.next/index.tsx'), - ]) - - preload(/node_modules/, '@devup-ui/react', true, '/css', [], {}, cwd) - - expect(codeExtractSpy).not.toHaveBeenCalled() - }) - - it('should skip files matching exclude regex', () => { - globSyncSpy.mockReturnValue(['/project/node_modules/pkg/index.tsx']) - - preload(/node_modules/, '@devup-ui/react', true, '/css', [], {}, '/project') - - expect(codeExtractSpy).not.toHaveBeenCalled() - }) - - it('should write css file when cssFile is returned', () => { - globSyncSpy.mockReturnValue(['/project/src/index.tsx']) - codeExtractSpy.mockReturnValue({ - code: 'extracted', - css: 'component css', - cssFile: 'devup-ui-1.css', - map: null, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as any) - - preload(/node_modules/, '@devup-ui/react', true, '/css', [], {}, '/project') - - expect(writeFileSyncSpy).toHaveBeenCalledWith( - path.join('/css', 'devup-ui-1.css'), - 'component css', - 'utf-8', - ) - }) - - it('should process local packages when include has items and hasLocalPackage', () => { - hasLocalPackageSpy.mockReturnValue(true) - getPackageNameSpy.mockReturnValue('@scope/local-pkg') - - // First call returns package.json paths, second returns source files - globSyncSpy - .mockReturnValueOnce(['/root/packages/local-pkg/package.json']) - .mockReturnValueOnce(['/root/packages/local-pkg/src/index.tsx']) - .mockReturnValueOnce(['/project/src/app.tsx']) - - preload( - /node_modules/, - '@devup-ui/react', - true, - '/css', - ['@scope/local-pkg'], - {}, - '/project', - ) - - expect(findTopPackageRootSpy).toHaveBeenCalled() - expect(getPackageNameSpy).toHaveBeenCalledWith( - '/root/packages/local-pkg/package.json', - ) - }) - - it('should not process local packages when nested is true', () => { - hasLocalPackageSpy.mockReturnValue(true) - getPackageNameSpy.mockReturnValue('@scope/local-pkg') - globSyncSpy - .mockReturnValueOnce(['/root/packages/local-pkg/package.json']) - .mockReturnValueOnce([]) - - preload( - /node_modules/, - '@devup-ui/react', - true, - '/css', - ['@scope/local-pkg'], - {}, - '/project', - true, // nested = true - ) - - // Should return early when nested is true after processing local packages - expect(writeFileSyncSpy).not.toHaveBeenCalled() - }) - - it('should handle empty css value', () => { - globSyncSpy.mockReturnValue(['/project/src/index.tsx']) - codeExtractSpy.mockReturnValue({ - code: 'extracted', - css: undefined, - cssFile: 'devup-ui-1.css', - map: null, - updatedBaseStyle: false, - free: mock(), - [Symbol.dispose]: mock(), - } as any) - - preload(/node_modules/, '@devup-ui/react', true, '/css', [], '/project') - - expect(writeFileSyncSpy).toHaveBeenCalledWith( - path.join('/css', 'devup-ui-1.css'), - '', - 'utf-8', - ) - }) -}) From 4cb4c4b41b9786b2445a1ad2f61525f63cba434d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Feb 2026 00:11:05 +0900 Subject: [PATCH 4/8] Add single test --- .github/workflows/publish.yml | 33 +++++++++++++++++++++++++++------ apps/landing/next.config.ts | 15 ++++++++++----- e2e/docs-pages.spec.ts | 11 +++++------ playwright.config.ts | 12 +----------- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4611da1c..860b18c8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -91,15 +91,36 @@ jobs: bun run --filter landing build - name: Install Playwright Browsers run: bunx playwright install chromium --with-deps + - name: Check for Existing E2E Snapshots + id: check-snapshots + run: | + SNAPSHOT_COUNT=$(find e2e -name '*.png' -path '*-snapshots/*' 2>/dev/null | wc -l) + echo "count=$SNAPSHOT_COUNT" >> $GITHUB_OUTPUT + echo "Found $SNAPSHOT_COUNT existing snapshot(s)" + - name: Generate E2E Snapshots + if: steps.check-snapshots.outputs.count == '0' + run: bunx playwright test --update-snapshots + - name: Commit E2E Snapshots + if: steps.check-snapshots.outputs.count == '0' && github.event_name == 'pull_request' + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "chore: generate e2e visual snapshots from CI" + file_pattern: "e2e/**/*-snapshots/*.png" - name: Run E2E Tests run: bun run test:e2e - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./apps/landing/out - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - - uses: actions/deploy-pages@v4 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@v3 + with: + path: ./apps/landing/out + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + - uses: actions/deploy-pages@v4 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + - name: Build Landing (singleCss) + run: | + rm -rf apps/landing/out apps/landing/.next apps/landing/df + DEVUP_SINGLE_CSS=1 bun run --filter landing build + - name: Run E2E Tests (singleCss) + run: bun run test:e2e - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: diff --git a/apps/landing/next.config.ts b/apps/landing/next.config.ts index 4476e8f9..71e66a52 100644 --- a/apps/landing/next.config.ts +++ b/apps/landing/next.config.ts @@ -6,9 +6,14 @@ const withMDX = createMDX({ }) export default withMDX( - DevupUI({ - pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], - output: 'export', - reactCompiler: true, - }), + DevupUI( + { + pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], + output: 'export', + reactCompiler: true, + }, + { + singleCss: process.env.DEVUP_SINGLE_CSS === '1', + }, + ), ) diff --git a/e2e/docs-pages.spec.ts b/e2e/docs-pages.spec.ts index e3c89a87..9db879e0 100644 --- a/e2e/docs-pages.spec.ts +++ b/e2e/docs-pages.spec.ts @@ -185,18 +185,17 @@ test.describe('Documentation Pages', () => { await context.close() }) - // Dark mode screenshot requires JS for ThemeScript to set data-theme attribute. - // With javaScriptEnabled: false, the theme cannot switch to dark mode. - // Using colorScheme: 'dark' in context only sets prefers-color-scheme media, - // which is insufficient if the app relies on a JS-driven theme class/attribute. - test.skip('dark mode features section screenshot', async ({ browser }) => { + test('dark mode features section screenshot', async ({ browser }) => { const context = await browser.newContext({ - javaScriptEnabled: false, viewport: { width: 1440, height: 900 }, colorScheme: 'dark', }) const page = await context.newPage() await page.goto('/') + await page.waitForLoadState('networkidle') + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'dark'), + ) await page.waitForTimeout(500) const features = page.getByText('Features').first() diff --git a/playwright.config.ts b/playwright.config.ts index 8deea17f..42a31cff 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,17 +22,7 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { - ...devices['Desktop Chrome'], - launchOptions: { - args: [ - '--font-render-hinting=none', - '--disable-font-subpixel-positioning', - '--disable-skia-runtime-opts', - '--disable-lcd-text', - ], - }, - }, + use: { ...devices['Desktop Chrome'] }, }, ], webServer: { From 13b9852b764c1e549954c43dfd51eb40da08ff9b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Feb 2026 00:59:04 +0900 Subject: [PATCH 5/8] Add single test --- playwright.config.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 42a31cff..8deea17f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,7 +22,17 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + launchOptions: { + args: [ + '--font-render-hinting=none', + '--disable-font-subpixel-positioning', + '--disable-skia-runtime-opts', + '--disable-lcd-text', + ], + }, + }, }, ], webServer: { From 47216156880c6636c53bb38cfd0e9ac9ca9ce1ec Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Feb 2026 01:50:25 +0900 Subject: [PATCH 6/8] Add single test --- .github/workflows/publish.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 860b18c8..5ba41292 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -121,6 +121,13 @@ jobs: DEVUP_SINGLE_CSS=1 bun run --filter landing build - name: Run E2E Tests (singleCss) run: bun run test:e2e + - name: Upload singleCss Playwright Report + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-singlecss + path: playwright-report/ + retention-days: 30 - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: From 2aa99f543ce04861f23a1d3ceac6ab052d12e451 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Feb 2026 02:08:12 +0900 Subject: [PATCH 7/8] Add testcase --- .../src/__tests__/css-loader.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/next-plugin/src/__tests__/css-loader.test.ts b/packages/next-plugin/src/__tests__/css-loader.test.ts index c267b2c6..07fd01c6 100644 --- a/packages/next-plugin/src/__tests__/css-loader.test.ts +++ b/packages/next-plugin/src/__tests__/css-loader.test.ts @@ -383,6 +383,33 @@ describe('devupUICssLoader', () => { server.close() }) + it('should error when readCoordinatorPort throws', async () => { + const portFile = `test-coordinator-throw.port` + + existsSyncSpy.mockImplementation((p: unknown) => p === portFile) + readFileSyncSpy.mockImplementation((p: unknown) => { + if (p === portFile) throw new Error('EACCES: permission denied') + return '{}' + }) + + const error = await new Promise((resolve) => { + const asyncCallback = mock((err: Error | null) => { + if (err) resolve(err) + }) + devupUICssLoader.bind({ + async: () => asyncCallback, + addContextDependency: mock(), + getOptions: () => ({ + ...defaultOptions, + coordinatorPortFile: portFile, + }), + resourcePath: 'devup-ui.css', + } as any)(Buffer.from(''), '', '') + }) + + expect(error.message).toBe('EACCES: permission denied') + }) + it('should error when coordinator connection fails', async () => { const portFile = `test-coordinator-conn-err.port` From 4ead9e6229773b2fc1015f7e86dbda8503c129c7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Feb 2026 02:10:35 +0900 Subject: [PATCH 8/8] Add testcase --- .changepacks/changepack_log_BXwPLkY765Ki1-2KuPckk.json | 1 + .changepacks/changepack_log_tuDvuARnGW9uutvLp5v-a.json | 1 + 2 files changed, 2 insertions(+) create mode 100644 .changepacks/changepack_log_BXwPLkY765Ki1-2KuPckk.json create mode 100644 .changepacks/changepack_log_tuDvuARnGW9uutvLp5v-a.json diff --git a/.changepacks/changepack_log_BXwPLkY765Ki1-2KuPckk.json b/.changepacks/changepack_log_BXwPLkY765Ki1-2KuPckk.json new file mode 100644 index 00000000..b7b56919 --- /dev/null +++ b/.changepacks/changepack_log_BXwPLkY765Ki1-2KuPckk.json @@ -0,0 +1 @@ +{"changes":{"packages/eslint-plugin/package.json":"Patch","packages/next-plugin/package.json":"Patch","bindings/devup-ui-wasm/package.json":"Patch"},"note":"Refactor build and dev process","date":"2026-02-11T17:09:50.999761800Z"} \ No newline at end of file diff --git a/.changepacks/changepack_log_tuDvuARnGW9uutvLp5v-a.json b/.changepacks/changepack_log_tuDvuARnGW9uutvLp5v-a.json new file mode 100644 index 00000000..9b3adad6 --- /dev/null +++ b/.changepacks/changepack_log_tuDvuARnGW9uutvLp5v-a.json @@ -0,0 +1 @@ +{"changes":{"bindings/devup-ui-wasm/package.json":"Patch"},"note":"Optimize","date":"2026-02-11T17:10:04.282365Z"} \ No newline at end of file