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