diff --git a/etc/runtime.api.md b/etc/runtime.api.md index 91c459bf83..a7f0e7537d 100644 --- a/etc/runtime.api.md +++ b/etc/runtime.api.md @@ -74,6 +74,7 @@ declare namespace __secret_internals { RenderUseBrickResult, MountUseBrickResult, updateSnippetPreviewSettings, + getBrickPackages, setRealTimeDataInspectRoot, addRealTimeDataInspectHook, legacyDoTransform, @@ -195,6 +196,9 @@ export function getAuth(): object | undefined; // @public export function getBasePath(): string; +// @public (undocumented) +function getBrickPackages(): BrickPackage[]; + // @public (undocumented) function getBrickPackagesById(id: string): BrickPackage | undefined; diff --git a/packages/brick-container/src/bootstrap.ts b/packages/brick-container/src/bootstrap.ts index b09d40661a..5f67750bf6 100644 --- a/packages/brick-container/src/bootstrap.ts +++ b/packages/brick-container/src/bootstrap.ts @@ -7,6 +7,7 @@ import { resetReloadForError, __secret_internals, isNetworkError, + getHistory, } from "@next-core/runtime"; import { HttpRequestConfig, http } from "@next-core/http"; import { i18n, initializeI18n } from "@next-core/i18n"; @@ -20,6 +21,7 @@ import { analytics, } from "@next-core/easyops-runtime"; import "@next-core/theme"; +import type { BootstrapData, Storyboard } from "@next-core/types"; import "./XMLHttpRequest.js"; import { loadCheckLogin } from "./loadCheckLogin.js"; import { fulfilStoryboard, loadBootstrapData } from "./loadBootstrapData.js"; @@ -30,6 +32,10 @@ import { getMock } from "./mocks.js"; import { NS, K, locales } from "./i18n.js"; import { DefaultError } from "./DefaultError.js"; +const isAppPreview = !!new URLSearchParams(window.location.search).get( + "_experimental_app_preview_" +); + customElements.define("easyops-default-error", DefaultError); analytics.initialize( @@ -115,61 +121,126 @@ const requestEnd = (): void => { window.addEventListener("request.start", requestStart); window.addEventListener("request.end", requestEnd); -const runtime = createRuntime({ - hooks: { - auth, - fulfilStoryboard, - checkPermissions, - flowApi, - checkInstalledApps, - menu, - images: { imagesFactory, widgetImagesFactory }, - messageDispatcher, - pageView: analytics.pageView, - }, -}); +function doCreateRuntime() { + return createRuntime({ + hooks: { + auth, + checkPermissions, + flowApi, + checkInstalledApps, + menu, + images: { imagesFactory, widgetImagesFactory }, + messageDispatcher, + ...(isAppPreview + ? null + : { + fulfilStoryboard, + pageView: analytics.pageView, + }), + }, + }); +} initializeI18n(NS, locales); +interface PreviewWindow extends Window { + _preview_only_appPreviewer?: AppPreviewer; +} + +class AppPreviewer { + #setupCalled = false; + + async setup(data: BootstrapData, url: string) { + try { + if (this.#setupCalled) { + throw new Error( + "_preview_only_setupAppPreview can only be called once" + ); + } + this.#setupCalled = true; + history.replaceState(null, "", url); + const runtime = doCreateRuntime(); + await loadCheckLogin(); + await runtime.bootstrap(data); + } catch (error) { + handleError(error); + } + requestEnd(); + } + + async update(appId: string, storyboard: Partial) { + __secret_internals.updateStoryboard(appId, storyboard); + } + + reload() { + getHistory().reload(); + } + + push(url: string) { + getHistory().push(url); + } + + replace(url: string) { + getHistory().replace(url); + } + + goBack() { + getHistory().goBack(); + } + + goForward() { + getHistory().goForward(); + } +} + async function main() { try { - const [, bootstrapData] = await Promise.all([ - loadCheckLogin(), - loadBootstrapData(), - ]); - await runtime.bootstrap(bootstrapData); - resetReloadForError(); + if (isAppPreview) { + requestStart(); + (window as PreviewWindow)._preview_only_appPreviewer = new AppPreviewer(); + return "ok"; + } else { + const runtime = doCreateRuntime(); + const [, bootstrapData] = await Promise.all([ + loadCheckLogin(), + loadBootstrapData(), + ]); + await runtime.bootstrap(bootstrapData); + resetReloadForError(); + } return "ok"; } catch (error) { - // eslint-disable-next-line no-console - console.error("bootstrap failed:", error); - - if (shouldReloadForError(error)) { - location.reload(); - return "failed"; - } + handleError(error); + return "failed"; + } +} - // `.bootstrap-error` makes loading-bar invisible. - document.body.classList.add("bootstrap-error"); - - const errorElement = document.createElement( - "easyops-default-error" - ) as DefaultError; - errorElement.errorTitle = isNetworkError(error) - ? i18n.t(`${NS}:${K.NETWORK_ERROR}`) - : i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`); - errorElement.textContent = httpErrorToString(error); - const linkElement = document.createElement("a"); - linkElement.slot = "link"; - linkElement.href = location.href; - linkElement.textContent = i18n.t(`${NS}:${K.RELOAD}`); - errorElement.appendChild(linkElement); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.querySelector("#main-mount-point")!.replaceChildren(errorElement); +function handleError(error: unknown) { + // eslint-disable-next-line no-console + console.error("bootstrap failed:", error); + if (shouldReloadForError(error)) { + location.reload(); return "failed"; } + + // `.bootstrap-error` makes loading-bar invisible. + document.body.classList.add("bootstrap-error"); + + const errorElement = document.createElement( + "easyops-default-error" + ) as DefaultError; + errorElement.errorTitle = isNetworkError(error) + ? i18n.t(`${NS}:${K.NETWORK_ERROR}`) + : i18n.t(`${NS}:${K.BOOTSTRAP_ERROR}`); + errorElement.textContent = httpErrorToString(error); + const linkElement = document.createElement("a"); + linkElement.slot = "link"; + linkElement.href = location.href; + linkElement.textContent = i18n.t(`${NS}:${K.RELOAD}`); + errorElement.appendChild(linkElement); + + document.querySelector("#main-mount-point")!.replaceChildren(errorElement); } const bootstrapStatus = main(); diff --git a/packages/runtime/src/internal/secret_internals.ts b/packages/runtime/src/internal/secret_internals.ts index 737bb1449f..477baadf91 100644 --- a/packages/runtime/src/internal/secret_internals.ts +++ b/packages/runtime/src/internal/secret_internals.ts @@ -365,6 +365,8 @@ export function getAllContextValues({ return runtimeContext.ctxStore.getAllValues(); } +export { getBrickPackages }; + export function getBrickPackagesById(id: string) { return getBrickPackages().find((pkg) => pkg.id ? pkg.id === id : pkg.filePath.startsWith(`${id}/`) diff --git a/packages/yo/package.json b/packages/yo/package.json index 7920e699a8..6963fdd0a0 100644 --- a/packages/yo/package.json +++ b/packages/yo/package.json @@ -21,6 +21,7 @@ "node": ">=16" }, "dependencies": { + "jsonc-parser": "^3.3.1", "minimist": "^1.2.8", "plop": "^4.0.1" }, diff --git a/packages/yo/src/plopfile.js b/packages/yo/src/plopfile.js index 703a30402d..876f8f1e33 100644 --- a/packages/yo/src/plopfile.js +++ b/packages/yo/src/plopfile.js @@ -1,7 +1,8 @@ import path from "node:path"; import { existsSync } from "node:fs"; -import { readFile, readdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; +import { parse } from "jsonc-parser"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -123,6 +124,10 @@ export default function ( name: "Create a new shared library", value: "shared", }, + { + name: "Setup VSCode testing settings", + value: "vscode-testing", + }, ], }, { @@ -387,6 +392,8 @@ export default function ( templateFiles: "templates/shared", }, ]; + } else if (data.type === "vscode-testing") { + return [initVscodeTesting]; } return []; }, @@ -396,3 +403,125 @@ export default function ( function getObjectPartialInPackageJson(object) { return JSON.stringify(object, null, 4).replace(/\}\s*$/, " }"); } + +async function initVscodeTesting() { + const folders = await getWorkspaceFolders(); + if (folders.length === 0) { + return "No workspace folders found, are you sure this is a monorepo?"; + } + + const vscodeDir = path.join(rootDir, ".vscode"); + if (!existsSync(vscodeDir)) { + await mkdir(vscodeDir); + } + + const settingsJson = path.join(vscodeDir, "settings.json"); + let settings = {}; + if (existsSync(settingsJson)) { + // settings = JSON.parse(await readFile(settingsJson, "utf-8")); + const errors = []; + settings = parse(await readFile(settingsJson, "utf-8"), errors, { + allowTrailingComma: true, + }); + if (errors.length > 0) { + return `Failed to parse existing .vscode/settings.json: ${errors + .map((e) => `at offset ${e.offset} (${e.error})`) + .join(", ")}`; + } + } + Object.assign(settings, { + "jest.runMode": "on-demand", + "jest.jestCommandLine": "npx test-next", + "jest.useJest30": true, + "jest.nodeEnv": { + NODE_ENV: "test", + }, + "jest.virtualFolders": folders.map((folder) => ({ + name: folder, + rootPath: folder, + })), + }); + await writeFile(settingsJson, JSON.stringify(settings, null, 2) + "\n"); + + const launchJson = path.join(vscodeDir, "launch.json"); + let launchConfig = {}; + if (existsSync(launchJson)) { + const errors = []; + launchConfig = parse(await readFile(launchJson, "utf-8"), errors, { + allowTrailingComma: true, + }); + if (errors.length > 0) { + return `Failed to parse existing .vscode/launch.json: ${errors + .map((e) => `at offset ${e.offset} (${e.error})`) + .join(", ")}`; + } + } + const configs = folders.map((folder) => ({ + type: "node", + name: `vscode-jest-tests.v2.${folder}`, + request: "launch", + args: [ + "--runInBand", + "--watchAll=false", + "--testNamePattern", + "${jest.testNamePattern}", + "--runTestsByPath", + "${jest.testFile}", + ], + cwd: `${rootDir}/${folder}`, + console: "integratedTerminal", + internalConsoleOptions: "neverOpen", + program: `${rootDir}/node_modules/.bin/test-next`, + env: { + NODE_ENV: "test", + }, + })); + launchConfig.configurations = [ + ...(launchConfig.configurations?.filter( + (config) => !config.name?.startsWith("vscode-jest-tests.") + ) ?? []), + ...configs, + ]; + await writeFile(launchJson, JSON.stringify(launchConfig, null, 2) + "\n"); + + return 'Updated VSCode testing settings in ".vscode/settings.json" and ".vscode/launch.json".'; +} + +async function getWorkspaceFolders() { + const { workspaces } = packageJson; + /** @type {string[]} */ + const folders = ( + await Promise.all( + workspaces.map(async (pattern) => { + const result = []; + if (pattern.endsWith("/*")) { + const folder = pattern.slice(0, -2); + const dir = path.join(rootDir, folder); + const dirs = await readdir(dir, { + withFileTypes: true, + }); + for (const d of dirs) { + const pkgJsonPath = path.join(dir, d.name, "package.json"); + if (d.isDirectory() && existsSync(pkgJsonPath)) { + const pkg = JSON.parse(await readFile(pkgJsonPath, "utf-8")); + if (pkg.scripts?.test) { + result.push(path.join(folder, d.name)); + } + } + } + } else { + const dir = path.join(rootDir, pattern); + const pkgJsonPath = path.join(dir, "package.json"); + if (existsSync(pkgJsonPath)) { + const pkg = JSON.parse(await readFile(pkgJsonPath, "utf-8")); + if (pkg.scripts?.test) { + result.push(pattern); + } + } + } + return result; + }) + ) + ).flat(); + return folders; +} diff --git a/yarn.lock b/yarn.lock index 4bc3b5b843..148dc7b359 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9112,6 +9112,11 @@ jsonc-parser@3.2.0: resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jsonfile@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz"