diff --git a/.clj-kondo/config/modules/config.edn b/.clj-kondo/config/modules/config.edn index 4f1b8b31c52..02e7083f8bc 100644 --- a/.clj-kondo/config/modules/config.edn +++ b/.clj-kondo/config/modules/config.edn @@ -1303,7 +1303,8 @@ metabase.metabot.core metabase.metabot.init metabase.metabot.self - metabase.metabot.settings} + metabase.metabot.settings + metabase.metabot.tools.entity-details} :friends #{agent-api enterprise/metabot enterprise/metabot-analytics @@ -2505,6 +2506,28 @@ :model/TransformRun :model/TransformTag}} + typed-schemas + {:team "Embedding" + :api #{metabase.typed-schemas.api} + :uses #{actions + api + collections + lib + lib-be + metabot + metrics + models + system + types + util} + :model-imports #{:model/Action + :model/Card + :model/Collection + :model/Database + :model/Field + :model/Measure + :model/Table}} + types {:team "Querying Platform" :api #{metabase.types.core @@ -3072,6 +3095,17 @@ :api #{} :uses #{premium-features}} + enterprise/data-apps + {:team "Gadget" + :api #{metabase-enterprise.data-apps.api + metabase-enterprise.data-apps.init + metabase-enterprise.data-apps.sync} + :uses #{api + models + premium-features + settings + util}} + enterprise/data-complexity-score {:team "Metabot" :api #{metabase-enterprise.data-complexity-score.api @@ -3420,6 +3454,7 @@ task task-history util + enterprise/data-apps enterprise/serialization enterprise/transforms-python} :model-exports #{:model/RemoteSyncTask} diff --git a/.gitignore b/.gitignore index 5ca874c6bbd..a61caada9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ cypress.env.json /resources/frontend_client/app/embedding-sdk.js.map /resources/frontend_client/app/embedding-sdk/ /resources/frontend_client/tmp-*/ +/resources/frontend_client/data-app.html /resources/frontend_client/embed.html /resources/frontend_client/embed-sdk.html /resources/frontend_client/embed-mcp.html diff --git a/.typedoc/typedoc-plugin-not-exported.js b/.typedoc/typedoc-plugin-not-exported.js new file mode 100644 index 00000000000..381784a360a --- /dev/null +++ b/.typedoc/typedoc-plugin-not-exported.js @@ -0,0 +1,27 @@ +import { Comment, Converter } from "typedoc"; + +const TAG = "@notExported"; + +export function load(app) { + app.options.getDeclaration("blockTags").defaultValue?.push?.(TAG); + + app.converter.on(Converter.EVENT_RESOLVE_END, (context) => { + const project = context.project; + const intentional = new Set(app.options.getValue("intentionallyNotExported")); + + for (const id in project.reflections) { + const ref = project.reflections[id]; + const tags = ref.comment?.blockTags ?? []; + for (const tag of tags) { + if (tag.tag === TAG) { + const name = Comment.combineDisplayParts(tag.content).trim(); + if (name) { + intentional.add(name); + } + } + } + } + + app.options.setValue("intentionallyNotExported", [...intentional]); + }); +} diff --git a/.typedoc/typedoc.common.config.js b/.typedoc/typedoc.common.config.js index 836818f502f..a828880c48c 100644 --- a/.typedoc/typedoc.common.config.js +++ b/.typedoc/typedoc.common.config.js @@ -10,6 +10,7 @@ const config = { "typedoc-plugin-dt-links", "typedoc-plugin-redirect", "./typedoc-plugin-replace-text.js", + "./typedoc-plugin-not-exported.js", ], entryPoints: ["../resources/embedding-sdk/dist/index.d.ts"], router: "structure", diff --git a/bin/embedding-sdk/fixup-types-after-compilation.js b/bin/embedding-sdk/fixup-types-after-compilation.js index 367b8b24e91..9b428614074 100644 --- a/bin/embedding-sdk/fixup-types-after-compilation.js +++ b/bin/embedding-sdk/fixup-types-after-compilation.js @@ -1,12 +1,15 @@ #!/usr/bin/env node /* eslint-env node */ -/* eslint-disable import/no-commonjs, import/order, no-console */ +/* eslint-disable import/order */ const fs = require("fs"); const path = require("path"); +const { spawn } = require("child_process"); const glob = require("glob"); -const { Extractor, ExtractorConfig } = require("@microsoft/api-extractor"); const SDK_DIST_DIR_PATH = path.resolve("./resources/embedding-sdk/dist"); +const API_EXTRACTOR_BIN_PATH = path.resolve( + "./node_modules/@microsoft/api-extractor/bin/api-extractor", +); /* * This script replaces all custom aliases in Embedding SDK generated ".d.ts" files so that this imports could be resolved @@ -26,10 +29,52 @@ const REPLACES_MAP = { cljs: "target/cljs_release", }; -const API_EXTRACTOR_CONFIG_PATH = path.join( - __dirname, - "../../enterprise/frontend/src/embedding-sdk-package/embedding-sdk-api-extractor.json", -); +const DTS_ROLLUPS = [ + { + name: "main", + configPath: path.join( + __dirname, + "../../enterprise/frontend/src/embedding-sdk-package/embedding-sdk-api-extractor.json", + ), + entryPointPath: path.join( + __dirname, + "../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/index.d.ts", + ), + }, + { + name: "data-app", + configPath: path.join( + __dirname, + "../../enterprise/frontend/src/embedding-sdk-package/embedding-sdk-data-app-api-extractor.json", + ), + entryPointPath: path.join( + __dirname, + "../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/data-app.d.ts", + ), + }, + { + name: "data-app-dev", + configPath: path.join( + __dirname, + "../../enterprise/frontend/src/embedding-sdk-package/embedding-sdk-data-app-dev-api-extractor.json", + ), + entryPointPath: path.join( + __dirname, + "../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/data-app-dev.d.ts", + ), + }, + { + name: "data-app-dev-config", + configPath: path.join( + __dirname, + "../../enterprise/frontend/src/embedding-sdk-package/embedding-sdk-data-app-dev-config-api-extractor.json", + ), + entryPointPath: path.join( + __dirname, + "../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/data-app-dev.config.d.ts", + ), + }, +]; const getLogger = (prefix) => { const verbose = process.env.SDK_FIXUP_VERBOSE_LOGS === "true"; @@ -110,7 +155,7 @@ const removeUnresolvedReexports = (filePath) => { } }; -const fixupTypesAfterCompilation = ({ isWatchMode }) => { +const fixupTypesAfterCompilation = async ({ isWatchMode }) => { log("Fixing SDK d.ts files..."); const dtsFilePaths = glob.sync(`${SDK_DIST_DIR_PATH}/**/*.d.ts`); @@ -123,45 +168,97 @@ const fixupTypesAfterCompilation = ({ isWatchMode }) => { console.log("[dts fixup] Done!"); if (!isWatchMode) { - generateDtsRollup(); + await generateDtsRollup(); } }; -const generateDtsRollup = () => { +const generateDtsRollup = async () => { // Dts rollup logger const { log, error } = getLogger("dts rollup"); log("Generate dts rollup..."); - const dtsRollupEntryPointPath = path.resolve( - path.join( - __dirname, - "../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/index.d.ts", - ), - ); - const dtsRollupEntryPointPathExist = fs.existsSync(dtsRollupEntryPointPath); + const existingEntryPointCount = DTS_ROLLUPS.filter(({ entryPointPath }) => + fs.existsSync(path.resolve(entryPointPath)), + ).length; - if (!dtsRollupEntryPointPathExist) { + if (existingEntryPointCount === 0) { log("It looks like dts rollup is already generated, skipping..."); return; } - const apiExtractorConfig = ExtractorConfig.loadFileAndPrepare( - API_EXTRACTOR_CONFIG_PATH, + if (existingEntryPointCount !== DTS_ROLLUPS.length) { + DTS_ROLLUPS.forEach(({ entryPointPath, name }) => { + const resolvedEntryPointPath = path.resolve(entryPointPath); + if (!fs.existsSync(resolvedEntryPointPath)) { + error( + `Dts rollup entry point for ${name} does not exist: ${resolvedEntryPointPath}`, + ); + } + }); + process.exitCode = 1; + return; + } + + let hasExtractorFailure = false; + + // The extractors are independent (each reads the shared compiled d.ts tree + // and writes its own rollup), so run them concurrently. Output is buffered + // per extractor and printed on completion so parallel runs don't interleave. + const runApiExtractor = ({ configPath, name }) => + new Promise((resolve) => { + const child = spawn( + process.execPath, + [ + API_EXTRACTOR_BIN_PATH, + "run", + "--local", + "--verbose", + "--config", + path.resolve(configPath), + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let output = ""; + child.stdout.on("data", (chunk) => (output += chunk)); + child.stderr.on("data", (chunk) => (output += chunk)); + child.on("close", (status, signal) => + resolve({ name, status, signal, output }), + ); + }); + + const results = await Promise.all( + DTS_ROLLUPS.filter(({ entryPointPath, name }) => { + if (fs.existsSync(path.resolve(entryPointPath))) { + return true; + } + + error( + `Dts rollup entry point for ${name} does not exist: ${path.resolve(entryPointPath)}`, + ); + hasExtractorFailure = true; + return false; + }).map(runApiExtractor), ); - const extractorResult = Extractor.invoke(apiExtractorConfig, { - localBuild: true, - showVerboseMessages: true, - }); + for (const { name, status, signal, output } of results) { + log(`--- api-extractor: ${name} ---`); + process.stdout.write(output); - if (!extractorResult.succeeded) { - error( - `API Extractor completed with ${extractorResult.errorCount} errors` + - ` and ${extractorResult.warningCount} warnings`, - ); + if (status !== 0 || signal) { + error( + `API Extractor failed for ${name}` + + (signal ? ` with signal ${signal}` : ""), + ); + hasExtractorFailure = true; + } + } + + if (hasExtractorFailure) { process.exitCode = 1; + return; } log("API Extractor completed successfully"); @@ -220,7 +317,7 @@ const isWatchMode = process.argv.includes("--watch"); const run = async () => { // when running on a clean state and with --watch, the folder might not exist yet await waitForFolder(SDK_DIST_DIR_PATH); - fixupTypesAfterCompilation({ isWatchMode }); + await fixupTypesAfterCompilation({ isWatchMode }); if (isWatchMode) { console.log("\n\n\n"); diff --git a/bun.lock b/bun.lock index 456b35e1692..c1c0ceec52f 100644 --- a/bun.lock +++ b/bun.lock @@ -277,6 +277,7 @@ "@types/tether": "^1.4.6", "@types/underscore": "^1.13.0", "@types/use-sync-external-store": "^1.5.0", + "@vitejs/plugin-react": "^6.0.2", "ajv": "^8.5.0", "babel-loader": "^9.1.2", "babel-plugin-ttag": "^1.8.16", @@ -402,6 +403,9 @@ "typedoc-plugin-redirect": "^1.2.0", "typescript-eslint": "8.60.0", "typescript-plugin-css-modules": "^5.1.0", + "vite": "^8.0.16", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vite-plugin-svgr": "^5.2.0", "web-streams-polyfill": "^4.1.0", "webpack": "5.105.0", "webpack-bundle-analyzer": "^4.10.1", @@ -1275,6 +1279,8 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.34.0", "", {}, "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.38.0", "", { "os": "android", "cpu": "arm" }, "sha512-lTN4//sgYywK8ulQo7a/EZVzOTGomGQv2IG/7tMYdqTV3xN3QTqWpXcZBGUzaicC4B882N+5zJLYZ37IWfUMcg=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.38.0", "", { "os": "android", "cpu": "arm64" }, "sha512-XbVgqR1WsIcCkfxwh2tdg3M1MWgR23YOboW2nbB8ab0gInNNLGy7cIAdr78XaoG/bGdaF4488XRhuGWq67xrzA=="], @@ -1439,6 +1445,40 @@ "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + "@rspack/binding": ["@rspack/binding@2.0.3", "", { "optionalDependencies": { "@rspack/binding-darwin-arm64": "2.0.3", "@rspack/binding-darwin-x64": "2.0.3", "@rspack/binding-linux-arm64-gnu": "2.0.3", "@rspack/binding-linux-arm64-musl": "2.0.3", "@rspack/binding-linux-x64-gnu": "2.0.3", "@rspack/binding-linux-x64-musl": "2.0.3", "@rspack/binding-wasm32-wasi": "2.0.3", "@rspack/binding-win32-arm64-msvc": "2.0.3", "@rspack/binding-win32-ia32-msvc": "2.0.3", "@rspack/binding-win32-x64-msvc": "2.0.3" } }, "sha512-4exVNhGhW5RFHjK87XeTKbkA/qAgI5NHJlT1jNqiJv0gcUXLqTOEU3w7f8+f9zUo4JMFvPc0c9veOi4M19YYTg=="], "@rspack/binding-darwin-arm64": ["@rspack/binding-darwin-arm64@2.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4UyCjLJwU/WxR6K1/gG4u3+jUsoaRHJ5rNu9fto/UbvrItwdlVNULChAApqZFw6mcSetMddSjSICeuj5pSB6sA=="], @@ -2157,6 +2197,8 @@ "@visx/vendor": ["@visx/vendor@3.5.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-scale": "4.0.2", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-scale": "4.0.2", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-yt3SEZRVmt36+APsCISSO9eSOtzQkBjt+QRxNRzcTWuzwMAaF3PHCCSe31++kkpgY9yFoF+Gfes1TBe5NlETiQ=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + "@vitest/expect": ["@vitest/expect@2.0.5", "", { "dependencies": { "@vitest/spy": "2.0.5", "@vitest/utils": "2.0.5", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA=="], "@vitest/pretty-format": ["@vitest/pretty-format@2.1.8", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ=="], @@ -2905,7 +2947,7 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], @@ -3119,7 +3161,7 @@ "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - "estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], @@ -3853,6 +3895,30 @@ "lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], "lines-and-columns": ["lines-and-columns@1.1.6", "", {}, "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="], @@ -4869,6 +4935,8 @@ "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + "rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="], "rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="], @@ -5461,6 +5529,12 @@ "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], + "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], + + "vite-plugin-css-injected-by-js": ["vite-plugin-css-injected-by-js@3.5.2", "", { "peerDependencies": { "vite": ">2.0.0-0" } }, "sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ=="], + + "vite-plugin-svgr": ["vite-plugin-svgr@5.2.0", "", { "dependencies": { "@rollup/pluginutils": "^5.3.0", "@svgr/core": "^8.1.0", "@svgr/plugin-jsx": "^8.1.0" }, "peerDependencies": { "vite": ">=3.0.0" } }, "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ=="], + "vitest-axe": ["vitest-axe@0.1.0", "", { "dependencies": { "aria-query": "^5.0.0", "axe-core": "^4.4.2", "chalk": "^5.0.1", "dom-accessibility-api": "^0.5.14", "lodash-es": "^4.17.21", "redent": "^3.0.0" } }, "sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA=="], "vm-browserify": ["vm-browserify@1.1.2", "", {}, "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="], @@ -5815,6 +5889,8 @@ "@nicolo-ribaudo/eslint-scope-5-internals/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], + "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "@pmmmwh/react-refresh-webpack-plugin/source-map": ["source-map@0.7.3", "", {}, "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ=="], "@redocly/cli/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="], @@ -5835,6 +5911,16 @@ "@redocly/respect-core/colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + + "@rollup/pluginutils/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@rollup/pluginutils/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "@rushstack/node-core-library/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "@rushstack/node-core-library/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], @@ -6055,12 +6141,8 @@ "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "@vue/compiler-sfc/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], @@ -7007,8 +7089,12 @@ "ripemd160/hash-base": ["hash-base@3.1.0", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^3.6.0", "safe-buffer": "^5.2.0" } }, "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA=="], + "rollup-plugin-inject/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + "rollup-plugin-inject/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + "router/path-to-regexp": ["path-to-regexp@8.4.0", "", {}, "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg=="], "rxjs/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], @@ -7197,6 +7283,12 @@ "vfile-message/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="], + "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "vite/postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "vitest-axe/aria-query": ["aria-query@5.0.0", "", {}, "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="], "vitest-axe/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], @@ -7463,6 +7555,14 @@ "@redocly/cli/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@rolldown/binding-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], "@storybook/addon-webpack5-compiler-babel/babel-loader/find-cache-dir": ["find-cache-dir@4.0.0", "", { "dependencies": { "common-path-prefix": "^3.0.0", "pkg-dir": "^7.0.0" } }, "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg=="], @@ -8331,6 +8431,8 @@ "unbox-primitive/which-boxed-primitive/is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + "vite/postcss/nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "webpack-dev-middleware/mime-types/mime-db": ["mime-db@1.51.0", "", {}, "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g=="], "webpack-dev-middleware/schema-utils/@types/json-schema": ["@types/json-schema@7.0.9", "", {}, "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ=="], @@ -8517,6 +8619,8 @@ "@redocly/cli/glob/minimatch/brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@storybook/addon-webpack5-compiler-babel/babel-loader/find-cache-dir/pkg-dir": ["pkg-dir@7.0.0", "", { "dependencies": { "find-up": "^6.3.0" } }, "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA=="], "@storybook/addon-webpack5-compiler-babel/babel-loader/schema-utils/@types/json-schema": ["@types/json-schema@7.0.9", "", {}, "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ=="], diff --git a/e2e/support/assets/data-apps/.gitignore b/e2e/support/assets/data-apps/.gitignore new file mode 100644 index 00000000000..875a192f532 --- /dev/null +++ b/e2e/support/assets/data-apps/.gitignore @@ -0,0 +1,6 @@ +# Each test app under this directory commits its `src/` and its `data_app.yaml` +# manifest. Everything else — the SDK/template base pulled in at test setup and +# the built `dist/` bundle (Vite API) — is generated, so it's ignored here. +/*/* +!/*/src +!/*/data_app.yaml diff --git a/e2e/support/assets/data-apps/custom-error-component/data_app.yaml b/e2e/support/assets/data-apps/custom-error-component/data_app.yaml new file mode 100644 index 00000000000..040a3afbd80 --- /dev/null +++ b/e2e/support/assets/data-apps/custom-error-component/data_app.yaml @@ -0,0 +1,2 @@ +name: Custom Error Component +path: ./dist/index.js diff --git a/e2e/support/assets/data-apps/custom-error-component/src/index.tsx b/e2e/support/assets/data-apps/custom-error-component/src/index.tsx new file mode 100644 index 00000000000..db5ed23c0a3 --- /dev/null +++ b/e2e/support/assets/data-apps/custom-error-component/src/index.tsx @@ -0,0 +1,34 @@ +import { + type SdkErrorComponentProps, + StaticQuestion, +} from "@metabase/embedding-sdk-react"; +import type { DataAppFactory } from "@metabase/embedding-sdk-react/data-app"; + +// A question id that does not exist, so the SDK takes its error path — which this +// app overrides with its own `errorComponent`, asserting an app-supplied component +// wins over the host's default neutral error state. +const MISSING_QUESTION_ID = 999999999; + +function CustomError({ message }: SdkErrorComponentProps) { + return ( +
Custom app error: {message}
+ ); +} + +function App() { + return ( +
+

Custom error app

+
+ +
+
+ ); +} + +const factory: DataAppFactory = () => ({ + component: App, + providerProps: { errorComponent: CustomError }, +}); + +export default factory; diff --git a/e2e/support/assets/data-apps/kitchen-sink/data_app.yaml b/e2e/support/assets/data-apps/kitchen-sink/data_app.yaml new file mode 100644 index 00000000000..64aebc33050 --- /dev/null +++ b/e2e/support/assets/data-apps/kitchen-sink/data_app.yaml @@ -0,0 +1,2 @@ +name: Kitchen Sink +path: ./dist/index.js diff --git a/e2e/support/assets/data-apps/kitchen-sink/src/App.tsx b/e2e/support/assets/data-apps/kitchen-sink/src/App.tsx new file mode 100644 index 00000000000..c7747d9569a --- /dev/null +++ b/e2e/support/assets/data-apps/kitchen-sink/src/App.tsx @@ -0,0 +1,436 @@ +import { + InteractiveQuestion, + StaticQuestion, + useAction, +} from "@metabase/embedding-sdk-react"; +import { + aggregations, + breakout, + copy, + DataAppLink, + DataAppRouter, + filter, + orderBy, + useDataAppLocation, + useMetabaseQuery, + useMetabaseQueryObject, +} from "@metabase/embedding-sdk-react/data-app"; +import { type ComponentType, useEffect, useState } from "react"; + +import { getTestEnv } from "./test-env"; + +// The sandbox throws host-realm errors, whose `instanceof Error` can be false in +// the guest; read `.message` defensively. +const describeError = (err: unknown): string => + (err as { message?: string })?.message ?? String(err); + +const PROBE_COLOR = "rgb(0, 128, 0)"; + +function Overview() { + const { scalarQuery, questionQuery } = getTestEnv(); + const ordersCount = useMetabaseQuery(scalarQuery); + const totalOrders = ordersCount.data?.rawRows?.[0]?.[0]; + const ordersQuery = useMetabaseQueryObject(questionQuery); + + return ( +
+

Orders overview

+ +
+
+ Total orders +
+
+ {ordersCount.isLoading ? "…" : String(totalOrders ?? "—")} +
+
+ +

All orders

+
+ {ordersQuery.query ? ( + + ) : ( +
…
+ )} +
+
+ ); +} + +function Details() { + return ( +
+

Order details

+

A nested page reached through the data-app router.

+
+ ); +} + +function QueryStates() { + const { scalarQuery, errorQuery } = getTestEnv(); + const broken = useMetabaseQuery(errorQuery!); + const working = useMetabaseQuery(scalarQuery); + const count = working.data?.rawRows?.[0]?.[0]; + + return ( +
+

Query states

+
{broken.error ? "error" : "no-error"}
+
+ {working.isLoading ? "loading" : "done"} +
+
+ {working.isLoading ? "…" : String(count ?? "—")} +
+ +
+ ); +} + +function StaticQuestionPage() { + const { questionQuery } = getTestEnv(); + const q = useMetabaseQueryObject(questionQuery); + + return ( +
+

Static question

+
+ {q.query ? ( + + ) : ( +
…
+ )} +
+
+ ); +} + +function Combinators() { + const combinators = getTestEnv().combinators!; + const countAgg = aggregations.count(); + + const q = useMetabaseQuery({ + source: combinators.source, + filters: [filter(combinators.filterField, ">", combinators.filterValue)], + aggregations: [countAgg], + breakouts: [breakout(combinators.breakoutField)], + orderBys: [orderBy(countAgg, "desc")], + }); + + return ( +
+

Combinators

+
+ {q.isLoading ? "loading" : "done"} +
+
+ {q.error ? "error" : "no-error"} +
+
+ {String(q.data?.rows?.length ?? 0)} +
+
+ ); +} + +function Actions() { + const { actionId, actionParams } = getTestEnv(); + const action = useAction(actionId ?? null); + const [output, setOutput] = useState("idle"); + + const onExecute = async () => { + try { + const res = await action.execute(actionParams ?? {}); + setOutput(res ? "returned-result" : "returned-null"); + } catch { + setOutput("threw"); + } + }; + + return ( +
+

Actions

+ + +
+ {action.isExecuting ? "executing" : "idle"} +
+
+ {action.result ? "has-result" : "no-result"} +
+
+ {action.error ? "has-error" : "no-error"} +
+
{output}
+
+ ); +} + +function Clipboard() { + const [status, setStatus] = useState("idle"); + + const onCopy = async () => { + try { + await copy("data-app-clipboard-payload"); + setStatus("copied"); + } catch (err) { + setStatus(`failed: ${describeError(err)}`); + } + }; + + return ( +
+

Clipboard

+ +
{status}
+
+ ); +} + +// With no app-supplied `errorComponent`, the host's default neutral error state +// renders here. +function MissingQuestion() { + return ( +
+ +
+ ); +} + +// Throws during render so the host's BundleErrorBoundary reports the failure to +// the parent, which renders its themed error screen. +function ThrowingPage(): JSX.Element { + throw new Error("intentional data-app bundle render error"); +} + +type Probe = { id: string; run: () => void }; + +const BLOCKED_PROBES: Probe[] = [ + { id: "script", run: () => document.createElement("script") }, + { id: "window-open", run: () => window.open("https://blocked.test") }, + { id: "alert", run: () => window.alert("x") }, + { id: "history", run: () => window.history.pushState({}, "", "/x") }, + { + id: "keydown-listener", + run: () => document.addEventListener("keydown", () => {}), + }, + { id: "websocket", run: () => new WebSocket("wss://blocked.test") }, + { + id: "sendbeacon", + run: () => navigator.sendBeacon("https://blocked.test"), + }, +]; + +function Sandbox() { + const { sandbox } = getTestEnv(); + const [probeResults, setProbeResults] = useState>({}); + const [innerHtml, setInnerHtml] = useState("pending"); + const [blockedFetch, setBlockedFetch] = useState("pending"); + const [allowedFetch, setAllowedFetch] = useState("pending"); + const [blockedXhr, setBlockedXhr] = useState("pending"); + const [allowedXhr, setAllowedXhr] = useState("pending"); + + useEffect(() => { + const results: Record = {}; + for (const probe of BLOCKED_PROBES) { + try { + probe.run(); + results[probe.id] = "not blocked"; + } catch { + results[probe.id] = "blocked"; + } + } + setProbeResults(results); + + // `innerHTML` is distorted through DOMPurify, which strips ok"; + setInnerHtml(el.querySelector("script") ? "not stripped" : "stripped"); + } catch (err) { + setInnerHtml(`threw: ${describeError(err)}`); + } + + if (sandbox) { + fetch(sandbox.blockedUrl) + .then((res) => setBlockedFetch(`ok: ${res.status}`)) + .catch((err) => setBlockedFetch(`blocked: ${describeError(err)}`)); + + fetch(sandbox.allowedUrl) + .then((res) => setAllowedFetch(`ok: ${res.status}`)) + .catch((err) => setAllowedFetch(`blocked: ${describeError(err)}`)); + + if (sandbox.xhrBlockedUrl) { + try { + const xhr = new XMLHttpRequest(); + xhr.open("GET", sandbox.xhrBlockedUrl); + setBlockedXhr("not blocked"); + } catch (err) { + setBlockedXhr(`blocked: ${describeError(err)}`); + } + } + + if (sandbox.xhrAllowedUrl) { + const xhr = new XMLHttpRequest(); + xhr.addEventListener("load", () => setAllowedXhr(`ok: ${xhr.status}`)); + xhr.addEventListener("error", () => setAllowedXhr("blocked: error")); + try { + xhr.open("GET", sandbox.xhrAllowedUrl); + xhr.send(); + } catch (err) { + setAllowedXhr(`blocked: ${describeError(err)}`); + } + } + } + }, [sandbox]); + + return ( +
+

Sandbox

+ {BLOCKED_PROBES.map((probe) => ( +
+ {probeResults[probe.id] ?? "pending"} +
+ ))} +
{innerHtml}
+
{blockedFetch}
+
{allowedFetch}
+
{blockedXhr}
+
{allowedXhr}
+
+ ); +} + +function Isolation() { + const [cssInjected, setCssInjected] = useState("pending"); + const [jsMarker, setJsMarker] = useState("pending"); + + useEffect(() => { + // `createElement("style")` is the one tag the data-app sandbox permits, so + // apps can style their own sandboxed document. + try { + const style = document.createElement("style"); + style.textContent = `.isolation-css-probe { color: ${PROBE_COLOR}; } body { background-color: ${PROBE_COLOR}; }`; + document.head.appendChild(style); + setCssInjected("ok"); + } catch (err) { + setCssInjected(`failed: ${describeError(err)}`); + } + + try { + const globals = globalThis as Record; + globals.__DATA_APP_ISOLATION_MARKER__ = "in-app"; + setJsMarker(String(globals.__DATA_APP_ISOLATION_MARKER__)); + } catch (err) { + setJsMarker(`failed: ${describeError(err)}`); + } + }, []); + + return ( +
+

Isolation

+
{cssInjected}
+
+ css probe +
+
{jsMarker}
+
+ ); +} + +const ROUTES: Record = { + "/details": Details, + "/sandboxing": Sandbox, + "/isolation": Isolation, + "/query-states": QueryStates, + "/static-question": StaticQuestionPage, + "/combinators": Combinators, + "/actions": Actions, + "/clipboard": Clipboard, + "/missing-question": MissingQuestion, + "/throw": ThrowingPage, +}; + +function Shell() { + const { pathname, navigate } = useDataAppLocation(); + const Page = ROUTES[pathname] ?? Overview; + + return ( +
+ + +
+ {pathname} +
+ + +
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/e2e/support/assets/data-apps/kitchen-sink/src/index.tsx b/e2e/support/assets/data-apps/kitchen-sink/src/index.tsx new file mode 100644 index 00000000000..97a94779d6c --- /dev/null +++ b/e2e/support/assets/data-apps/kitchen-sink/src/index.tsx @@ -0,0 +1,7 @@ +import type { DataAppFactory } from "@metabase/embedding-sdk-react/data-app"; + +import App from "./App"; + +const factory: DataAppFactory = () => ({ component: App }); + +export default factory; diff --git a/e2e/support/assets/data-apps/kitchen-sink/src/test-env.ts b/e2e/support/assets/data-apps/kitchen-sink/src/test-env.ts new file mode 100644 index 00000000000..c0004477b30 --- /dev/null +++ b/e2e/support/assets/data-apps/kitchen-sink/src/test-env.ts @@ -0,0 +1,20 @@ +// The type is the tests' contract and lives with them; this file is only how the +// app reads it back inside the sandbox realm. +import type { DataAppTestEnv } from "../../../../helpers/data-app-test-env"; + +declare global { + // eslint-disable-next-line no-var -- `var` (not `const`) so `globalThis.x` typechecks + var __METABASE_DATA_APP_TEST_ENV__: DataAppTestEnv | undefined; +} + +export function getTestEnv(): DataAppTestEnv { + const env = globalThis.__METABASE_DATA_APP_TEST_ENV__; + + if (!env) { + throw new Error( + "data-app test env was not injected — pass `testEnv` to H.mockDataApp", + ); + } + + return env; +} diff --git a/e2e/support/assets/data-apps/tsconfig.json b/e2e/support/assets/data-apps/tsconfig.json new file mode 100644 index 00000000000..5775a3ca625 --- /dev/null +++ b/e2e/support/assets/data-apps/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "incremental": false, + "rootDir": "../../../.." + }, + "include": [ + "../../../../frontend/src/types/**/*.d.ts", + "../../../../frontend/src/embedding-sdk-shared/types/*.d.ts", + "../../../../frontend/src/metabase/utils/dayjs.ts", + "*/src/**/*.ts", + "*/src/**/*.tsx" + ], + "exclude": [".build"] +} diff --git a/e2e/support/assets/example_synced_data_apps/data_apps/bad-config/data_app.yaml b/e2e/support/assets/example_synced_data_apps/data_apps/bad-config/data_app.yaml new file mode 100644 index 00000000000..89a1e6b0e9d --- /dev/null +++ b/e2e/support/assets/example_synced_data_apps/data_apps/bad-config/data_app.yaml @@ -0,0 +1 @@ +name: [unterminated diff --git a/e2e/support/assets/example_synced_data_apps/data_apps/bad-config/index.js b/e2e/support/assets/example_synced_data_apps/data_apps/bad-config/index.js new file mode 100644 index 00000000000..2d569610c95 --- /dev/null +++ b/e2e/support/assets/example_synced_data_apps/data_apps/bad-config/index.js @@ -0,0 +1,2 @@ +// Present, but unreachable: its data_app.yaml can't be parsed, so the app is skipped. +export default () => ({ component: null }); diff --git a/e2e/support/assets/example_synced_data_apps/data_apps/broken-bundle/data_app.yaml b/e2e/support/assets/example_synced_data_apps/data_apps/broken-bundle/data_app.yaml new file mode 100644 index 00000000000..400b85650c3 --- /dev/null +++ b/e2e/support/assets/example_synced_data_apps/data_apps/broken-bundle/data_app.yaml @@ -0,0 +1,3 @@ +name: Broken Bundle +# Points at a file that isn't in the repo — a valid config, an un-loadable app. +path: ./missing.js diff --git a/e2e/support/assets/example_synced_data_apps/data_apps/good/data_app.yaml b/e2e/support/assets/example_synced_data_apps/data_apps/good/data_app.yaml new file mode 100644 index 00000000000..800e24a54e0 --- /dev/null +++ b/e2e/support/assets/example_synced_data_apps/data_apps/good/data_app.yaml @@ -0,0 +1,2 @@ +name: Good App +path: ./index.js diff --git a/e2e/support/assets/example_synced_data_apps/data_apps/good/index.js b/e2e/support/assets/example_synced_data_apps/data_apps/good/index.js new file mode 100644 index 00000000000..e68ee3ca4c4 --- /dev/null +++ b/e2e/support/assets/example_synced_data_apps/data_apps/good/index.js @@ -0,0 +1,3 @@ +// A placeholder bundle — this fixture exercises sync/materialization + the admin +// listing + the bundle endpoint, not rendering, so the bytes are served opaquely. +export default () => ({ component: null }); diff --git a/e2e/support/assets/example_synced_data_apps/data_apps/no-config/index.js b/e2e/support/assets/example_synced_data_apps/data_apps/no-config/index.js new file mode 100644 index 00000000000..9c1c2183649 --- /dev/null +++ b/e2e/support/assets/example_synced_data_apps/data_apps/no-config/index.js @@ -0,0 +1,3 @@ +// A bundle with no data_app.yaml beside it — nothing declares this directory an +// app, so sync doesn't discover it. +export default () => ({ component: null }); diff --git a/e2e/support/config.js b/e2e/support/config.js index 58326eac612..aaadc62990c 100644 --- a/e2e/support/config.js +++ b/e2e/support/config.js @@ -26,6 +26,7 @@ import { startCustomVizDevServer, stopCustomVizDevServer, } from "./helpers/e2e-custom-viz-dev-server-tasks"; +import { buildDataApp } from "./helpers/e2e-data-app-tasks"; import { signJwt } from "./helpers/e2e-jwt-tasks"; import { startMockLlmServer, @@ -215,6 +216,7 @@ const defaultConfig = { stopMockLlmServer, startCustomVizDevServer, stopCustomVizDevServer, + buildDataApp, }); /******************************************************************** diff --git a/e2e/support/helpers/build-data-app-fixture.mjs b/e2e/support/helpers/build-data-app-fixture.mjs new file mode 100644 index 00000000000..abc41f12ed2 --- /dev/null +++ b/e2e/support/helpers/build-data-app-fixture.mjs @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { build as bundle } from "esbuild"; +import { build } from "vite"; + +import { + BUILD_CONFIGS_DIR, + DATA_APP_FIXTURES_DIR, + SDK_DATA_APP_DEV_CONFIG_SOURCE, +} from "./data-app-fixture-paths.mjs"; + +const appName = process.argv[2]; +if (!appName) { + console.error("usage: build-data-app-fixture.mjs "); + process.exit(2); +} + +const appDir = path.join(DATA_APP_FIXTURES_DIR, appName); + +if (!fs.existsSync(path.join(appDir, "src"))) { + console.error(`data-app fixture "${appName}" has no src/ at ${appDir}`); + process.exit(2); +} + +const scratchDir = path.join(DATA_APP_FIXTURES_DIR, ".build"); +fs.mkdirSync(scratchDir, { recursive: true }); +const dataAppDevEntry = path.join(scratchDir, `${appName}.data-app-dev.mjs`); + +await bundle({ + entryPoints: [SDK_DATA_APP_DEV_CONFIG_SOURCE], + outfile: dataAppDevEntry, + bundle: true, + platform: "node", + format: "esm", + packages: "external", + alias: { "build-configs": BUILD_CONFIGS_DIR }, + logLevel: "warning", +}); + +const { dataAppConfig } = await import(pathToFileURL(dataAppDevEntry).href); + +process.chdir(appDir); + +await build({ + root: appDir, + configFile: false, + logLevel: "warn", + plugins: dataAppConfig().plugins, +}); diff --git a/e2e/support/helpers/data-app-fixture-paths.mjs b/e2e/support/helpers/data-app-fixture-paths.mjs new file mode 100644 index 00000000000..fcdf0fd950c --- /dev/null +++ b/e2e/support/helpers/data-app-fixture-paths.mjs @@ -0,0 +1,23 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +export const REPO_ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", +}).trim(); + +export const DATA_APP_FIXTURES_DIR = path.join( + REPO_ROOT, + "e2e/support/assets/data-apps", +); + +export const DATA_APP_BUILD_SCRIPT = path.join( + REPO_ROOT, + "e2e/support/helpers/build-data-app-fixture.mjs", +); + +export const SDK_DATA_APP_DEV_CONFIG_SOURCE = path.join( + REPO_ROOT, + "enterprise/frontend/src/embedding-sdk-package/data-app-dev.config.ts", +); + +export const BUILD_CONFIGS_DIR = path.join(REPO_ROOT, "frontend/build"); diff --git a/e2e/support/helpers/data-app-test-env.ts b/e2e/support/helpers/data-app-test-env.ts new file mode 100644 index 00000000000..8e177e682a8 --- /dev/null +++ b/e2e/support/helpers/data-app-test-env.ts @@ -0,0 +1,56 @@ +import type { LocalFieldReference } from "@metabase/embedding-sdk-react/data-app"; + +/** + * The config a spec injects into a data-app fixture (via `H.mockDataApp`'s + * `testEnv`), so a fixture doesn't hard-code values that track the Cypress + * snapshot — sample-DB ids, URLs, the id of an action the spec created. + * + * It lives with the tests rather than inside a fixture because it's the contract + * between the two, and the fixtures are standalone SDK apps outside this TS + * program (see `exclude` in `e2e/tsconfig.json`). A fixture reads it back at + * runtime through its own `getTestEnv()`. + */ +export type DataAppTestEnv = { + scalarQuery: { source: TableSource; aggregations: CountAggregation[] }; + questionQuery: { source: TableSource }; + /** + * `/sandboxing` page: URLs it fetches to probe the sandbox's `allowed_hosts` + * gate. `allowedUrl` is expected to be in `allowed_hosts`, `blockedUrl` is not. + * `xhr*Url` drive the same gate through `XMLHttpRequest`. + */ + sandbox?: { + allowedUrl: string; + blockedUrl: string; + xhrAllowedUrl?: string; + xhrBlockedUrl?: string; + }; + /** + * `/query-states` page: a deliberately invalid query (bad table id), so the hook + * resolves to an `error` state; the page also exercises `refetch`. + */ + errorQuery?: { source: TableSource }; + /** + * `/combinators` page: exercises `filter`/`breakout`/`orderBy`/`aggregations`. + * The page hardcodes a `>` filter and a count aggregation ordered desc, so + * `filterField` must be numeric; `breakoutField` groups the rows. + */ + combinators?: { + source: TableSource; + filterField: LocalFieldReference; + filterValue: number; + breakoutField: LocalFieldReference; + }; + /** + * `/actions` page: the id of the action the spec creates and `useAction` + * executes, so it can't be hard-coded in the app. Left out to exercise the "no + * action id" path, where the hook must not request anything. + */ + actionId?: number; + /** `/actions` page: the parameters the page passes to `execute()`. */ + actionParams?: Record; +}; + +// The queries use the SDK's `source` API (`{ type: "table", id }`); the database +// is resolved from the SDK's metadata store, so no databaseId is passed. +type TableSource = { type: "table"; id: number }; +type CountAggregation = { type: "operator"; operator: "count"; args: [] }; diff --git a/e2e/support/helpers/e2e-data-app-helpers.ts b/e2e/support/helpers/e2e-data-app-helpers.ts new file mode 100644 index 00000000000..981d108f00a --- /dev/null +++ b/e2e/support/helpers/e2e-data-app-helpers.ts @@ -0,0 +1,130 @@ +import * as Urls from "metabase/urls/data-apps"; +import type { DataApp } from "metabase-types/api"; + +import type { DataAppTestEnv } from "./data-app-test-env"; +import { getIframeBody } from "./e2e-embedding-helpers"; +import { LOCAL_GIT_PATH } from "./e2e-remote-sync-helpers"; + +export const SYNCED_DATA_APPS_FIXTURE_PATH = + Cypress.config("projectRoot") + + "/e2e/support/assets/example_synced_data_apps"; + +/** + * Copy the example data-app repo into the working directory of the local git repo + * `setupGitSync` created, so a `commitToRepo` + a real pull materialize its apps. + * The repo holds one directory per materialization outcome (a good app, an app + * whose bundle is missing, a malformed config, and a directory with no config) — + * see the fixture and the sync spec that drives it. + */ +export const copySyncedDataAppsFixture = () => + cy.task("copyDirectory", { + source: SYNCED_DATA_APPS_FIXTURE_PATH, + destination: LOCAL_GIT_PATH, + }); + +/** + * The e2e data-app fixture that `mockDataApp` builds and serves — its directory + * name and its `/apps/` URL segment. One bundle with a page per surface it + * exercises (questions, query hooks, actions, clipboard, sandbox, isolation), so + * the specs share a single build. + */ +export const DATA_APP_NAME = "kitchen-sink"; +export const DATA_APP_DISPLAY_NAME = "Kitchen Sink"; + +/** + * Visit a nested route inside the fixture data app. `openDataApp` encodes the + * slug, so it can't carry a sub-path — deep-links go through a raw `cy.visit`. + */ +export const visitDataAppRoute = (route: string) => + cy.visit(`/apps/${DATA_APP_NAME}/${route}`); + +/** + * A minimal `DataApp`-shaped row. Admin-list state tests use it directly (they + * only assert how the management UI renders the list, no real bundle needed); + * `mockDataApp` uses it for the intercepted metadata of the built fixture. + */ +export const fakeDataApp = (overrides: Partial = {}): DataApp => ({ + id: 1, + name: DATA_APP_NAME, + display_name: DATA_APP_DISPLAY_NAME, + bundle_path: `data_apps/${DATA_APP_NAME}/dist/index.js`, + enabled: true, + allowed_hosts: [], + bundle_hash: "e2e-bundle-hash", + last_synced_sha: "e2e0000", + last_synced_at: "2024-01-01T00:00:00Z", + sync_error: null, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + ...overrides, +}); + +type MockDataAppOptions = { + /** Display name (iframe title + admin list); defaults to the fixture dir name. */ + displayName?: string; + /** `allowed_hosts` served in the bundle response header. */ + allowedHosts?: string[]; + /** + * Config a fixture reads at runtime, so it doesn't hard-code values that track + * the Cypress snapshot (e.g. sample-DB ids). It's JSON-serialized and prepended + * to the served bundle as `globalThis.__METABASE_DATA_APP_TEST_ENV__`; since the + * bundle is evaluated as one script in the sandbox realm, the app reads it as a + * plain global. Typed by `DataAppTestEnv` by default; pass another fixture's + * type as `TestEnv` if it differs. + */ + testEnv?: TestEnv; +}; + +/** + * Set up a data app for the current test + */ +export const mockDataApp = ( + appName: string, + options: MockDataAppOptions = {}, +) => { + const slug = appName; + const displayName = options.displayName ?? appName; + const allowedHosts = options.allowedHosts ?? []; + + // Prelude runs in the sandbox realm before the bundle's factory, so the app + // can read the injected config as a global (see MockDataAppOptions.testEnv). + const prelude = + options.testEnv !== undefined + ? `globalThis.__METABASE_DATA_APP_TEST_ENV__ = ${JSON.stringify(options.testEnv)};\n` + : ""; + + return cy.task("buildDataApp", { appName }).then((bundleCode) => { + const app = fakeDataApp({ + name: slug, + display_name: displayName, + allowed_hosts: allowedHosts, + }); + + cy.intercept("GET", "/api/apps/repo-status", { + configured: true, + }); + cy.intercept("GET", "/api/apps", [app]); + cy.intercept({ method: "GET", pathname: `/api/apps/${slug}` }, app); + cy.intercept( + { method: "GET", pathname: `/api/apps/${slug}/bundle` }, + { + statusCode: 200, + headers: { + "content-type": "text/javascript", + "X-Metabase-Data-App-Allowed-Hosts": JSON.stringify(allowedHosts), + }, + body: prelude + bundleCode, + }, + ); + + return cy.wrap({ slug, displayName }, { log: false }); + }); +}; + +export function openDataApp(slug: string) { + return cy.visit(Urls.dataApp(slug)); +} + +export function dataAppIframe(displayName: string) { + return getIframeBody(`iframe[title="${displayName}"]`); +} diff --git a/e2e/support/helpers/e2e-data-app-tasks.ts b/e2e/support/helpers/e2e-data-app-tasks.ts new file mode 100644 index 00000000000..724e9e0f6e9 --- /dev/null +++ b/e2e/support/helpers/e2e-data-app-tasks.ts @@ -0,0 +1,52 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { + DATA_APP_BUILD_SCRIPT, + DATA_APP_FIXTURES_DIR, + REPO_ROOT, +} from "./data-app-fixture-paths.mjs"; + +/** + * Cypress task: build a data-app fixture and return its bundle code. + */ +export async function buildDataApp({ + appName, +}: { + appName: string; +}): Promise { + const appDir = path.join(DATA_APP_FIXTURES_DIR, appName); + + if (!fs.existsSync(path.join(appDir, "src"))) { + throw new Error(`data-app fixture "${appName}" has no src/ at ${appDir}`); + } + + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [DATA_APP_BUILD_SCRIPT, appName], { + cwd: REPO_ROOT, + stdio: ["ignore", "inherit", "inherit"], + }); + + child.on("error", reject); + child.on("exit", (code) => + code === 0 + ? resolve() + : reject( + new Error( + `data-app build for "${appName}" exited with code ${code}`, + ), + ), + ); + }); + + const bundlePath = path.join(DATA_APP_FIXTURES_DIR, appName, "dist/index.js"); + + if (!fs.existsSync(bundlePath)) { + throw new Error( + `data-app build for "${appName}" produced no bundle at ${bundlePath}`, + ); + } + + return fs.readFileSync(bundlePath, "utf8"); +} diff --git a/e2e/support/helpers/index.ts b/e2e/support/helpers/index.ts index f0ec0ffb3f1..1d4ffd84e7a 100644 --- a/e2e/support/helpers/index.ts +++ b/e2e/support/helpers/index.ts @@ -13,6 +13,8 @@ export * from "./e2e-command-palette-helpers"; export * from "./e2e-comment-helpers"; export * from "./e2e-custom-column-helpers"; export * from "./e2e-custom-viz-helpers"; +export * from "./data-app-test-env"; +export * from "./e2e-data-app-helpers"; export * from "./e2e-dashboard-helpers"; export * from "./e2e-dashboard-visualizer-helpers"; export * from "./e2e-data-studio-helpers"; diff --git a/e2e/test-component/scenarios/embedding-sdk/sdk-question-card-prop.cy.spec.tsx b/e2e/test-component/scenarios/embedding-sdk/sdk-question-card-prop.cy.spec.tsx new file mode 100644 index 00000000000..44dc7023f74 --- /dev/null +++ b/e2e/test-component/scenarios/embedding-sdk/sdk-question-card-prop.cy.spec.tsx @@ -0,0 +1,75 @@ +import { + InteractiveQuestion, + type MetabaseCard, + StaticQuestion, +} from "@metabase/embedding-sdk-react"; + +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers"; +import { mountSdkContent } from "e2e/support/helpers/embedding-sdk-component-testing"; +import { signInAsAdminAndEnableEmbeddingSdk } from "e2e/support/helpers/embedding-sdk-testing"; +import { mockAuthProviderAndJwtSignIn } from "e2e/support/helpers/embedding-sdk-testing/embedding-sdk-helpers"; + +const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; + +// What a data app actually passes: a `card` object built from a query (e.g. the +// output of `createMetabaseQuery` / `useMetabaseQueryObject`) plus the chosen +// `visualization` and its `visualizationSettings`. The serialized-string form is +// an edge case covered by the unit tests; here we verify the data-app path +// renders the chosen chart end-to-end. +const card: MetabaseCard = { + query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["max", ["field", ORDERS.QUANTITY, null]]], + breakout: [["field", ORDERS.PRODUCT_ID, null]], + limit: 2, + }, + }, + visualization: "bar", + visualizationSettings: { + "graph.y_axis.title_text": "Max quantity", + }, +}; + +describe("scenarios > embedding-sdk > sdk-question card prop", () => { + beforeEach(() => { + signInAsAdminAndEnableEmbeddingSdk(); + + cy.signOut(); + mockAuthProviderAndJwtSignIn(); + + cy.intercept("POST", "/api/dataset").as("dataset"); + }); + + it("InteractiveQuestion should render the chosen visualization from the `card` prop", () => { + mountSdkContent(); + + cy.wait("@dataset").then(({ response }) => { + expect(response?.statusCode).to.equal(202); + }); + + getSdkRoot().within(() => { + cy.log("renders the chart, honoring the chosen `bar` visualization"); + cy.findByTestId("visualization-root").should("be.visible"); + cy.findByTestId("table-root").should("not.exist"); + }); + }); + + it("StaticQuestion should render the chosen visualization from the `card` prop", () => { + mountSdkContent(); + + cy.wait("@dataset").then(({ response }) => { + expect(response?.statusCode).to.equal(202); + }); + + getSdkRoot().within(() => { + cy.log("renders the chart, honoring the chosen `bar` visualization"); + cy.findByTestId("visualization-root").should("be.visible"); + cy.findByTestId("table-root").should("not.exist"); + }); + }); +}); diff --git a/e2e/test-component/scenarios/embedding-sdk/static-question.cy.spec.tsx b/e2e/test-component/scenarios/embedding-sdk/static-question.cy.spec.tsx index 79c824a2507..f84ae2e785d 100644 --- a/e2e/test-component/scenarios/embedding-sdk/static-question.cy.spec.tsx +++ b/e2e/test-component/scenarios/embedding-sdk/static-question.cy.spec.tsx @@ -6,6 +6,7 @@ import { type StaticQuestionProps, } from "@metabase/embedding-sdk-react"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { createQuestion, modal, popover } from "e2e/support/helpers"; import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-helpers"; @@ -281,4 +282,43 @@ describe("scenarios > embedding-sdk > static-question", () => { getSdkRoot().button("Alerts").should("be.visible"); }); }); + + it("should not request /api/card/undefined when clicking a data point on a query-only ad-hoc card", () => { + // A query-only ad-hoc card has no saved card id. A static question must not + // navigate on a chart click — previously it did, fetching + // `GET /api/card/undefined` (the new card's id was `undefined`). + cy.intercept("GET", "/api/card/undefined").as("getUndefinedCard"); + + mountSdkContent( + , + ); + + getSdkRoot().within(() => { + cy.log("the aggregated query renders a cartesian chart"); + H.cartesianChartCircle().should("have.length.greaterThan", 0); + + cy.log("clicking a data point must not navigate to a new card"); + H.cartesianChartCircle().first().click(); + + // The chart stays put (retry gives any erroneous navigation time to fire). + H.cartesianChartCircle().should("have.length.greaterThan", 0); + }); + + cy.get("@getUndefinedCard.all").should("have.length", 0); + }); }); diff --git a/e2e/test/scenarios/data-apps/actions.cy.spec.ts b/e2e/test/scenarios/data-apps/actions.cy.spec.ts new file mode 100644 index 00000000000..d7af613db87 --- /dev/null +++ b/e2e/test/scenarios/data-apps/actions.cy.spec.ts @@ -0,0 +1,103 @@ +import { WRITABLE_DB_ID } from "e2e/support/cypress_data"; +import { + DATA_APP_DISPLAY_NAME as APP_DISPLAY_NAME, + DATA_APP_NAME as APP_NAME, + visitDataAppRoute as visitAppRoute, +} from "e2e/support/helpers"; + +import { DATA_APP_TEST_ENV as TEST_ENV } from "./helpers"; + +const { H } = cy; + +const TEST_TABLE = "scoreboard_actions"; +const MODEL_NAME = "Scoreboard model"; +const EXISTING_TEAM = "Amorous Aardvarks"; + +describe( + "scenarios > data apps > actions (useAction)", + { tags: ["@external", "@actions"] }, + () => { + beforeEach(() => { + H.restore("postgres-writable"); + cy.signInAsAdmin(); + H.activateToken("bleeding-edge"); + + H.resetTestTable({ type: "postgres", table: TEST_TABLE }); + H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: TEST_TABLE }); + H.setActionsEnabledForDB(WRITABLE_DB_ID); + H.createModelFromTableName({ + tableName: TEST_TABLE, + modelName: MODEL_NAME, + }); + }); + + const setupActionsApp = (actionParams: Record) => + cy.get("@modelId").then((modelId) => { + H.createImplicitAction({ + model_id: modelId, + kind: "create", + }).then(({ body: action }) => { + H.mockDataApp(APP_NAME, { + displayName: APP_DISPLAY_NAME, + testEnv: { ...TEST_ENV, actionId: action.id, actionParams }, + }); + }); + }); + + it("executes the action, exposing isExecuting and result, and resets", () => { + cy.intercept("POST", "/api/action/*/execute", (req) => { + req.on("response", (res) => res.setDelay(300)); + }); + + setupActionsApp({ team_name: "Data App FC", score: 7 }); + + visitAppRoute("actions"); + H.dataAppIframe(APP_DISPLAY_NAME).within(() => { + cy.findByTestId("action-execute").click(); + cy.findByTestId("action-executing").should("have.text", "executing"); + + cy.findByTestId("action-result", { timeout: 30000 }).should( + "have.text", + "has-result", + ); + cy.findByTestId("action-output").should("have.text", "returned-result"); + cy.findByTestId("action-error").should("have.text", "no-error"); + + cy.findByTestId("action-reset").click(); + cy.findByTestId("action-result").should("have.text", "no-result"); + }); + + H.queryWritableDB( + `SELECT team_name, score FROM ${TEST_TABLE} WHERE team_name = 'Data App FC'`, + ).then(({ rows }) => { + expect(rows).to.have.length(1); + expect(rows[0].score).to.eq(7); + }); + }); + + it("surfaces an error when the action fails", () => { + // `team_name` is unique, so creating a team that already exists fails in the + // database — a real rejection, not a stubbed one. + setupActionsApp({ team_name: EXISTING_TEAM, score: 1 }); + + visitAppRoute("actions"); + H.dataAppIframe(APP_DISPLAY_NAME).within(() => { + cy.findByTestId("action-execute").click(); + + cy.findByTestId("action-error", { timeout: 30000 }).should( + "have.text", + "has-error", + ); + cy.findByTestId("action-result").should("have.text", "no-result"); + cy.findByTestId("action-executing").should("have.text", "idle"); + }); + + H.queryWritableDB( + `SELECT team_name FROM ${TEST_TABLE} WHERE team_name = '${EXISTING_TEAM}'`, + ).then(({ rows }) => { + // Nothing was written - the existing team is still the only one. + expect(rows).to.have.length(1); + }); + }); + }, +); diff --git a/e2e/test/scenarios/data-apps/admin.cy.spec.ts b/e2e/test/scenarios/data-apps/admin.cy.spec.ts new file mode 100644 index 00000000000..454c30ea212 --- /dev/null +++ b/e2e/test/scenarios/data-apps/admin.cy.spec.ts @@ -0,0 +1,106 @@ +import { + DATA_APP_DISPLAY_NAME as APP_DISPLAY_NAME, + DATA_APP_NAME as APP_NAME, +} from "e2e/support/helpers"; + +import { DATA_APP_TEST_ENV as TEST_ENV } from "./helpers"; + +const { H } = cy; + +describe("scenarios > data apps > admin management", () => { + beforeEach(() => { + H.restore(); + cy.signInAsAdmin(); + // `bleeding-edge` grants the `data-apps` premium feature; requires the EE build. + H.activateToken("bleeding-edge"); + }); + + it("Happy Path: lists a data app and renders it in its sandboxed iframe with real SDK data", () => { + H.mockDataApp(APP_NAME, { + displayName: APP_DISPLAY_NAME, + testEnv: TEST_ENV, + }); + + cy.visit("/admin/settings/apps"); + cy.findByRole("link", { name: APP_DISPLAY_NAME }) + .scrollIntoView() + .should("be.visible"); + + cy.intercept("POST", "/api/dataset").as("dataAppQuery"); + + H.openDataApp(APP_NAME); + H.dataAppIframe(APP_DISPLAY_NAME).within(() => { + cy.findByRole("heading", { name: "Orders overview" }).should( + "be.visible", + ); + + cy.findByTestId("orders-count", { timeout: 30000 }) + .invoke("text") + .should("match", /^\d+$/); + + cy.findByText("Subtotal", { timeout: 30000 }).should("be.visible"); + }); + + // The iframe's query requests must be attributed to the data app, so + // query_execution analytics record which app ran them (EMB-2088). + cy.wait("@dataAppQuery").then(({ request }) => { + expect(request.headers["x-metabase-client"]).to.equal("data-app"); + expect(request.headers["x-metabase-client-identifier"]).to.equal( + APP_NAME, + ); + }); + }); + + it("dismisses the promo banner and keeps it hidden across a reload", () => { + cy.intercept("GET", "/api/apps/repo-status", { configured: true }); + cy.intercept("GET", "/api/apps", []); + cy.intercept( + "PUT", + "/api/user-key-value/namespace/user_acknowledgement/key/data-apps-admin-settings-banner", + ).as("ackBanner"); + + cy.visit("/admin/settings/apps"); + + H.main() + .findByText(/AI-generated React apps/) + .should("be.visible"); + cy.findByRole("button", { name: "Dismiss" }).click(); + cy.wait("@ackBanner"); + H.main() + .findByText(/AI-generated React apps/) + .should("not.exist"); + + // The dismissal persists (a real user-key-value write), so a reload keeps it hidden. + cy.reload(); + cy.findByRole("heading", { name: "Data apps" }).should("be.visible"); + H.main() + .findByText(/AI-generated React apps/) + .should("not.exist"); + }); +}); + +describe("scenarios > data apps > upsell (OSS)", { tags: "@OSS" }, () => { + beforeEach(() => { + H.restore(); + cy.signInAsAdmin(); + // No token: on the OSS build the `data-apps` feature is unavailable, so the + // settings page shows the upsell instead of the management UI. + }); + + it("shows the data-apps upsell instead of the management UI", () => { + cy.visit("/admin/settings/apps"); + + H.main().within(() => { + cy.findByText("Build apps on your data").should("be.visible"); + cy.findByText("Try for free").should("be.visible"); + }); + }); + + it("marks the Data apps settings nav item with an upsell gem", () => { + cy.visit("/admin/settings/apps"); + + cy.findByRole("link", { name: /Data apps/ }).within(() => { + cy.findByTestId("upsell-gem").should("exist"); + }); + }); +}); diff --git a/e2e/test/scenarios/data-apps/helpers/index.ts b/e2e/test/scenarios/data-apps/helpers/index.ts new file mode 100644 index 00000000000..95fc15b7ea1 --- /dev/null +++ b/e2e/test/scenarios/data-apps/helpers/index.ts @@ -0,0 +1,38 @@ +import type { LocalFieldReference } from "@metabase/embedding-sdk-react/data-app"; + +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import type { DataAppTestEnv } from "e2e/support/helpers"; + +// These live here (co-located with the specs) rather than in +// `e2e/support/helpers/e2e-data-app-helpers.ts`: that file is part of the +// Cypress support bundle, and importing `cypress_sample_database` there would +// pull the git-ignored `cypress_sample_database.json` into the snapshot-creation +// build — before that file is generated. Specs are bundled only after it exists. + +const { ORDERS_ID } = SAMPLE_DATABASE; + +/** + * A numeric field dimension for the query-builder combinators, hand-built the way + * an app without a generated `metabase.data.ts` schema would build one. + */ +export const dataAppNumericField = ( + fieldId: number, + name: string, +): LocalFieldReference => ({ + type: "column", + fieldId, + tableId: ORDERS_ID, + name, + jsType: "number", +}); + +const source = { type: "table" as const, id: ORDERS_ID }; + +/** The `testEnv` the fixture's Overview page reads (Orders count + question). */ +export const DATA_APP_TEST_ENV: DataAppTestEnv = { + scalarQuery: { + source, + aggregations: [{ type: "operator", operator: "count", args: [] }], + }, + questionQuery: { source }, +}; diff --git a/e2e/test/scenarios/data-apps/sandbox.cy.spec.ts b/e2e/test/scenarios/data-apps/sandbox.cy.spec.ts new file mode 100644 index 00000000000..c3f956e8cfa --- /dev/null +++ b/e2e/test/scenarios/data-apps/sandbox.cy.spec.ts @@ -0,0 +1,210 @@ +import { + DATA_APP_DISPLAY_NAME as APP_DISPLAY_NAME, + DATA_APP_NAME as APP_NAME, + visitDataAppRoute as visitAppRoute, +} from "e2e/support/helpers"; + +import { DATA_APP_TEST_ENV as TEST_ENV } from "./helpers"; + +const { H } = cy; + +describe("scenarios > data apps > sandbox & isolation", () => { + beforeEach(() => { + H.restore(); + cy.signInAsAdmin(); + // `bleeding-edge` grants the `data-apps` premium feature; requires the EE build. + H.activateToken("bleeding-edge"); + }); + + describe("sandboxing", () => { + const ALLOWED_ORIGIN = "https://allowed.data-app.test"; + const ALLOWED_URL = `${ALLOWED_ORIGIN}/ping`; + const BLOCKED_URL = "https://blocked.data-app.test/ping"; + + it("blocks disallowed APIs and cross-origin fetch, but permits allowed_hosts", () => { + // The allowed host is stubbed with a CORS header so the sandbox's real + // network call resolves; the blocked host is never intercepted because the + // sandbox rejects it before it reaches the network. + cy.intercept("GET", ALLOWED_URL, { + statusCode: 200, + headers: { "access-control-allow-origin": "*" }, + body: "pong", + }); + + H.mockDataApp(APP_NAME, { + displayName: APP_DISPLAY_NAME, + allowedHosts: [ALLOWED_ORIGIN], + testEnv: { + ...TEST_ENV, + sandbox: { allowedUrl: ALLOWED_URL, blockedUrl: BLOCKED_URL }, + }, + }); + + H.openDataApp(APP_NAME); + H.dataAppIframe(APP_DISPLAY_NAME).within(() => { + cy.findByRole("heading", { name: "Orders overview" }).should( + "be.visible", + ); + cy.findByRole("link", { name: "Sandbox" }).click(); + + // A blocked DOM API throws synchronously inside the sandbox. + cy.findByTestId("probe-script", { timeout: 30000 }).should( + "have.text", + "blocked", + ); + + // A fetch to a host not in allowed_hosts is rejected by the sandbox. + cy.findByTestId("blocked-fetch-result", { timeout: 30000 }).should( + "contain", + "not in allowed_hosts", + ); + + // A fetch to a host in allowed_hosts reaches the (stubbed) network. + cy.findByTestId("allowed-fetch-result", { timeout: 30000 }).should( + "have.text", + "ok: 200", + ); + }); + }); + }); + + describe("sandbox breadth", () => { + const ALLOWED_ORIGIN = "https://allowed.data-app.test"; + const ALLOWED_URL = `${ALLOWED_ORIGIN}/ping`; + const BLOCKED_URL = "https://blocked.data-app.test/ping"; + + // Method/constructor calls the sandbox distortion replaces with a throwing + // shim. (Getter-only reads like `localStorage` aren't intercepted, so they + // aren't asserted here.) + const BLOCKED_PROBE_IDS = [ + "script", + "window-open", + "alert", + "history", + "keydown-listener", + "websocket", + "sendbeacon", + ]; + + it("blocks a broad set of dangerous globals, strips innerHTML, and gates XHR", () => { + cy.intercept("GET", ALLOWED_URL, { + statusCode: 200, + headers: { "access-control-allow-origin": "*" }, + body: "pong", + }); + + H.mockDataApp(APP_NAME, { + displayName: APP_DISPLAY_NAME, + allowedHosts: [ALLOWED_ORIGIN], + testEnv: { + ...TEST_ENV, + sandbox: { + allowedUrl: ALLOWED_URL, + blockedUrl: BLOCKED_URL, + xhrAllowedUrl: ALLOWED_URL, + xhrBlockedUrl: BLOCKED_URL, + }, + }, + }); + + visitAppRoute("sandboxing"); + H.dataAppIframe(APP_DISPLAY_NAME).within(() => { + BLOCKED_PROBE_IDS.forEach((id) => { + cy.findByTestId(`probe-${id}`, { timeout: 30000 }).should( + "have.text", + "blocked", + ); + }); + + cy.findByTestId("probe-innerhtml").should("have.text", "stripped"); + + // XHR obeys the same allowlist as fetch. + cy.findByTestId("blocked-xhr-result").should("contain", "blocked"); + cy.findByTestId("allowed-xhr-result", { timeout: 30000 }).should( + "have.text", + "ok: 200", + ); + }); + }); + }); + + describe("isolation", () => { + const openIsolationPage = () => { + H.mockDataApp(APP_NAME, { + displayName: APP_DISPLAY_NAME, + testEnv: TEST_ENV, + }); + H.openDataApp(APP_NAME); + H.dataAppIframe(APP_DISPLAY_NAME).within(() => { + cy.findByRole("heading", { name: "Orders overview" }).should( + "be.visible", + ); + cy.findByRole("link", { name: "Isolation" }).click(); + }); + }; + + it("keeps the app's CSS inside the iframe", () => { + openIsolationPage(); + + H.dataAppIframe(APP_DISPLAY_NAME).within(() => { + // The injected