diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2633734ac9..494afbc0cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: - name: Build project run: pnpm run build + + - name: Verify report template asset + run: pnpm run verify:report-template - name: Check CLI CJS bundle on minimum supported Node run: | diff --git a/.github/workflows/studio-headless-linux.yml b/.github/workflows/studio-headless-linux.yml index 7b3412dc24..2121bce1b9 100644 --- a/.github/workflows/studio-headless-linux.yml +++ b/.github/workflows/studio-headless-linux.yml @@ -91,9 +91,6 @@ jobs: - name: Build Studio run: npx nx run-many --target=build --projects=studio,@midscene/report - - name: Re-inject report template into @midscene/core dist - run: node apps/report/scripts/inject-report-template.mjs - - name: Run Studio startup smoke test run: xvfb-run -a --server-args="-screen 0 1920x1080x24" pnpm --dir apps/studio run test:smoke env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 320928c83e..934dfe3b1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,13 +152,13 @@ cd apps/playground && pnpm run dev cd apps/chrome-extension && pnpm run dev ``` -### `REPLACE_ME_WITH_REPORT_HTML` error in the report file +### Missing report template errors `apps/report` is not standalone at runtime. Its built `index.html` template is -injected back into `packages/core/dist` during build. If report UI changes do -not show up, or you see `REPLACE_ME_WITH_REPORT_HTML` in the report file, the -template injection is usually stale. Rebuild the entire workspace without Nx -cache to fix it: +synced into `packages/core/dist/report-template/index.html` during build. If +report UI changes do not show up, or the runtime says the Midscene report +template is missing, rebuild the entire workspace without Nx cache to refresh +the synced template: ```sh # Rebuild the entire project without cache diff --git a/apps/android-playground/package.json b/apps/android-playground/package.json index 00ecc7ae30..0cc0b82d2b 100644 --- a/apps/android-playground/package.json +++ b/apps/android-playground/package.json @@ -24,5 +24,19 @@ "@types/react-dom": "^18.3.1", "less": "^4.2.0", "typescript": "^5.8.3" + }, + "nx": { + "targets": { + "build": { + "dependsOn": [ + "^build", + { + "projects": ["@midscene/report"], + "target": "build", + "params": "ignore" + } + ] + } + } } } diff --git a/apps/android-playground/rsbuild.config.ts b/apps/android-playground/rsbuild.config.ts index 2cace57fc2..f0986c8b3f 100644 --- a/apps/android-playground/rsbuild.config.ts +++ b/apps/android-playground/rsbuild.config.ts @@ -8,6 +8,7 @@ import { pluginWorkspaceDev } from 'rsbuild-plugin-workspace-dev'; import { version as playgroundVersion } from '../../packages/playground/package.json'; import { commonIgnoreWarnings, + createCoreReportTemplateReplacementPlugin, createPlaygroundCopyPlugin, createTypeCheckPlugin, } from '../../scripts/rsbuild-utils.ts'; @@ -66,6 +67,9 @@ export default defineConfig({ pluginNodePolyfill(), pluginLess(), pluginSvgr(), + createCoreReportTemplateReplacementPlugin({ + appDir: __dirname, + }), createPlaygroundCopyPlugin( path.join(__dirname, 'dist'), path.join(__dirname, '../../packages/android-playground/static'), diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index b159f657ae..2c3447a356 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -16,7 +16,6 @@ "@midscene/core": "workspace:*", "@midscene/playground": "workspace:*", "@midscene/recorder": "workspace:*", - "@midscene/report": "workspace:*", "@midscene/shared": "workspace:*", "@midscene/visualizer": "workspace:*", "@midscene/web": "workspace:*", @@ -52,5 +51,19 @@ "typescript": "^5.8.3", "vitest": "3.0.5", "web-ext": "9.0.0" + }, + "nx": { + "targets": { + "build": { + "dependsOn": [ + "^build", + { + "projects": ["@midscene/report"], + "target": "build", + "params": "ignore" + } + ] + } + } } } diff --git a/apps/chrome-extension/rsbuild.config.ts b/apps/chrome-extension/rsbuild.config.ts index b1a28f849a..46485dcc50 100644 --- a/apps/chrome-extension/rsbuild.config.ts +++ b/apps/chrome-extension/rsbuild.config.ts @@ -8,6 +8,7 @@ import { pluginWorkspaceDev } from 'rsbuild-plugin-workspace-dev'; import { version } from '../../packages/visualizer/package.json'; import { commonIgnoreWarnings, + createCoreReportTemplateReplacementPlugin, createTypeCheckPlugin, } from '../../scripts/rsbuild-utils.ts'; @@ -123,6 +124,9 @@ export default defineConfig({ pluginLess(), pluginSvgr(), createTypeCheckPlugin(), + createCoreReportTemplateReplacementPlugin({ + appDir: __dirname, + }), pluginWorkspaceDev({ projects: { '@midscene/report': { diff --git a/apps/computer-playground/package.json b/apps/computer-playground/package.json index 9be715730a..a439f57ac9 100644 --- a/apps/computer-playground/package.json +++ b/apps/computer-playground/package.json @@ -24,5 +24,19 @@ "@types/react-dom": "^18.3.1", "less": "^4.2.0", "typescript": "^5.8.3" + }, + "nx": { + "targets": { + "build": { + "dependsOn": [ + "^build", + { + "projects": ["@midscene/report"], + "target": "build", + "params": "ignore" + } + ] + } + } } } diff --git a/apps/computer-playground/rsbuild.config.ts b/apps/computer-playground/rsbuild.config.ts index d9676ff484..2a4ae364a5 100644 --- a/apps/computer-playground/rsbuild.config.ts +++ b/apps/computer-playground/rsbuild.config.ts @@ -8,6 +8,7 @@ import { pluginWorkspaceDev } from 'rsbuild-plugin-workspace-dev'; import { version as playgroundVersion } from '../../packages/playground/package.json'; import { commonIgnoreWarnings, + createCoreReportTemplateReplacementPlugin, createPlaygroundCopyPlugin, createTypeCheckPlugin, } from '../../scripts/rsbuild-utils.ts'; @@ -66,6 +67,9 @@ export default defineConfig({ pluginNodePolyfill(), pluginLess(), pluginSvgr(), + createCoreReportTemplateReplacementPlugin({ + appDir: __dirname, + }), createPlaygroundCopyPlugin( path.join(__dirname, 'dist'), path.join(__dirname, '../../packages/computer-playground/static'), diff --git a/apps/playground/package.json b/apps/playground/package.json index 2e1dd945d1..b0428488c3 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -33,5 +33,19 @@ "rsbuild-plugin-workspace-dev": "0.0.1", "tsx": "^4.19.2", "typescript": "^5.8.3" + }, + "nx": { + "targets": { + "build": { + "dependsOn": [ + "^build", + { + "projects": ["@midscene/report"], + "target": "build", + "params": "ignore" + } + ] + } + } } } diff --git a/apps/playground/rsbuild.config.ts b/apps/playground/rsbuild.config.ts index 4ddb96bef8..574176f5b2 100644 --- a/apps/playground/rsbuild.config.ts +++ b/apps/playground/rsbuild.config.ts @@ -8,6 +8,7 @@ import { pluginWorkspaceDev } from 'rsbuild-plugin-workspace-dev'; import { version as playgroundVersion } from '../../packages/playground/package.json'; import { commonIgnoreWarnings, + createCoreReportTemplateReplacementPlugin, createPlaygroundCopyPlugin, createTypeCheckPlugin, } from '../../scripts/rsbuild-utils.ts'; @@ -23,6 +24,9 @@ export default defineConfig({ pluginLess(), pluginNodePolyfill(), pluginSvgr(), + createCoreReportTemplateReplacementPlugin({ + appDir: __dirname, + }), createPlaygroundCopyPlugin( path.join(__dirname, 'dist'), path.join(__dirname, '../../packages/playground/static'), diff --git a/apps/report/package.json b/apps/report/package.json index cfef66c544..b610cb75fa 100644 --- a/apps/report/package.json +++ b/apps/report/package.json @@ -13,6 +13,16 @@ "generate-demo": "node scripts/generate-demo-report.mjs", "e2e": "node scripts/generate-demo-report.mjs && node ../../packages/cli/bin/midscene ./e2e/" }, + "nx": { + "targets": { + "build": { + "outputs": [ + "{projectRoot}/dist", + "{workspaceRoot}/packages/core/dist/report-template" + ] + } + } + }, "dependencies": { "@ant-design/icons": "^5.3.1", "@midscene/core": "workspace:*", diff --git a/apps/report/rsbuild.config.ts b/apps/report/rsbuild.config.ts index 84101ef517..fd2b865bd9 100644 --- a/apps/report/rsbuild.config.ts +++ b/apps/report/rsbuild.config.ts @@ -1,4 +1,3 @@ -import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; import { defineConfig } from '@rsbuild/core'; @@ -7,15 +6,9 @@ import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'; import { pluginReact } from '@rsbuild/plugin-react'; import { pluginSvgr } from '@rsbuild/plugin-svgr'; import { pluginWorkspaceDev } from 'rsbuild-plugin-workspace-dev'; -import { - buildReportTemplateInjection, - isReportTemplateInjectableFile, - reportTemplateMagicString, - reportTemplateReplacedMark, - reportTemplateReplacementRegExp, -} from '../../scripts/report-template-utils.mjs'; import { commonIgnoreWarnings, + createReportTemplateSyncPlugin, createTypeCheckPlugin, } from '../../scripts/rsbuild-utils.ts'; @@ -33,80 +26,17 @@ const allTestData = jsonFiles.map((file) => { }; }); -// put back the report template to the core package -// this is a workaround for the circular dependency issue -// ERROR: This repository uses pkg in bundler mode. It is necessary to declare @midscene/report in the dependency; otherwise, it may cause packaging order issues and thus lead to the failure of report injection -const copyReportTemplate = () => ({ - name: 'copy-report-template', - setup(api: { - onAfterBuild: (arg0: ({ compiler }: { compiler: any }) => void) => void; - }) { - api.onAfterBuild(({ compiler }) => { - // read the template file - const srcPath = path.join(__dirname, 'dist', 'index.html'); - const { sanitizedTplFileContent, finalContent } = - buildReportTemplateInjection(fs.readFileSync(srcPath, 'utf-8')); - assert( - !sanitizedTplFileContent.includes(reportTemplateMagicString), - 'magic string should not be in the template file', - ); - - // find the core package - const corePkgDir = path.join(__dirname, '..', '..', 'packages', 'core'); - const corePkgJson = JSON.parse( - fs.readFileSync(path.join(corePkgDir, 'package.json'), 'utf-8'), - ); - assert( - corePkgJson.name === '@midscene/core', - 'core package name is not @midscene/core', - ); - const corePkgDistDir = path.join(corePkgDir, 'dist'); - - // traverse all .js files and inject (or update) the template - const jsFiles = fs.readdirSync(corePkgDistDir, { recursive: true }); - let replacedCount = 0; - for (const file of jsFiles) { - if (isReportTemplateInjectableFile(file)) { - const filePath = path.join(corePkgDistDir, file.toString()); - const fileContent = fs.readFileSync(filePath, 'utf-8'); - if (fileContent.includes(reportTemplateReplacedMark)) { - assert( - reportTemplateReplacementRegExp.test(fileContent), - 'a replaced mark is found but cannot match', - ); - - const replacedContent = fileContent.replace( - reportTemplateReplacementRegExp, - () => finalContent, - ); - fs.writeFileSync(filePath, replacedContent); - replacedCount++; - console.log(`Template updated in file ${filePath}`); - } else if (fileContent.includes(reportTemplateMagicString)) { - const magicStringCount = ( - fileContent.match(new RegExp(reportTemplateMagicString, 'g')) || - [] - ).length; - assert( - magicStringCount === 1, - 'magic string shows more than once in the file, cannot process', - ); - const replacedContent = fileContent.replace( - `'${reportTemplateMagicString}'`, - () => finalContent, // there are some $- code in the tpl, so we have to use a function as the second argument - ); - fs.writeFileSync(filePath, replacedContent); - replacedCount++; - console.log(`Template injected into ${filePath}`); - } - } - } - if (replacedCount === 0) { - throw new Error('No html template found in the core package'); - } - }); - }, -}); +const reportTemplatePath = path.join(__dirname, 'dist', 'index.html'); +const coreReportTemplatePath = path.join( + __dirname, + '..', + '..', + 'packages', + 'core', + 'dist', + 'report-template', + 'index.html', +); export default defineConfig({ html: { @@ -167,8 +97,12 @@ export default defineConfig({ pluginLess(), pluginNodePolyfill(), pluginSvgr(), - copyReportTemplate(), createTypeCheckPlugin(), + createReportTemplateSyncPlugin({ + srcPath: reportTemplatePath, + destPath: coreReportTemplatePath, + pluginName: 'sync-report-template-to-core', + }), pluginWorkspaceDev({ projects: { '@midscene/report': { diff --git a/apps/report/scripts/inject-report-template.mjs b/apps/report/scripts/inject-report-template.mjs deleted file mode 100644 index 5c326be64e..0000000000 --- a/apps/report/scripts/inject-report-template.mjs +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - buildReportTemplateInjection, - isReportTemplateInjectableFile, - reportTemplateMagicString, - reportTemplateReplacedMark, - reportTemplateReplacementRegExp, -} from '../../../scripts/report-template-utils.mjs'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const reportRoot = path.resolve(__dirname, '..'); -const repoRoot = path.resolve(reportRoot, '..', '..'); - -const srcPath = path.join(reportRoot, 'dist', 'index.html'); -if (!fs.existsSync(srcPath)) { - throw new Error( - `Report template not found at ${srcPath}. Run "nx build @midscene/report" first.`, - ); -} - -const { sanitizedTplFileContent, finalContent } = buildReportTemplateInjection( - fs.readFileSync(srcPath, 'utf-8'), -); -assert( - !sanitizedTplFileContent.includes(reportTemplateMagicString), - 'magic string should not be in the template file', -); - -const corePkgDir = path.join(repoRoot, 'packages', 'core'); -const corePkgJson = JSON.parse( - fs.readFileSync(path.join(corePkgDir, 'package.json'), 'utf-8'), -); -assert( - corePkgJson.name === '@midscene/core', - 'core package name is not @midscene/core', -); -const corePkgDistDir = path.join(corePkgDir, 'dist'); - -const jsFiles = fs.readdirSync(corePkgDistDir, { recursive: true }); -let replacedCount = 0; -for (const file of jsFiles) { - if (isReportTemplateInjectableFile(file)) { - const filePath = path.join(corePkgDistDir, file); - const fileContent = fs.readFileSync(filePath, 'utf-8'); - if (fileContent.includes(reportTemplateReplacedMark)) { - assert( - reportTemplateReplacementRegExp.test(fileContent), - 'a replaced mark is found but cannot match', - ); - const replacedContent = fileContent.replace( - reportTemplateReplacementRegExp, - () => finalContent, - ); - fs.writeFileSync(filePath, replacedContent); - replacedCount++; - console.log(`Template updated in file ${filePath}`); - } else if (fileContent.includes(reportTemplateMagicString)) { - const magicStringCount = ( - fileContent.match(new RegExp(reportTemplateMagicString, 'g')) || [] - ).length; - assert( - magicStringCount === 1, - 'magic string shows more than once in the file, cannot process', - ); - const replacedContent = fileContent.replace( - `'${reportTemplateMagicString}'`, - () => finalContent, - ); - fs.writeFileSync(filePath, replacedContent); - replacedCount++; - console.log(`Template injected into ${filePath}`); - } - } -} - -if (replacedCount === 0) { - throw new Error( - 'No html template marker found in @midscene/core dist; nothing to inject.', - ); -} diff --git a/apps/report/src/components/playground/index.tsx b/apps/report/src/components/playground/index.tsx index d8ca4cd52f..6b5927f974 100644 --- a/apps/report/src/components/playground/index.tsx +++ b/apps/report/src/components/playground/index.tsx @@ -84,6 +84,7 @@ export function StandardPlayground({ const [replayCounter, setReplayCounter] = useState(0); const [actionSpace, setActionSpace] = useState[]>([]); const [actionSpaceLoading, setActionSpaceLoading] = useState(true); + const collectReportHTML = canDownloadReport !== false; // Form and environment configuration const [form] = Form.useForm(); @@ -329,14 +330,14 @@ export function StandardPlayground({ const response = serverResponse as ServerResponse; result.result = response.result; result.dump = response.dump; - result.reportHTML = response.reportHTML; + result.reportHTML = collectReportHTML ? response.reportHTML : null; if (response.error) { result.error = response.error; } console.log('Server response:', { hasResult: !!response.result, hasDump: !!response.dump, - hasReportHTML: !!response.reportHTML, + hasReportHTML: collectReportHTML && !!response.reportHTML, hasError: !!response.error, actionType, requestId: thisRunningId, @@ -358,7 +359,9 @@ export function StandardPlayground({ const serverResponse = response as ServerResponse; result.result = serverResponse.result; result.dump = serverResponse.dump; - result.reportHTML = serverResponse.reportHTML; + result.reportHTML = collectReportHTML + ? serverResponse.reportHTML + : null; } else { result.result = response; } @@ -394,7 +397,7 @@ export function StandardPlayground({ : null ) as PlaygroundResult['dump']; } - if (!result.reportHTML) { + if (collectReportHTML && !result.reportHTML) { result.reportHTML = activeAgent?.reportHTMLString() || null; } } @@ -449,6 +452,7 @@ export function StandardPlayground({ deepThink, actionSpace, actionSpaceLoading, + collectReportHTML, ]); // Dummy handleStop for Standard mode (no real stopping functionality) diff --git a/apps/studio/package.json b/apps/studio/package.json index 01fe68b0ff..82ee73f811 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -60,5 +60,19 @@ "appdmg": "0.6.6", "fs-xattr": "0.3.1", "macos-alias": "0.2.12" + }, + "nx": { + "targets": { + "build": { + "dependsOn": [ + "^build", + { + "projects": ["@midscene/report"], + "target": "build", + "params": "ignore" + } + ] + } + } } } diff --git a/apps/studio/rsbuild.config.ts b/apps/studio/rsbuild.config.ts index 8e2c2e0fb5..45006e7763 100644 --- a/apps/studio/rsbuild.config.ts +++ b/apps/studio/rsbuild.config.ts @@ -6,6 +6,7 @@ import { pluginReact } from '@rsbuild/plugin-react'; import { pluginSvgr } from '@rsbuild/plugin-svgr'; import { commonIgnoreWarnings, + createCoreReportTemplateReplacementPlugin, createTypeCheckPlugin, } from '../../scripts/rsbuild-utils.ts'; import { version as appVersion } from './package.json'; @@ -50,6 +51,9 @@ export default defineConfig({ pluginLess(), pluginNodePolyfill(), createTypeCheckPlugin(), + createCoreReportTemplateReplacementPlugin({ + appDir: __dirname, + }), ], resolve: { alias: { diff --git a/apps/studio/scripts/package-electron.mjs b/apps/studio/scripts/package-electron.mjs index 5e339b8004..5381650f8e 100644 --- a/apps/studio/scripts/package-electron.mjs +++ b/apps/studio/scripts/package-electron.mjs @@ -17,13 +17,9 @@ const studioRootDir = path.resolve(__dirname, '..'); const workspaceRootDir = path.resolve(studioRootDir, '..', '..'); const studioBuildDir = path.join(studioRootDir, 'build'); const reportRootDir = path.join(workspaceRootDir, 'apps', 'report'); -const coreRootDir = path.join(workspaceRootDir, 'packages', 'core'); -const coreDistDir = path.join(coreRootDir, 'dist'); const reportTemplatePath = path.join(reportRootDir, 'dist', 'index.html'); -const reportTemplatePlaceholder = 'REPLACE_ME_WITH_REPORT_HTML'; -const unresolvedReportTemplatePattern = new RegExp( - String.raw`(?:=|return)\s*(['"])${reportTemplatePlaceholder}\1`, -); +const reportTemplateInjectionRegExp = + /globalThis\.__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__\s*=/; // Keep release packaging state outside `apps/studio` so local build outputs do // not recurse back into the generated Electron payload. @@ -600,7 +596,7 @@ const collectLatestMtime = async (targetPath) => { const reportRuntimeOutputExtensions = new Set(['.cjs', '.html', '.js', '.mjs']); -export const pathContainsReportTemplatePlaceholder = async (rootDir) => { +export const pathContainsReportTemplateInjection = async (rootDir) => { let entries; try { entries = await fs.readdir(rootDir, { withFileTypes: true }); @@ -614,7 +610,7 @@ export const pathContainsReportTemplatePlaceholder = async (rootDir) => { for (const entry of entries) { const entryPath = path.join(rootDir, entry.name); if (entry.isDirectory()) { - if (await pathContainsReportTemplatePlaceholder(entryPath)) { + if (await pathContainsReportTemplateInjection(entryPath)) { return true; } continue; @@ -628,7 +624,7 @@ export const pathContainsReportTemplatePlaceholder = async (rootDir) => { } const content = await fs.readFile(entryPath, 'utf8'); - if (unresolvedReportTemplatePattern.test(content)) { + if (reportTemplateInjectionRegExp.test(content)) { return true; } } @@ -806,35 +802,6 @@ const prepareReportBuildOutput = async () => { await ensureReportTemplateReady(); }; -const ensureCoreReportTemplateInjected = async () => { - let status = await getBuildStatus({ - packageDir: coreRootDir, - sourceTargets: packageBuildSourceTargets, - additionalSourceTargets: [reportTemplatePath], - }); - - if ( - !status.needsBuild && - (await pathContainsReportTemplatePlaceholder(coreDistDir)) - ) { - status = { - needsBuild: true, - reason: 'report template placeholder remains in build output', - }; - } - - if (status.needsBuild) { - console.log(`Building packages/core (${status.reason}).`); - await buildPackageDir(path.relative(workspaceRootDir, coreRootDir)); - } - - if (await pathContainsReportTemplatePlaceholder(coreDistDir)) { - throw new Error( - `packages/core build still contains ${reportTemplatePlaceholder}; rebuild apps/report before packaging.`, - ); - } -}; - const prepareStudioWorkspacePackages = async (workspacePackages) => { for (const workspacePackage of workspacePackages) { const status = await getBuildStatus({ @@ -866,11 +833,11 @@ const prepareStudioBuildOutput = async ({ const studioDistDir = path.join(studioRootDir, 'dist'); if ( !status.needsBuild && - (await pathContainsReportTemplatePlaceholder(studioDistDir)) + !(await pathContainsReportTemplateInjection(studioDistDir)) ) { status = { needsBuild: true, - reason: 'report template placeholder remains in build output', + reason: 'report template content is not injected in build output', }; } @@ -881,9 +848,9 @@ const prepareStudioBuildOutput = async ({ console.log(`Skipping apps/studio build (${status.reason}).`); } - if (await pathContainsReportTemplatePlaceholder(studioDistDir)) { + if (!(await pathContainsReportTemplateInjection(studioDistDir))) { throw new Error( - `apps/studio build still contains ${reportTemplatePlaceholder}; rebuild apps/report before packaging.`, + 'apps/studio build does not contain injected report template content; rebuild apps/report before packaging.', ); } }; @@ -1629,9 +1596,8 @@ const createPackagingWorkspace = async ({ await prepareStaticWorkspacePackageSources(workspacePackages); await prepareStudioWorkspacePackages(workspacePackages); await prepareReportBuildOutput(); - await ensureCoreReportTemplateInjected(); await prepareStudioBuildOutput({ - additionalSourceTargets: [reportTemplatePath, coreDistDir], + additionalSourceTargets: [reportTemplatePath], }); await removeIfExists(stageDir); await fs.mkdir(path.dirname(stageDir), { recursive: true }); diff --git a/apps/studio/tests/package-electron.test.mjs b/apps/studio/tests/package-electron.test.mjs index 8190110883..c100fe0466 100644 --- a/apps/studio/tests/package-electron.test.mjs +++ b/apps/studio/tests/package-electron.test.mjs @@ -27,7 +27,7 @@ import { normalizeReleaseVersion, packagedAsarOptions, parseBooleanLike, - pathContainsReportTemplatePlaceholder, + pathContainsReportTemplateInjection, pruneAntdUmdBundles, pruneGifwrapTestFixtures, pruneSourceMapFiles, @@ -1315,28 +1315,28 @@ describe('package-electron helpers', () => { } }); - it('detects unresolved report template placeholders in runtime output only', async () => { + it('detects report template injections in runtime output only', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'midscene-report-')); try { await fs.writeFile( path.join(root, 'guard.js'), - "if (html.includes('REPLACE_ME_WITH_REPORT_HTML')) reportHTML = null;", + "const marker = '__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__';", ); await fs.writeFile( path.join(root, 'bundle.js.map'), - '{"sourcesContent":["const reportTpl = \'REPLACE_ME_WITH_REPORT_HTML\';"]}', + '{"sourcesContent":["globalThis.__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__ = \\"\\";"]}', ); - await expect(pathContainsReportTemplatePlaceholder(root)).resolves.toBe( + await expect(pathContainsReportTemplateInjection(root)).resolves.toBe( false, ); await fs.writeFile( path.join(root, 'utils.js'), - "const reportTpl = 'REPLACE_ME_WITH_REPORT_HTML';", + 'globalThis.__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__ = "";', ); - await expect(pathContainsReportTemplatePlaceholder(root)).resolves.toBe( + await expect(pathContainsReportTemplateInjection(root)).resolves.toBe( true, ); } finally { diff --git a/apps/studio/tests/rsbuild-utils.test.ts b/apps/studio/tests/rsbuild-utils.test.ts new file mode 100644 index 0000000000..bda30ffd6d --- /dev/null +++ b/apps/studio/tests/rsbuild-utils.test.ts @@ -0,0 +1,239 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createCoreReportTemplateReplacementPlugin, + prepareCoreWrapperModules, + readCoreExportEntries, + reportTemplateGlobalName, +} from '../../../scripts/rsbuild-utils'; + +const tempDirs: string[] = []; + +function createTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'midscene-rsbuild-utils-')); + tempDirs.push(dir); + return dir; +} + +function createCorePackage(root: string) { + const corePackageDir = path.join(root, 'packages', 'core'); + fs.mkdirSync(corePackageDir, { recursive: true }); + fs.writeFileSync( + path.join(corePackageDir, 'package.json'), + JSON.stringify( + { + exports: { + '.': { + import: './dist/es/index.mjs', + require: './dist/lib/index.js', + }, + './utils': { + import: './dist/es/utils.mjs', + require: './dist/lib/utils.js', + }, + './report': { + import: './dist/es/report.mjs', + require: './dist/lib/report.js', + }, + './cjs-only': { + require: './dist/lib/cjs-only.js', + }, + }, + }, + null, + 2, + ), + ); + return corePackageDir; +} + +function createReportTemplate(root: string, content = 'template') { + const reportTemplatePath = path.join( + root, + 'apps', + 'report', + 'dist', + 'index.html', + ); + fs.mkdirSync(path.dirname(reportTemplatePath), { recursive: true }); + fs.writeFileSync(reportTemplatePath, content); + return reportTemplatePath; +} + +function createMockRsbuildApi(action: 'build' | 'dev' = 'build') { + const beforeCreateCompilerCallbacks: Array<() => void> = []; + const resolveCallbacks: Array< + (args: { resolveData: { request: string } }) => void + > = []; + + return { + api: { + context: { action }, + onBeforeCreateCompiler(callback: () => void) { + beforeCreateCompilerCallbacks.push(callback); + }, + resolve(callback: (args: { resolveData: { request: string } }) => void) { + resolveCallbacks.push(callback); + }, + }, + runBeforeCreateCompiler() { + for (const callback of beforeCreateCompilerCallbacks) { + callback(); + } + }, + resolveRequest(request: string) { + const resolveData = { request }; + for (const callback of resolveCallbacks) { + callback({ resolveData }); + } + return resolveData.request; + }, + }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('rsbuild report template utils', () => { + it('reads ESM core export entries from package.json', () => { + const root = createTempDir(); + const corePackageDir = createCorePackage(root); + + expect(readCoreExportEntries(corePackageDir)).toEqual({ + '': 'index.mjs', + report: 'report.mjs', + utils: 'utils.mjs', + }); + }); + + it('creates report template bootstrap and core wrapper modules', () => { + const root = createTempDir(); + const corePackageDir = createCorePackage(root); + const reportTemplatePath = createReportTemplate( + root, + 'report template', + ); + const appDir = path.join(root, 'apps', 'playground'); + const cacheDir = path.join( + appDir, + 'node_modules', + '.cache', + 'core-wrapper', + ); + + const result = prepareCoreWrapperModules({ + appDir, + cacheDir, + corePackageDir, + reportTemplatePath, + }); + + expect(result.coreExportEntries).toEqual({ + '': 'index.mjs', + report: 'report.mjs', + utils: 'utils.mjs', + }); + expect( + fs.readFileSync(path.join(cacheDir, 'report-template.mjs'), 'utf-8'), + ).toContain(`globalThis.${reportTemplateGlobalName}=`); + expect( + fs.readFileSync(path.join(cacheDir, 'index.mjs'), 'utf-8'), + ).toContain('export { default }'); + expect(fs.existsSync(path.join(cacheDir, 'utils.mjs'))).toBe(true); + expect(fs.existsSync(path.join(cacheDir, 'report.mjs'))).toBe(true); + }); + + it('rewrites supported @midscene/core requests to wrapper modules', () => { + const root = createTempDir(); + const corePackageDir = createCorePackage(root); + const reportTemplatePath = createReportTemplate(root); + const appDir = path.join(root, 'apps', 'studio'); + const cacheDir = path.join( + appDir, + 'node_modules', + '.cache', + 'core-wrapper', + ); + const mock = createMockRsbuildApi(); + + createCoreReportTemplateReplacementPlugin({ + appDir, + cacheDir, + corePackageDir, + reportTemplatePath, + }).setup(mock.api); + + mock.runBeforeCreateCompiler(); + + expect(mock.resolveRequest('@midscene/core')).toBe( + path.join(cacheDir, 'index.mjs'), + ); + expect(mock.resolveRequest('@midscene/core/utils')).toBe( + path.join(cacheDir, 'utils.mjs'), + ); + expect(mock.resolveRequest('@midscene/core/report')).toBe( + path.join(cacheDir, 'report.mjs'), + ); + }); + + it('throws a clear error for unsupported core deep imports', () => { + const root = createTempDir(); + const corePackageDir = createCorePackage(root); + const reportTemplatePath = createReportTemplate(root); + const appDir = path.join(root, 'apps', 'studio'); + const cacheDir = path.join( + appDir, + 'node_modules', + '.cache', + 'core-wrapper', + ); + const mock = createMockRsbuildApi(); + + createCoreReportTemplateReplacementPlugin({ + appDir, + cacheDir, + corePackageDir, + reportTemplatePath, + }).setup(mock.api); + + expect(() => mock.resolveRequest('@midscene/core/cjs-only')).toThrow( + 'Unsupported @midscene/core deep import for report template replacement: @midscene/core/cjs-only', + ); + }); + + it('throws a clear error when the report template is missing', () => { + const root = createTempDir(); + const corePackageDir = createCorePackage(root); + const appDir = path.join(root, 'apps', 'studio'); + const cacheDir = path.join( + appDir, + 'node_modules', + '.cache', + 'core-wrapper', + ); + const missingReportTemplatePath = path.join( + root, + 'apps', + 'report', + 'dist', + 'index.html', + ); + const mock = createMockRsbuildApi(); + + createCoreReportTemplateReplacementPlugin({ + appDir, + cacheDir, + corePackageDir, + reportTemplatePath: missingReportTemplatePath, + }).setup(mock.api); + + expect(() => mock.runBeforeCreateCompiler()).toThrow( + `Report template not found: ${missingReportTemplatePath}. Build @midscene/report before bundling this target.`, + ); + }); +}); diff --git a/package.json b/package.json index ca615a1baf..4e9ccd21cd 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "version": "1.9.8", "scripts": { "dev": "node scripts/dev-prepare.js && nx run-many --target=build:watch --exclude=android-playground,chrome-extension,@midscene/report,doc --verbose --parallel=6", - "build": "nx run-many --target=build --exclude=doc --verbose", + "build": "nx run-many --target=build --exclude=doc --verbose && pnpm run verify:report-template", "build:packages": "pnpm -r --filter './packages/**' --if-present run build", - "build:skip-cache": "npm run clean && nx run-many --target=build --exclude=doc --verbose --skip-nx-cache", + "build:skip-cache": "npm run clean && nx run-many --target=build --exclude=doc --verbose --skip-nx-cache && pnpm run verify:report-template", "type-check:tests": "node scripts/type-check-tests.mjs", "test": "nx run-many --target=test --verbose", "test:coverage": "nx run-many --target=test --verbose -- --coverage", @@ -18,6 +18,7 @@ "e2e:visualizer": "nx run @midscene/visualizer:e2e --verbose --exclude-task-dependencies", "test:ai:all": "npm run e2e && npm run e2e:cache && npm run e2e:report && npm run test:ai && npm run e2e:visualizer", "prepare": "simple-git-hooks && pnpm run build", + "verify:report-template": "test -s packages/core/dist/report-template/index.html", "check-dependency-version": "check-dependency-version-consistency .", "check:references": "node scripts/check-tsconfig-references.mjs", "lint": "npx biome check . --diagnostic-level=info --no-errors-on-unmatched --fix", diff --git a/packages/core/rslib.config.ts b/packages/core/rslib.config.ts index 3303b99c63..7abbb2a1a3 100644 --- a/packages/core/rslib.config.ts +++ b/packages/core/rslib.config.ts @@ -1,86 +1,8 @@ -import fs from 'node:fs'; import path from 'node:path'; import { defineConfig } from '@rslib/core'; -import { - buildReportTemplateInjection, - isReportTemplateInjectableFile, - reportTemplateMagicString, - reportTemplateReplacedMark, - reportTemplateReplacementRegExp, -} from '../../scripts/report-template-utils.mjs'; import { createTypeCheckPlugin } from '../../scripts/rsbuild-utils.ts'; import { version } from './package.json'; -// Inject report template into dist if available (self-injection as fallback) -const injectReportTemplate = () => ({ - name: 'inject-report-template', - setup: (api: { onAfterBuild: (fn: () => void) => void }) => { - api.onAfterBuild(() => { - if (process.env.MIDSCENE_SKIP_REPORT_TEMPLATE_INJECTION) { - console.warn('[@midscene/core] Report template injection skipped.'); - return; - } - - const reportTplPath = path.resolve( - __dirname, - '../../apps/report/dist/index.html', - ); - - // Only inject if the report template exists - if (!fs.existsSync(reportTplPath)) { - console.warn( - '[@midscene/core] Report template not found. Run "pnpm run build" to generate it.', - ); - return; - } - - const { finalContent } = buildReportTemplateInjection( - fs.readFileSync(reportTplPath, 'utf-8'), - ); - - const distDir = path.resolve(__dirname, 'dist'); - const files = fs.readdirSync(distDir, { recursive: true }); - let injectedCount = 0; - - for (const file of files) { - if (isReportTemplateInjectableFile(file)) { - const filePath = path.join(distDir, file); - const content = fs.readFileSync(filePath, 'utf-8'); - - if (content.includes(reportTemplateReplacedMark)) { - // Already injected, update it - const updated = content.replace( - reportTemplateReplacementRegExp, - () => finalContent, - ); - fs.writeFileSync(filePath, updated); - injectedCount++; - } else if (content.includes(reportTemplateMagicString)) { - // First injection - const updated = content.replace( - `'${reportTemplateMagicString}'`, - () => finalContent, - ); - fs.writeFileSync(filePath, updated); - injectedCount++; - } - } - } - - if (injectedCount > 0) { - console.log( - `[@midscene/core] Report template injected into ${injectedCount} file(s)`, - ); - } - - // Warning to help users find the solution when they encounter build issues - console.warn( - 'If you see "REPLACE_ME_WITH_REPORT_HTML" error in the Midscene report file, please rebuild the entire project with "pnpm run build:skip-cache". Reference: https://github.com/web-infra-dev/midscene/blob/main/CONTRIBUTING.md#replace_me_with_report_html-error-in-the-report-file', - ); - }); - }, -}); - export default defineConfig({ lib: [ { @@ -122,5 +44,5 @@ export default defineConfig({ output: { sourceMap: true, }, - plugins: [createTypeCheckPlugin(), injectReportTemplate()], + plugins: [createTypeCheckPlugin()], }); diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 0614465720..c23b9197ee 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { defaultRunDirName, @@ -83,16 +84,48 @@ export function processCacheConfig( const reportInitializedMap = new Map(); const reportGroupIdMap = new Map(); +const reportTemplateGlobalName = + '__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__'; declare const __DEV_REPORT_PATH__: string; export function getReportTpl() { + const globalReportTpl = (globalThis as any)[reportTemplateGlobalName]; + if (typeof globalReportTpl === 'string' && globalReportTpl) { + return globalReportTpl; + } + if (typeof __DEV_REPORT_PATH__ === 'string' && __DEV_REPORT_PATH__) { return fs.readFileSync(__DEV_REPORT_PATH__, 'utf-8'); } - const reportTpl = 'REPLACE_ME_WITH_REPORT_HTML'; - return reportTpl; + if (ifInBrowser || ifInWorker) { + throw new Error( + `Midscene report template is missing. If you bundle @midscene/core, inject the report template with globalThis.${reportTemplateGlobalName} before report generation.`, + ); + } + + return readReportTemplateFromPackage(); +} + +function resolveReportTemplatePath() { + const currentDir = + typeof __dirname !== 'undefined' + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(currentDir, '..', 'report-template', 'index.html'); +} + +export function readReportTemplateFromPackage( + reportTplPath = resolveReportTemplatePath(), +) { + if (!fs.existsSync(reportTplPath)) { + throw new Error( + `Midscene report template is missing at ${reportTplPath}. Build @midscene/report first so it can sync apps/report/dist/index.html into packages/core/dist/report-template/index.html. If you bundle @midscene/core, inject the template with globalThis.${reportTemplateGlobalName}.`, + ); + } + + return fs.readFileSync(reportTplPath, 'utf-8'); } /** diff --git a/packages/core/tests/unit-test/report.test.ts b/packages/core/tests/unit-test/report.test.ts index 30483bfb82..73f2b10df0 100644 --- a/packages/core/tests/unit-test/report.test.ts +++ b/packages/core/tests/unit-test/report.test.ts @@ -8,7 +8,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { extractAllDumpScriptsSync, generateImageScriptTag, @@ -17,6 +17,24 @@ import { ReportMergingTool } from '../../src/report'; import { ReportActionDump } from '../../src/types'; import { getReportTpl, getTmpFile, writeDumpReport } from '../../src/utils'; +const reportTemplateGlobalName = + '__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__'; +let originalReportTemplateContent: unknown; + +beforeEach(() => { + originalReportTemplateContent = (globalThis as any)[reportTemplateGlobalName]; + (globalThis as any)[reportTemplateGlobalName] = ''; +}); + +afterEach(() => { + if (typeof originalReportTemplateContent === 'undefined') { + delete (globalThis as any)[reportTemplateGlobalName]; + return; + } + + (globalThis as any)[reportTemplateGlobalName] = originalReportTemplateContent; +}); + function generateNReports( n: number, c: string, diff --git a/packages/core/tests/unit-test/utils.test.ts b/packages/core/tests/unit-test/utils.test.ts index d0322b89fc..96a7d612be 100644 --- a/packages/core/tests/unit-test/utils.test.ts +++ b/packages/core/tests/unit-test/utils.test.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'; import * as fs from 'node:fs'; +import * as path from 'node:path'; import { extractJSONFromCodeBlock, safeParseJson, @@ -8,7 +9,7 @@ import { dumpActionParam, findAllMidsceneLocatorField } from '@/common'; import { getMidsceneLocationSchema } from '@/index'; import { getMidsceneRunSubDir } from '@midscene/shared/common'; import { uuid } from '@midscene/shared/utils'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { z } from 'zod'; import { ifPlanLocateParamHasLocatedPixelBbox, @@ -21,6 +22,7 @@ import { getTmpFile, insertScriptBeforeClosingHtml, overlapped, + readReportTemplateFromPackage, reportHTMLContent, writeDumpReport, } from '../../src/utils'; @@ -40,6 +42,27 @@ function createTempHtmlFile(content: string): string { } describe('utils', () => { + const reportTemplateGlobalName = + '__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__'; + let originalReportTemplateContent: unknown; + + beforeEach(() => { + originalReportTemplateContent = (globalThis as any)[ + reportTemplateGlobalName + ]; + (globalThis as any)[reportTemplateGlobalName] = ''; + }); + + afterEach(() => { + if (typeof originalReportTemplateContent === 'undefined') { + delete (globalThis as any)[reportTemplateGlobalName]; + return; + } + + (globalThis as any)[reportTemplateGlobalName] = + originalReportTemplateContent; + }); + it('tmpDir', () => { const testDir = getTmpDir(); expect(typeof testDir).toBe('string'); @@ -68,6 +91,30 @@ describe('utils', () => { expect(reportContent).contains('type="midscene_web_dump"'); }); + it('reads report template from the package asset', () => { + const templateContent = `${uuid()}`; + const templatePath = getTmpFile('html'); + if (!templatePath) { + throw new Error('Failed to create temp html file'); + } + + try { + fs.writeFileSync(templatePath, templateContent, 'utf-8'); + + expect(readReportTemplateFromPackage(templatePath)).toBe(templateContent); + } finally { + rmSync(templatePath, { force: true }); + } + }); + + it('throws a clear error when the package report template asset is missing', () => { + const missingTemplatePath = path.join(getTmpDir()!, uuid(), 'index.html'); + + expect(() => readReportTemplateFromPackage(missingTemplatePath)).toThrow( + /Midscene report template is missing.*globalThis\.__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__/, + ); + }); + it('write report file with attributes', () => { const content = uuid(); const reportPath = writeDumpReport('test', { diff --git a/packages/playground/src/adapters/local-execution.ts b/packages/playground/src/adapters/local-execution.ts index b3e29923c1..a2345f6af5 100644 --- a/packages/playground/src/adapters/local-execution.ts +++ b/packages/playground/src/adapters/local-execution.ts @@ -352,11 +352,7 @@ export class LocalExecutionAdapter extends BasePlaygroundAdapter { try { if (typeof this.agent.reportHTMLString === 'function') { const html = this.agent.reportHTMLString(); - if ( - html && - typeof html === 'string' && - !html.includes('REPLACE_ME_WITH_REPORT_HTML') - ) { + if (html && typeof html === 'string') { reportHTML = html; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61c0f00be0..6f2b596c2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,9 +129,6 @@ importers: '@midscene/recorder': specifier: workspace:* version: link:../../packages/recorder - '@midscene/report': - specifier: workspace:* - version: link:../report '@midscene/shared': specifier: workspace:* version: link:../../packages/shared diff --git a/scripts/rsbuild-utils.ts b/scripts/rsbuild-utils.ts index 00decab684..394ab80337 100644 --- a/scripts/rsbuild-utils.ts +++ b/scripts/rsbuild-utils.ts @@ -9,6 +9,26 @@ export interface CopyStaticOptions { pluginName?: string; } +export interface ReportTemplateInjectOptions { + appDir: string; + cacheDir?: string; + corePackageDir?: string; + enabledIn?: 'build' | 'dev' | 'both'; + pluginName?: string; + reportTemplatePath?: string; +} + +export interface ReportTemplateSyncOptions { + srcPath: string; + destPath: string; + pluginName?: string; +} + +export const reportTemplateGlobalName = + '__MIDSCENE_INTERNAL_REPORT_TEMPLATE_CONTENT__'; +export const reportTemplateInjectionMarker = + '/*MIDSCENE_REPORT_TEMPLATE_CONTENT*/'; + export const commonIgnoreWarnings = [ /Critical dependency: the request of a dependency is an expression/, ]; @@ -24,6 +44,231 @@ export const createTypeCheckPlugin = () => }, }); +type CoreExportEntries = Record; + +const coreRequestRegExp = /^@midscene\/core(?:\/(.+))?$/; + +const toImportSpecifier = (specifierPath: string) => + specifierPath.split(path.sep).join('/'); + +const toRelativeImportSpecifier = (fromDir: string, toPath: string) => { + const relativePath = toImportSpecifier(path.relative(fromDir, toPath)); + return relativePath.startsWith('.') ? relativePath : `./${relativePath}`; +}; + +const wrapperFileName = (subpath: string) => + subpath ? `${subpath.replace(/\//g, '__')}.mjs` : 'index.mjs'; + +const createCoreWrapperModule = ({ + coreModulePath, + wrapperDir, + hasDefaultExport, +}: { + coreModulePath: string; + wrapperDir: string; + hasDefaultExport: boolean; +}) => { + const coreImportSpecifier = toRelativeImportSpecifier( + wrapperDir, + coreModulePath, + ); + return [ + "import './report-template.mjs';", + `export * from '${coreImportSpecifier}';`, + hasDefaultExport ? `export { default } from '${coreImportSpecifier}';` : '', + '', + ].join('\n'); +}; + +export const readCoreExportEntries = ( + corePackageDir: string, +): CoreExportEntries => { + const packageJsonPath = path.join(corePackageDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { + exports?: Record; + }; + const entries: CoreExportEntries = {}; + + for (const [exportPath, exportConfig] of Object.entries( + packageJson.exports || {}, + )) { + const importPath = + typeof exportConfig === 'string' ? exportConfig : exportConfig.import; + if (!importPath?.startsWith('./dist/es/')) { + continue; + } + + const subpath = exportPath === '.' ? '' : exportPath.replace(/^\.\//, ''); + entries[subpath] = importPath.slice('./dist/es/'.length); + } + + if (!Object.keys(entries).length) { + throw new Error( + `No ESM exports found in @midscene/core package.json: ${packageJsonPath}`, + ); + } + + return entries; +}; + +export const prepareCoreWrapperModules = ({ + appDir, + cacheDir, + corePackageDir, + reportTemplatePath, +}: Required< + Pick< + ReportTemplateInjectOptions, + 'appDir' | 'cacheDir' | 'corePackageDir' | 'reportTemplatePath' + > +>) => { + if (!fs.existsSync(reportTemplatePath)) { + throw new Error( + `Report template not found: ${reportTemplatePath}. Build @midscene/report before bundling this target.`, + ); + } + + const templateContent = fs.readFileSync(reportTemplatePath, 'utf-8'); + if (!templateContent.trim()) { + throw new Error(`Report template is empty: ${reportTemplatePath}`); + } + + const coreExportEntries = readCoreExportEntries(corePackageDir); + + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + path.join(cacheDir, 'report-template.mjs'), + // If this is ever written into an inline HTML script, escape ``. + `${reportTemplateInjectionMarker}globalThis.${reportTemplateGlobalName}=${JSON.stringify( + templateContent, + )};\n`, + ); + + for (const [subpath, modulePath] of Object.entries(coreExportEntries)) { + fs.writeFileSync( + path.join(cacheDir, wrapperFileName(subpath)), + createCoreWrapperModule({ + coreModulePath: path.join(corePackageDir, 'dist', 'es', modulePath), + wrapperDir: cacheDir, + hasDefaultExport: subpath === '', + }), + ); + } + + return { + cacheDir, + coreExportEntries, + reportTemplatePath, + relativeCacheDir: path.relative(appDir, cacheDir) || '.', + }; +}; + +export const createCoreReportTemplateReplacementPlugin = ({ + appDir, + cacheDir, + corePackageDir, + enabledIn = 'build', + pluginName = 'replace-core-with-report-template', + reportTemplatePath, +}: ReportTemplateInjectOptions) => ({ + name: pluginName, + setup(api: any) { + // This plugin only consumes the report template build output. It does not + // build @midscene/report; configure bundle targets to build it first. + const isEnabled = () => { + const isDev = api.context?.action === 'dev'; + return enabledIn === 'both' || (enabledIn === 'dev') === isDev; + }; + const resolvedAppDir = appDir; + const workspaceRoot = path.resolve(resolvedAppDir, '..', '..'); + const resolvedCacheDir = + cacheDir || + path.join( + resolvedAppDir, + 'node_modules', + '.cache', + 'midscene-report-template', + 'core-wrapper', + ); + const resolvedCorePackageDir = + corePackageDir || path.join(workspaceRoot, 'packages', 'core'); + const resolvedReportTemplatePath = + reportTemplatePath || + path.join(workspaceRoot, 'apps', 'report', 'dist', 'index.html'); + + let prepared: ReturnType | undefined; + const ensurePrepared = () => { + prepared ||= prepareCoreWrapperModules({ + appDir: resolvedAppDir, + cacheDir: resolvedCacheDir, + corePackageDir: resolvedCorePackageDir, + reportTemplatePath: resolvedReportTemplatePath, + }); + return prepared; + }; + + api.onBeforeCreateCompiler(() => { + if (!isEnabled()) { + return; + } + + const result = ensurePrepared(); + console.log( + `Prepared Midscene core report template wrappers under ${result.relativeCacheDir}`, + ); + }); + + api.resolve(({ resolveData }: any) => { + if (!isEnabled()) { + return; + } + + const match = coreRequestRegExp.exec(resolveData.request); + if (!match) { + return; + } + + const subpath = match[1] || ''; + if (!(subpath in ensurePrepared().coreExportEntries)) { + throw new Error( + `Unsupported @midscene/core deep import for report template replacement: ${resolveData.request}`, + ); + } + + resolveData.request = path.join( + ensurePrepared().cacheDir, + wrapperFileName(subpath), + ); + }); + }, +}); + +export const createReportTemplateSyncPlugin = ({ + srcPath, + destPath, + pluginName = 'sync-report-template', +}: ReportTemplateSyncOptions) => ({ + name: pluginName, + setup(api: any) { + api.onAfterBuild(async () => { + if (!fs.existsSync(srcPath)) { + throw new Error(`Report template source is missing: ${srcPath}`); + } + + const content = await fs.promises.readFile(srcPath, 'utf-8'); + if (!content.trim()) { + throw new Error(`Report template is empty: ${srcPath}`); + } + + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + await fs.promises.copyFile(srcPath, destPath); + console.log( + `Synced Midscene report template from ${srcPath} to ${destPath}`, + ); + }); + }, +}); + export const createCopyStaticPlugin = (options: CopyStaticOptions) => ({ name: options.pluginName || 'copy-static', setup(api: any) {