Data Apps Beta (#74840)

* initial proof-of-concept for data apps

POC 2

POC 3

POC 4

Revert "POC 4"

This reverts commit 1f62e574dc.

POC 5

POC 6

* Expose all SDK components to child realm

* Setup app publishing and serving

* Refactor distortion helpers

* Adjust skill

* Add useQuestionQuery SDK hook (#74900)

Co-authored-by: Timofei <timofei@metabase.com>

* Mock METABASE_EMBEDDING_SDK_BUNDLE for data app sdk hooks/components

* Adjust skill for TS project

* Update skill to clearly mention TS

* Add blocked APIs list to skill

* Prototype schema exports and typed hooks for custom data apps (#74978)

* add prototype for schema generation

* add formatting for generated schemas

* add skill file

* useQuestionQuery should take only ID based on @sanex3339 feedback

* add segments, measures, tables and useMetabaseQuery hook

* Add useMetabaseQuery to endowments

* Fix queryDataset

---------

Co-authored-by: Timofei <timofei@metabase.com>

* Add ability to update app bundle

* Simplify hook

* Add spinner instruction

* Mode Data Apps to OSS

* Instruct to not not use blocked event handlers

* Improve API, add styles isolation, add router components (#75418)

* Prepare skills for data app template + adjust router components (#75490)

* Fixes after rebase

* Move migration to v63

* Move migration to v63 #2

* Fix config.edn

* Fix BE tests

* Fix team name

* Fix duplicated config

* Adjust version text in skill

* Fix data app bundle uploading after legacy client removal

* Remove `actions` from config edn

* Fix failing test

* Fix failing test #2

* Add custom actions for data apps (#75627)

* Fix imports

* Fix type annotations

* Adjust migration

* Adjust permissions

* Fixed iframe security

* Data Apps publishing via remote repository POC (#75702)

* Update gitignore with data-app.html

* [PoC] Type safety and modularity improvements for custom data apps (#75381)

* allow separate libraries in schemas

* add metric type checking

* add collections parameter

* check that segments and measures belongs to table in useMetabaseQuery

* require less type information for measures

* require less type information for segments

* use metric dataset endpoint when querying measures in metrics

* add validation for breakout buckets

* fix filter operation type scoping

* expose filter type helper for nicer autocompletions

* update skill file

* add breakout schema helper for better type errors

* update breakout schema helper to accept binning and bucket

* move fields that are not used in compile and runtime to comments to shorten type errors

* enforce data truthfulness in skill

* enforce semantic layer richness in skill

* handle dimension key collisions mapped from many tables

* update skill description

* prevent hallucinated measures and breakouts

* fix field serialization

* add guidance on using filter matcher

* add skill guidance on long results and units

* accept query object in StaticQuestion and InteractiveQuestion

* extract use-metabase-query to multiple files

* update skill file to use sdk question by default

* update skill file to avoid hallucinated derived calculations

* add new functions into sandbox

* kondo and cljfmt lint fixes

* update CSP rules to allow style tags for data apps

* address review feedback on scoping and metric filters

* address code review feedback

* Add aggregations support for breakouts in query hooks (#75723)

* add aggregations support

* add additional aggregation helpers

* Add metrics support in useMetabaseQueryObject hook (#75728)

* add metric queries

* indicate metric support in skill file for useMetabaseQueryObject

* add new version for release

* fix query dataset tests

* update release workflow temporarily

* update skill description

* update skill to use more generic examples

* hotfix: allow esm usage in vite dev mode

* skill file update for table caveat

* update sandbox

* fix type errors in useMetabaseQuery

* add v0.63.2 to release workflow

* update skill file to use the 63-data-apps version

* Move data apps to the EE directory and under `data-apps` token feature (#75871)

* remove skill file alias

* Add error handling for data apps

* Add spinner while iframe is loading

* Add OSS/no token upsell banner placeholder for data apps (#75893)

* ensure a fixed height is set if container has height

* Allow including everything in data and metric library in schema endpoint (#75944)

* add filter for including library and data collections

* address review on skill file

* remove id field from rendered comment

* remove useQuestionQuery and useMetricQuery hook

* Improve env files lookup in skills (#75955)

* Allow `unsafe-eval` for data app iframe (#76004)

* Fix lint

* Fixes after rebase

* Fix duplicated test

* Improve Providers and typesafety (#75934)

* chore: reformat single expression functions to arrow

* Use entity id in questionCollections when generating typed schemas (#76230)

* allow using entity ids for question collection filter

* guide the agent towards using entity ids

* add failing test for invalid entity id

* Make custom viz work in data apps (#76237)

* Allow blob: images in data-app CSP (EMB-1923)

SDK custom visualizations load their icon assets via blob: URLs (to avoid
the cross-origin auth issue), but the /embed/data-app/ route allowed
'unsafe-eval' without the blob: scheme in img-src, so icons were blocked
under a production CSP. Add blob: to img-src scoped to the data-app iframe
route only; the main app img-src stays strict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* EMB-1880: support for `providerProps`  in data apps

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Reduce schema bloat in dimensions of generated metrics when many tables are involved (#76003)

* Add useQuestionQuery SDK hook (#74900)

Co-authored-by: Timofei <timofei@metabase.com>

* Prototype schema exports and typed hooks for custom data apps (#74978)

* add prototype for schema generation

* add formatting for generated schemas

* add skill file

* useQuestionQuery should take only ID based on @sanex3339 feedback

* add segments, measures, tables and useMetabaseQuery hook

* Add useMetabaseQuery to endowments

* Fix queryDataset

---------

Co-authored-by: Timofei <timofei@metabase.com>

* [PoC] Type safety and modularity improvements for custom data apps (#75381)

* allow separate libraries in schemas

* add metric type checking

* add collections parameter

* check that segments and measures belongs to table in useMetabaseQuery

* require less type information for measures

* require less type information for segments

* use metric dataset endpoint when querying measures in metrics

* add validation for breakout buckets

* fix filter operation type scoping

* expose filter type helper for nicer autocompletions

* update skill file

* add breakout schema helper for better type errors

* update breakout schema helper to accept binning and bucket

* move fields that are not used in compile and runtime to comments to shorten type errors

* enforce data truthfulness in skill

* enforce semantic layer richness in skill

* handle dimension key collisions mapped from many tables

* update skill description

* prevent hallucinated measures and breakouts

* fix field serialization

* add guidance on using filter matcher

* add skill guidance on long results and units

* accept query object in StaticQuestion and InteractiveQuestion

* extract use-metabase-query to multiple files

* update skill file to use sdk question by default

* update skill file to avoid hallucinated derived calculations

* add new functions into sandbox

* kondo and cljfmt lint fixes

* update CSP rules to allow style tags for data apps

* address review feedback on scoping and metric filters

* address code review feedback

* Add aggregations support for breakouts in query hooks (#75723)

* add aggregations support

* add additional aggregation helpers

* Add metrics support in useMetabaseQueryObject hook (#75728)

* add metric queries

* indicate metric support in skill file for useMetabaseQueryObject

* add new version for release

* fix query dataset tests

* remove useQuestionQuery and useMetricQuery hook

* Data Apps publishing via remote repository POC (#75702)

* avoid duplicating dimension fields

namespace dimensions by table

add dimension id to mapping

pick from table fields for only allowed dimensions

fix incorrect type guards

allow querying metric using question as source

remove createMetabaseQuery from public interface

add sourceFieldId to metric

* doc generation type fixes

---------

Co-authored-by: Timofei <timofei@metabase.com>

* Add allowed hosts handling for data apps (#76094)

* Update version

* Properly include data app changes in `pull changes` modal (#76294)

* Move app template into main repo, handle skill versioning flow (#76288)

* feat(data-apps): allow passing down a card with viz settings (#75993)

support card prop in sdk question component

* Rename data app env variables and read them only in dev mode (#76604)

* Add custom data apps layout with sliding toolbar (#76600)

* Remove unused helper

* Dev env Sandboxing, Human Readable Errors and Copy Helper (#76597)

* Revert wrong rebase changes

* Improve creating filtering interfaces in custom data apps (#76401)

* update skill files to guide filter usages

* update skill file to prevent filtering mistakes

* move filtering ui patterns into a separate file

* do not treat mb_replace_me as valid creds

* use combobox for filters

* pass title false to sdk question

* combobox trigger before typing

* Add missing exported types

* Apply visualizations and visualization settings from SDK package (#76659)

* ask the agent to pull possible visualizations

* ask the agent to use card prop

* teach the agent when to use viz settings

* Support query-only SDK cards

* Remove cross references between data apps skills (#76661)

Remove data app skill cross references

* Move Data App Dev Vite App setup into SDK package (#76695)

* Don't navigate on ad-hoc question chart clicks (#76751)

* Fix sandboxing crashes and update skill file mistakes (#76858)

Fix data app sandboxing and skill guidance

* Block form submissions in data apps via CSP (#76882)

* Fix SDK theming hover text colors in generated data apps (#77083)

update guidance on colors

* Remove SDK question query prop for data apps use cases (#77080)

* Remove SDK question query prop

* update tests to use card prop instead

* Adopt Lib.createTestQuery for table sources when querying in data apps (#75957)

* Add base metabase-lib query implementation

* switch to Lib.createTestQuery DSL

* Load query metadata at runtime for metabase-lib instead of hard-coding (#76874)

* load query metadata dynamically

* accept id-only source references

* Wait for SDK login before resolving query metadata

* refactor: use useAsyncFn to handle states

* pass source-name to createTestQuery

* Add metric aggregations for useMetabaseQuery via Lib.createTestQuery (#76947)

* add metric sources for useMetabaseQuery via Lib.createTestQuery

* use MetabaseQueryObject instead of opaque type marker

* Fix metric schema and join source validation

* add metric library filtering to skill files

* make metric an aggregation expression instead of join sources

* mark tableId as never in joined fields

* export public MetabaseQueryObject type

* use camelCase for source name and field id

* Add initial data app e2e setup (#77101)

* Add sorting and fields to useMetabaseQuery via Lib.createTestQuery (#76966)

* add sorting, limiting and fields to useMetabaseQuery

* refactor: cleanup sorting and aggregations

* Fix metric order by validation

* Support saved questions for useMetabaseQuery via Lib.createTestQuery (#77073)

* add saved questions as useMetabaseQuery input

* remove extra operators for saved cards

* Add unit fe/be tests for data apps (#76979)

* Fix lint

* Use modern api in query-dataset.ts

* Fix BG color

* Fix execute-action tests

* Update SDK-data-apps version

* Move migrations to v64 folder

* Adjust router components to use metabase/router

* Allow opening a data app for any signed user

* Rename data app skills (#77266)

* Update DataAppLayout.tsx `go back` button logic

* Harden data apps skills and action validation errors (#77261)

* harden data apps skills and actions error validation

* do not display models as questions in data apps

* clarify action result types

* Agents should report errors when models, actions or questions cannot be schema generated (#77260)

* return an error if models or saved questions are invalid

* return an error if actions are invalid

* update skill file guidance on model generation

* fix model boundary

* Add data apps admin UI + routing/sandboxing/isolation e2e (#77271)

* Rename data-apps paths to /apps (#77298)

* Include all action-included models in the typed schema when a mutation is specified (#77291)

* add includeModels=true flag to include all models

* omit model columns and models without actions

* remove extra metadata

* Adjust admin UI for Data Apps (#77420)

* Add more e2e/unit tests for Data Apps (#77524)

* Update bun.lock

* Fix errors after rebase

* Parallelize dts rollup (#77663)

* revert EMB-1840 measures support in metric dataset endpoint

* chore: add data app module (#77705)

* test ci

* apply code review suggestion

* Setup separate entries for client and node dev data app entries (#77687)

* Fix type casts lint errors (#77696)

* Adjust data apps setup + add proper analytics setup (#77604)

* Extract common and scope helpers + tests for typed schema module (#77403)

* refactor: extract common and query param helpers

* refactor: use kebab-case for query params

* refactor: cleanup schema mapping

ci: fix module config

* refactor: rename schema scope helpers to scope.clj

* refactor: extract scope_test.clj

* Extract typed schema rendering helpers to render.clj (#77703)

* extract schema render function

* move runtime compact test out

* remove javascript and json endpoints from schema generator

* refactor: cleanup javascript.clj and render.clj

* Extract typed schema generators for tables and questions (#77729)

* refactor: extract schema generator modules

* remove question-details from model-schemas

* fix: batch queries in table and question schema generator

* remove N+1 in typed schema field table lookup

* Extract typed schema generators for models and actions (#77739)

* extract model schema generator

* batch model action querying to avoid n+1 queries

* Extract typed schema generator for metrics (#77806)

* Extract typed schema generators for models and actions (#77739)

* extract model schema generator

* batch model action querying to avoid n+1 queries

* extract metric schema generator

* test: simplify fields in metric_test fixtures

* refactor: clean up metric schema generation

* remove n+1 from metric field dimension read

* disable default temporal dimension breakout

* surface errors if metric schema cannot be built

* revert metrics/api.clj to master to remove outdated comments

* Fix routes

* Take a data app's slug from its directory name (#77688)

* Merge data-apps specific migrations into one

* ci: fix failing backend test and cljfmt lint

* Add a beta badge in data apps admin page heading (#77877)

marks the data app as beta

* Revert "Merge data-apps specific migrations into one"

This reverts commit 383a5fa28d.

* Revert "Revert "Merge data-apps specific migrations into one""

This reverts commit 948d904b62.

* ci: actionlint fix

* Fix data apps route

* Use getSubpathSafeUrl in attachIframeUrlMirror

* Fix comment

* Do not send apps with sync error for token check

* Add data app listing sidebar section for non-admins (#77977)

* show data apps in sidebar for non-admins

* add app icon to data apps

* use available=true to filter no sync error and enabled apps

* fix navbar keys in data apps

* test: use non-admin user to assert non-admin response

* fix toucan query in data apps listing endpoint

* re-export entity detail helpers and result column to metabot core

* open data apps in new tab from the sidebar

* Prevent race conditions in responses of useMetabaseQuery hook (#78023)

prevent stale responses in useMetabaseQuery

* ci: remove tools.util from module declaration

* Merge migrations into a single one

* reset release-embedding-sdk.yml to master

---------

Co-authored-by: Phoomparin Mano <poom@metabase.com>
Co-authored-by: Phoomparin Mano <phoomparin@gmail.com>
Co-authored-by: Nicolò Pretto <info@npretto.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timofey Kachalov
2026-07-18 20:42:50 +04:00
committed by GitHub
parent 8dd86422fe
commit 806e4b756c
380 changed files with 23613 additions and 996 deletions

View File

@@ -1303,7 +1303,8 @@
metabase.metabot.core metabase.metabot.core
metabase.metabot.init metabase.metabot.init
metabase.metabot.self metabase.metabot.self
metabase.metabot.settings} metabase.metabot.settings
metabase.metabot.tools.entity-details}
:friends #{agent-api :friends #{agent-api
enterprise/metabot enterprise/metabot
enterprise/metabot-analytics enterprise/metabot-analytics
@@ -2505,6 +2506,28 @@
:model/TransformRun :model/TransformRun
:model/TransformTag}} :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 types
{:team "Querying Platform" {:team "Querying Platform"
:api #{metabase.types.core :api #{metabase.types.core
@@ -3072,6 +3095,17 @@
:api #{} :api #{}
:uses #{premium-features}} :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 enterprise/data-complexity-score
{:team "Metabot" {:team "Metabot"
:api #{metabase-enterprise.data-complexity-score.api :api #{metabase-enterprise.data-complexity-score.api
@@ -3420,6 +3454,7 @@
task task
task-history task-history
util util
enterprise/data-apps
enterprise/serialization enterprise/serialization
enterprise/transforms-python} enterprise/transforms-python}
:model-exports #{:model/RemoteSyncTask} :model-exports #{:model/RemoteSyncTask}

1
.gitignore vendored
View File

@@ -89,6 +89,7 @@ cypress.env.json
/resources/frontend_client/app/embedding-sdk.js.map /resources/frontend_client/app/embedding-sdk.js.map
/resources/frontend_client/app/embedding-sdk/ /resources/frontend_client/app/embedding-sdk/
/resources/frontend_client/tmp-*/ /resources/frontend_client/tmp-*/
/resources/frontend_client/data-app.html
/resources/frontend_client/embed.html /resources/frontend_client/embed.html
/resources/frontend_client/embed-sdk.html /resources/frontend_client/embed-sdk.html
/resources/frontend_client/embed-mcp.html /resources/frontend_client/embed-mcp.html

View File

@@ -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]);
});
}

View File

@@ -10,6 +10,7 @@ const config = {
"typedoc-plugin-dt-links", "typedoc-plugin-dt-links",
"typedoc-plugin-redirect", "typedoc-plugin-redirect",
"./typedoc-plugin-replace-text.js", "./typedoc-plugin-replace-text.js",
"./typedoc-plugin-not-exported.js",
], ],
entryPoints: ["../resources/embedding-sdk/dist/index.d.ts"], entryPoints: ["../resources/embedding-sdk/dist/index.d.ts"],
router: "structure", router: "structure",

View File

@@ -1,12 +1,15 @@
#!/usr/bin/env node #!/usr/bin/env node
/* eslint-env node */ /* eslint-env node */
/* eslint-disable import/no-commonjs, import/order, no-console */ /* eslint-disable import/order */
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { spawn } = require("child_process");
const glob = require("glob"); const glob = require("glob");
const { Extractor, ExtractorConfig } = require("@microsoft/api-extractor");
const SDK_DIST_DIR_PATH = path.resolve("./resources/embedding-sdk/dist"); 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 * 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", cljs: "target/cljs_release",
}; };
const API_EXTRACTOR_CONFIG_PATH = path.join( const DTS_ROLLUPS = [
__dirname, {
"../../enterprise/frontend/src/embedding-sdk-package/embedding-sdk-api-extractor.json", 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 getLogger = (prefix) => {
const verbose = process.env.SDK_FIXUP_VERBOSE_LOGS === "true"; 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..."); log("Fixing SDK d.ts files...");
const dtsFilePaths = glob.sync(`${SDK_DIST_DIR_PATH}/**/*.d.ts`); const dtsFilePaths = glob.sync(`${SDK_DIST_DIR_PATH}/**/*.d.ts`);
@@ -123,45 +168,97 @@ const fixupTypesAfterCompilation = ({ isWatchMode }) => {
console.log("[dts fixup] Done!"); console.log("[dts fixup] Done!");
if (!isWatchMode) { if (!isWatchMode) {
generateDtsRollup(); await generateDtsRollup();
} }
}; };
const generateDtsRollup = () => { const generateDtsRollup = async () => {
// Dts rollup logger // Dts rollup logger
const { log, error } = getLogger("dts rollup"); const { log, error } = getLogger("dts rollup");
log("Generate dts rollup..."); log("Generate dts rollup...");
const dtsRollupEntryPointPath = path.resolve( const existingEntryPointCount = DTS_ROLLUPS.filter(({ entryPointPath }) =>
path.join( fs.existsSync(path.resolve(entryPointPath)),
__dirname, ).length;
"../../resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/index.d.ts",
),
);
const dtsRollupEntryPointPathExist = fs.existsSync(dtsRollupEntryPointPath);
if (!dtsRollupEntryPointPathExist) { if (existingEntryPointCount === 0) {
log("It looks like dts rollup is already generated, skipping..."); log("It looks like dts rollup is already generated, skipping...");
return; return;
} }
const apiExtractorConfig = ExtractorConfig.loadFileAndPrepare( if (existingEntryPointCount !== DTS_ROLLUPS.length) {
API_EXTRACTOR_CONFIG_PATH, 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, { for (const { name, status, signal, output } of results) {
localBuild: true, log(`--- api-extractor: ${name} ---`);
showVerboseMessages: true, process.stdout.write(output);
});
if (!extractorResult.succeeded) { if (status !== 0 || signal) {
error( error(
`API Extractor completed with ${extractorResult.errorCount} errors` + `API Extractor failed for ${name}` +
` and ${extractorResult.warningCount} warnings`, (signal ? ` with signal ${signal}` : ""),
); );
hasExtractorFailure = true;
}
}
if (hasExtractorFailure) {
process.exitCode = 1; process.exitCode = 1;
return;
} }
log("API Extractor completed successfully"); log("API Extractor completed successfully");
@@ -220,7 +317,7 @@ const isWatchMode = process.argv.includes("--watch");
const run = async () => { const run = async () => {
// when running on a clean state and with --watch, the folder might not exist yet // when running on a clean state and with --watch, the folder might not exist yet
await waitForFolder(SDK_DIST_DIR_PATH); await waitForFolder(SDK_DIST_DIR_PATH);
fixupTypesAfterCompilation({ isWatchMode }); await fixupTypesAfterCompilation({ isWatchMode });
if (isWatchMode) { if (isWatchMode) {
console.log("\n\n\n"); console.log("\n\n\n");

116
bun.lock
View File

@@ -277,6 +277,7 @@
"@types/tether": "^1.4.6", "@types/tether": "^1.4.6",
"@types/underscore": "^1.13.0", "@types/underscore": "^1.13.0",
"@types/use-sync-external-store": "^1.5.0", "@types/use-sync-external-store": "^1.5.0",
"@vitejs/plugin-react": "^6.0.2",
"ajv": "^8.5.0", "ajv": "^8.5.0",
"babel-loader": "^9.1.2", "babel-loader": "^9.1.2",
"babel-plugin-ttag": "^1.8.16", "babel-plugin-ttag": "^1.8.16",
@@ -402,6 +403,9 @@
"typedoc-plugin-redirect": "^1.2.0", "typedoc-plugin-redirect": "^1.2.0",
"typescript-eslint": "8.60.0", "typescript-eslint": "8.60.0",
"typescript-plugin-css-modules": "^5.1.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", "web-streams-polyfill": "^4.1.0",
"webpack": "5.105.0", "webpack": "5.105.0",
"webpack-bundle-analyzer": "^4.10.1", "webpack-bundle-analyzer": "^4.10.1",
@@ -1275,6 +1279,8 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.34.0", "", {}, "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA=="], "@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-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=="], "@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=="], "@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": ["@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=="], "@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=="], "@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/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=="], "@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=="], "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=="], "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-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=="], "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=="], "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=="], "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lines-and-columns": ["lines-and-columns@1.1.6", "", {}, "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="], "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=="], "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-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=="], "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=="], "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=="], "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=="], "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=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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/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/@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/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=="], "@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=="], "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-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=="], "router/path-to-regexp": ["path-to-regexp@8.4.0", "", {}, "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg=="],
"rxjs/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], "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=="], "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/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=="], "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=="], "@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-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=="], "@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=="], "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/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=="], "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=="], "@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/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=="], "@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=="],

View File

@@ -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

View File

@@ -0,0 +1,2 @@
name: Custom Error Component
path: ./dist/index.js

View File

@@ -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 (
<div data-testid="custom-error-component">Custom app error: {message}</div>
);
}
function App() {
return (
<div data-testid="custom-error-app" style={{ padding: 24 }}>
<h1>Custom error app</h1>
<div style={{ height: 300 }}>
<StaticQuestion questionId={MISSING_QUESTION_ID} />
</div>
</div>
);
}
const factory: DataAppFactory = () => ({
component: App,
providerProps: { errorComponent: CustomError },
});
export default factory;

View File

@@ -0,0 +1,2 @@
name: Kitchen Sink
path: ./dist/index.js

View File

@@ -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 (
<div data-testid="data-app-content" style={{ padding: 24 }}>
<h1 style={{ margin: "0 0 16px" }}>Orders overview</h1>
<section
style={{
padding: 16,
border: "1px solid #e0e0e0",
borderRadius: 8,
marginBottom: 24,
maxWidth: 240,
}}
>
<div
style={{
color: "#6b7280",
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0.5,
}}
>
Total orders
</div>
<div
data-testid="orders-count"
style={{ fontSize: 32, fontWeight: 700 }}
>
{ordersCount.isLoading ? "…" : String(totalOrders ?? "—")}
</div>
</section>
<h2 style={{ margin: "0 0 8px" }}>All orders</h2>
<div style={{ height: 400 }}>
{ordersQuery.query ? (
<InteractiveQuestion card={{ query: ordersQuery.query }} />
) : (
<div>…</div>
)}
</div>
</div>
);
}
function Details() {
return (
<div data-testid="data-app-details" style={{ padding: 24 }}>
<h1 style={{ margin: "0 0 16px" }}>Order details</h1>
<p>A nested page reached through the data-app router.</p>
</div>
);
}
function QueryStates() {
const { scalarQuery, errorQuery } = getTestEnv();
const broken = useMetabaseQuery(errorQuery!);
const working = useMetabaseQuery(scalarQuery);
const count = working.data?.rawRows?.[0]?.[0];
return (
<div data-testid="data-app-query-states" style={{ padding: 24 }}>
<h1>Query states</h1>
<div data-testid="query-error">{broken.error ? "error" : "no-error"}</div>
<div data-testid="query-loading">
{working.isLoading ? "loading" : "done"}
</div>
<div data-testid="query-value">
{working.isLoading ? "…" : String(count ?? "—")}
</div>
<button
type="button"
data-testid="query-refetch"
onClick={() => {
working.refetch();
}}
>
refetch
</button>
</div>
);
}
function StaticQuestionPage() {
const { questionQuery } = getTestEnv();
const q = useMetabaseQueryObject(questionQuery);
return (
<div data-testid="data-app-static-question" style={{ padding: 24 }}>
<h1>Static question</h1>
<div style={{ height: 360 }}>
{q.query ? (
<StaticQuestion card={{ query: q.query }} />
) : (
<div data-testid="static-question-loading">…</div>
)}
</div>
</div>
);
}
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 (
<div data-testid="data-app-combinators" style={{ padding: 24 }}>
<h1>Combinators</h1>
<div data-testid="combinators-loading">
{q.isLoading ? "loading" : "done"}
</div>
<div data-testid="combinators-error">
{q.error ? "error" : "no-error"}
</div>
<div data-testid="combinators-rowcount">
{String(q.data?.rows?.length ?? 0)}
</div>
</div>
);
}
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 (
<div data-testid="data-app-actions" style={{ padding: 24 }}>
<h1>Actions</h1>
<button type="button" data-testid="action-execute" onClick={onExecute}>
execute
</button>
<button
type="button"
data-testid="action-reset"
onClick={() => action.reset()}
>
reset
</button>
<div data-testid="action-executing">
{action.isExecuting ? "executing" : "idle"}
</div>
<div data-testid="action-result">
{action.result ? "has-result" : "no-result"}
</div>
<div data-testid="action-error">
{action.error ? "has-error" : "no-error"}
</div>
<div data-testid="action-output">{output}</div>
</div>
);
}
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 (
<div data-testid="data-app-clipboard" style={{ padding: 24 }}>
<h1>Clipboard</h1>
<button type="button" data-testid="clipboard-copy" onClick={onCopy}>
copy
</button>
<div data-testid="clipboard-status">{status}</div>
</div>
);
}
// With no app-supplied `errorComponent`, the host's default neutral error state
// renders here.
function MissingQuestion() {
return (
<div
data-testid="data-app-missing-question"
style={{ padding: 24, height: 300 }}
>
<StaticQuestion questionId={999999999} />
</div>
);
}
// 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<Record<string, string>>({});
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<string, string> = {};
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 <script>.
try {
const el = document.createElement("div");
el.innerHTML = "<script>window.x=1</script><b>ok</b>";
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 (
<div data-testid="data-app-sandbox" style={{ padding: 24 }}>
<h1 style={{ margin: "0 0 16px" }}>Sandbox</h1>
{BLOCKED_PROBES.map((probe) => (
<div key={probe.id} data-testid={`probe-${probe.id}`}>
{probeResults[probe.id] ?? "pending"}
</div>
))}
<div data-testid="probe-innerhtml">{innerHtml}</div>
<div data-testid="blocked-fetch-result">{blockedFetch}</div>
<div data-testid="allowed-fetch-result">{allowedFetch}</div>
<div data-testid="blocked-xhr-result">{blockedXhr}</div>
<div data-testid="allowed-xhr-result">{allowedXhr}</div>
</div>
);
}
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<string, unknown>;
globals.__DATA_APP_ISOLATION_MARKER__ = "in-app";
setJsMarker(String(globals.__DATA_APP_ISOLATION_MARKER__));
} catch (err) {
setJsMarker(`failed: ${describeError(err)}`);
}
}, []);
return (
<div data-testid="data-app-isolation" style={{ padding: 24 }}>
<h1 style={{ margin: "0 0 16px" }}>Isolation</h1>
<div data-testid="css-injected">{cssInjected}</div>
<div data-testid="isolation-css-probe" className="isolation-css-probe">
css probe
</div>
<div data-testid="js-marker">{jsMarker}</div>
</div>
);
}
const ROUTES: Record<string, ComponentType> = {
"/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 (
<div style={{ fontFamily: "sans-serif" }}>
<nav
style={{
padding: 16,
borderBottom: "1px solid #e0e0e0",
display: "flex",
gap: 16,
flexWrap: "wrap",
}}
>
<DataAppLink to="/">Overview</DataAppLink>
<DataAppLink to="/details">Details</DataAppLink>
<DataAppLink to="/sandboxing">Sandbox</DataAppLink>
<DataAppLink to="/isolation">Isolation</DataAppLink>
<button
type="button"
data-testid="navigate-to-details"
onClick={() => navigate("/details")}
>
navigate to details
</button>
</nav>
<div data-testid="current-pathname" style={{ padding: "0 16px" }}>
{pathname}
</div>
<Page />
</div>
);
}
export default function App() {
return (
<DataAppRouter>
<Shell />
</DataAppRouter>
);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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"]
}

View File

@@ -0,0 +1 @@
name: [unterminated

View File

@@ -0,0 +1,2 @@
// Present, but unreachable: its data_app.yaml can't be parsed, so the app is skipped.
export default () => ({ component: null });

View File

@@ -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

View File

@@ -0,0 +1,2 @@
name: Good App
path: ./index.js

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -26,6 +26,7 @@ import {
startCustomVizDevServer, startCustomVizDevServer,
stopCustomVizDevServer, stopCustomVizDevServer,
} from "./helpers/e2e-custom-viz-dev-server-tasks"; } from "./helpers/e2e-custom-viz-dev-server-tasks";
import { buildDataApp } from "./helpers/e2e-data-app-tasks";
import { signJwt } from "./helpers/e2e-jwt-tasks"; import { signJwt } from "./helpers/e2e-jwt-tasks";
import { import {
startMockLlmServer, startMockLlmServer,
@@ -215,6 +216,7 @@ const defaultConfig = {
stopMockLlmServer, stopMockLlmServer,
startCustomVizDevServer, startCustomVizDevServer,
stopCustomVizDevServer, stopCustomVizDevServer,
buildDataApp,
}); });
/******************************************************************** /********************************************************************

View File

@@ -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 <appName>");
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,
});

View File

@@ -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");

View File

@@ -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<string, string | number>;
};
// 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: [] };

View File

@@ -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/<slug>` 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> = {}): 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<TestEnv> = {
/** 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 = <TestEnv = DataAppTestEnv>(
appName: string,
options: MockDataAppOptions<TestEnv> = {},
) => {
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<string>("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}"]`);
}

View File

@@ -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<string> {
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<void>((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");
}

View File

@@ -13,6 +13,8 @@ export * from "./e2e-command-palette-helpers";
export * from "./e2e-comment-helpers"; export * from "./e2e-comment-helpers";
export * from "./e2e-custom-column-helpers"; export * from "./e2e-custom-column-helpers";
export * from "./e2e-custom-viz-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-helpers";
export * from "./e2e-dashboard-visualizer-helpers"; export * from "./e2e-dashboard-visualizer-helpers";
export * from "./e2e-data-studio-helpers"; export * from "./e2e-data-studio-helpers";

View File

@@ -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(<InteractiveQuestion card={card} />);
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(<StaticQuestion card={card} />);
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");
});
});
});

View File

@@ -6,6 +6,7 @@ import {
type StaticQuestionProps, type StaticQuestionProps,
} from "@metabase/embedding-sdk-react"; } from "@metabase/embedding-sdk-react";
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import { createQuestion, modal, popover } from "e2e/support/helpers"; import { createQuestion, modal, popover } from "e2e/support/helpers";
import { getSdkRoot } from "e2e/support/helpers/e2e-embedding-sdk-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"); 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(
<StaticQuestion
card={{
query: {
database: SAMPLE_DB_ID,
type: "query",
query: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
breakout: [
["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
],
limit: 5,
},
},
}}
/>,
);
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);
});
}); });

View File

@@ -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<string, string | number>) =>
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);
});
});
},
);

View File

@@ -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");
});
});
});

View File

@@ -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 },
};

View File

@@ -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 <style> applies to the app's own document.
cy.findByTestId("css-injected").should("have.text", "ok");
cy.findByTestId("isolation-css-probe").should(
"have.css",
"color",
"rgb(0, 128, 0)",
);
});
// The aggressive `body` rule injected in the iframe does not reach the
// parent document.
cy.get("body").should(
"not.have.css",
"background-color",
"rgb(0, 128, 0)",
);
});
it("keeps the app's JS globals out of the parent realm (near-membrane)", () => {
openIsolationPage();
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
// The app set the global in its own realm and read it back.
cy.findByTestId("js-marker").should("have.text", "in-app");
});
// The parent window never sees the app's global.
cy.window().should("not.have.property", "__DATA_APP_ISOLATION_MARKER__");
});
});
describe("iframe security headers", () => {
it("serves the embed document with the expected CSP + framing headers", () => {
cy.request({
url: `/embed/apps/${APP_NAME}`,
failOnStatusCode: false,
}).then((res) => {
const csp = String(res.headers["content-security-policy"] ?? "");
expect(csp).to.contain("frame-ancestors 'self'");
expect(csp).to.contain("default-src 'none'");
expect(csp).to.contain("form-action 'none'");
expect(csp).to.contain("'unsafe-eval'");
expect(String(res.headers["x-frame-options"] ?? "")).to.match(
/sameorigin/i,
);
});
});
it("renders the app in a locked-down sandboxed iframe", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
H.openDataApp(APP_NAME);
cy.get(`iframe[title="${APP_DISPLAY_NAME}"]`)
.should("have.attr", "sandbox")
.and("contain", "allow-scripts");
});
});
});

View File

@@ -0,0 +1,203 @@
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
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,
dataAppNumericField as numericField,
} from "./helpers";
const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE;
const { H } = cy;
describe("scenarios > data apps > SDK runtime", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
// `bleeding-edge` grants the `data-apps` premium feature; requires the EE build.
H.activateToken("bleeding-edge");
});
describe("query hooks & question components", () => {
const setupQueryStatesApp = () =>
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: {
...TEST_ENV,
// A table id that doesn't exist → the query resolves to an error.
errorQuery: { source: { type: "table", id: 999999 } },
},
});
it("surfaces the useMetabaseQuery error state", () => {
setupQueryStatesApp();
visitAppRoute("query-states");
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByTestId("query-error", { timeout: 30000 }).should(
"have.text",
"error",
);
});
});
it("re-runs the query when the app calls refetch", () => {
cy.intercept("POST", "/api/dataset").as("datasetQuery");
setupQueryStatesApp();
visitAppRoute("query-states");
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
// The query has to have resolved before refetching means anything.
cy.findByTestId("query-value", { timeout: 30000 })
.invoke("text")
.should("match", /^\d+$/);
});
// A refetch of a query that already succeeded renders the same value, so the
// request going out again is the only thing that proves refetch did anything.
cy.get("@datasetQuery.all").then((queriesBefore) => {
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByTestId("query-refetch").click();
});
cy.get("@datasetQuery.all").should(
"have.length",
queriesBefore.length + 1,
);
});
});
it("renders a StaticQuestion from useMetabaseQueryObject", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
visitAppRoute("static-question");
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
// A rendered value, not just the header: the hook's query object has to
// have actually run.
cy.findAllByTestId("header-cell", { timeout: 30000 }).then(
($headers) => {
const subtotalIndex = [...$headers].findIndex(
(header) => header.textContent?.trim() === "Subtotal",
);
expect(subtotalIndex, "Subtotal column").to.be.at.least(0);
// Cells are row-major, so the first row's Subtotal sits at the
// column's own index.
cy.findByTestId("table-body")
.findAllByTestId("cell-data")
.should("have.length.greaterThan", subtotalIndex)
.then(($cells) => {
const subtotal = $cells.eq(subtotalIndex).text();
expect(parseFloat(subtotal.replace(/[^\d.]/g, ""))).to.be.above(
0,
);
});
},
);
});
});
it("builds a query with filter/breakout/orderBy/aggregations helpers", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: {
...TEST_ENV,
combinators: {
source: { type: "table", id: ORDERS_ID },
filterField: numericField(ORDERS.TOTAL, "TOTAL"),
filterValue: 50,
breakoutField: numericField(ORDERS.PRODUCT_ID, "PRODUCT_ID"),
},
},
});
visitAppRoute("combinators");
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByTestId("combinators-loading", { timeout: 30000 }).should(
"have.text",
"done",
);
cy.findByTestId("combinators-error").should("have.text", "no-error");
// `.should(callback)` retries until the query resolves with rows.
cy.findByTestId("combinators-rowcount", { timeout: 30000 }).should(
($el) => {
expect(Number($el.text())).to.be.greaterThan(0);
},
);
});
});
});
describe("clipboard (copy)", () => {
it("writes text to the clipboard from a user click", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
visitAppRoute("clipboard");
// Headless Chrome reports the iframe document as unfocused, so the real
// `navigator.clipboard.writeText` rejects ("Document is not focused").
// Stub it: the test still verifies the app reaches the sanctioned `copy`
// (not blocked by the sandbox) with the right text — the OS write itself
// is browser behavior, not ours.
H.dataAppIframe(APP_DISPLAY_NAME).then(($body) => {
const win = $body[0].ownerDocument.defaultView;
if (win) {
const writeText = cy.stub(win.navigator.clipboard, "writeText");
writeText.resolves();
writeText.as("writeText");
}
});
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByTestId("clipboard-copy").click();
cy.findByTestId("clipboard-status", { timeout: 30000 }).should(
"have.text",
"copied",
);
});
cy.get("@writeText").should(
"have.been.calledOnceWith",
"data-app-clipboard-payload",
);
});
});
describe("error component", () => {
it("shows the default neutral error state for a missing question", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
visitAppRoute("missing-question");
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByText(/not found/i, { timeout: 30000 }).should("be.visible");
});
});
it("lets an app override the default with its own errorComponent", () => {
const CUSTOM_APP = "custom-error-component";
const CUSTOM_DISPLAY = "Custom Error App";
H.mockDataApp(CUSTOM_APP, { displayName: CUSTOM_DISPLAY });
H.openDataApp(CUSTOM_APP);
H.dataAppIframe(CUSTOM_DISPLAY).within(() => {
cy.findByTestId("custom-error-component", { timeout: 30000 })
.should("be.visible")
.and("contain", "Custom app error");
});
});
});
});

View File

@@ -0,0 +1,95 @@
const { H } = cy;
/**
* Drives a real remote-sync pull of a repo whose `data_apps/` covers every
* materialization outcome, and asserts each is handled the way the backend
* intends (see `data-apps.sync` / `data-apps.config`):
*
* good/ valid config + bundle -> materialized, served
* broken-bundle/ valid config, missing bundle -> row with "Sync failed", not served
* bad-config/ malformed data_app.yaml -> skipped, no row
* no-config/ a bundle but no data_app.yaml -> not discovered, no row
*
* A bad app never blocks a good one, and neither a bad config nor a missing
* bundle removes an app that isn't theirs.
*/
describe("scenarios > data apps > repo sync", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.setupGitSync();
});
it("materializes each app per its config/bundle, isolating the broken ones", () => {
H.copySyncedCollectionFixture();
H.copySyncedDataAppsFixture();
H.commitToRepo("Add data apps with mixed config/bundle states");
H.configureGitAndPullChanges("read-write");
cy.visit("/admin/settings/apps");
cy.findByTestId("admin-layout-content").within(() => {
// The good app is materialized and shown as synced. Scope to its own row so
// its status can't be satisfied by another app's — a "Sync failed" leaking
// onto the good app (or "Synced" onto the broken one) must fail the test.
cy.findByTestId("data-app-list-item-good")
.scrollIntoView()
.within(() => {
cy.findByRole("link", { name: "Good App" }).should("be.visible");
cy.findByText(/^Synced/).should("be.visible");
});
// The app whose bundle is missing still appears — with its failure, not hidden.
// Its name is plain text, not a link: a sync-failed app can't be opened.
cy.findByTestId("data-app-list-item-broken-bundle")
.scrollIntoView()
.within(() => {
cy.findByText("Broken Bundle").should("be.visible");
cy.findByRole("link", { name: "Broken Bundle" }).should("not.exist");
cy.findByText("Sync failed").should("be.visible");
});
// The malformed config and the config-less directory produced no app at all.
cy.findByText("/apps/bad-config").should("not.exist");
cy.findByText("/apps/no-config").should("not.exist");
});
// The API tells the same story: exactly the two apps, and only the good one serves a bundle.
cy.request("GET", "/api/apps").then(({ body: apps }) => {
expect(apps.map((app: { name: string }) => app.name).sort()).to.deep.eq([
"broken-bundle",
"good",
]);
});
cy.request("/api/apps/good/bundle").its("status").should("eq", 200);
cy.request({
url: "/api/apps/broken-bundle/bundle",
failOnStatusCode: false,
}).then(({ status, body }) => {
expect(status).to.eq(404);
expect(body).to.deep.eq({ error: "Bundle not synced yet" });
});
for (const slug of ["bad-config", "no-config"]) {
cy.request({
url: `/api/apps/${slug}/bundle`,
failOnStatusCode: false,
}).then(({ status, body }) => {
expect(status).to.eq(404);
expect(body).to.eq("Not found.");
});
}
// And what a user opening the broken app sees: its metadata loads (the app
// exists), the host frames it, and the bundle 404 surfaces from inside the
// iframe as the "isn't ready yet" screen — driven by the real pull, no mocks.
// (The not-found screen for a missing app is covered in viewing.cy.spec.ts.)
cy.visit("/apps/broken-bundle");
H.main()
.findByText(/isn.t ready yet/i, { timeout: 30000 })
.should("be.visible");
});
});

View File

@@ -0,0 +1,147 @@
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 > viewing & routing", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
// `bleeding-edge` grants the `data-apps` premium feature; requires the EE build.
H.activateToken("bleeding-edge");
});
describe("viewing permissions", () => {
it("lets a non-admin open a data app by direct URL", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
// A normal (non-admin) user has data access, so the app opens and renders
// its data — the admin gate is only on *managing* apps, not viewing them.
cy.signInAsNormalUser();
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+$/);
});
});
it("opens the app shell for a user without data access, but shows no data", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
// The `nodata` user can open the app (viewing isn't gated), but the query
// the app runs goes through the QP with the user's own permissions — with
// no data access it resolves to no data (the fixture renders "—").
cy.signIn("nodata");
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 }).should(
"have.text",
"—",
);
});
});
it("shows a not-found state for a disabled or missing app", () => {
// No app with this slug exists, so the metadata endpoint really 404s and the
// host renders a not-found state rather than a broken iframe — no mock needed.
H.openDataApp("does-not-exist");
H.main().findByText("Data app not found").should("be.visible");
});
});
describe("internal routing", () => {
it("mirrors internal route changes into the parent URL", () => {
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",
);
// Navigate to a nested page using the exposed `DataAppLink`.
cy.findByRole("link", { name: "Details" }).click();
cy.findByRole("heading", { name: "Order details" }).should(
"be.visible",
);
// The app's own router moved, too — not just the page that rendered.
cy.findByTestId("current-pathname").should("have.text", "/details");
});
// The iframe's client-side navigation is mirrored to the parent's URL bar
// (via replaceState), so the top-level path reflects the nested route.
cy.location("pathname").should("eq", `/apps/${APP_NAME}/details`);
});
it("starts on the target page when deep-linked directly to a sub-route", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
visitAppRoute("details");
H.dataAppIframe(APP_DISPLAY_NAME).within(() => {
cy.findByRole("heading", { name: "Order details" }).should(
"be.visible",
);
cy.findByTestId("current-pathname").should("have.text", "/details");
});
});
it("navigates imperatively via useDataAppLocation().navigate", () => {
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.findByTestId("navigate-to-details").click();
cy.findByRole("heading", { name: "Order details" }).should(
"be.visible",
);
cy.findByTestId("current-pathname").should("have.text", "/details");
});
cy.location("pathname").should("eq", `/apps/${APP_NAME}/details`);
});
});
describe("host error / not-ready screens", () => {
it("shows a themed host error screen when the bundle throws while rendering", () => {
H.mockDataApp(APP_NAME, {
displayName: APP_DISPLAY_NAME,
testEnv: TEST_ENV,
});
visitAppRoute("throw");
// The throw is caught in the iframe and reported to the parent, which
// renders its themed failure screen in the host realm.
H.main()
.findByText(/couldn.t be loaded/i, { timeout: 30000 })
.should("be.visible");
});
});
});

View File

@@ -15,8 +15,15 @@
"@metabase/embedding-sdk-react": [ "@metabase/embedding-sdk-react": [
"../enterprise/frontend/src/embedding-sdk-package/index.ts" "../enterprise/frontend/src/embedding-sdk-package/index.ts"
], ],
"@metabase/embedding-sdk-react/data-app": [
"../enterprise/frontend/src/embedding-sdk-package/data-app.ts"
],
"*": ["../frontend/src/*"] "*": ["../frontend/src/*"]
} }
}, },
"include": ["**/*"] "include": ["**/*"],
// Data-app fixtures are standalone SDK apps (built by Vite, typechecked by
// their own tsconfig) — not part of the e2e TS program. Including them pulls
// SDK source outside this project's rootDir.
"exclude": ["support/assets/data-apps"]
} }

View File

@@ -17,6 +17,7 @@
[metabase-enterprise.content-translation.routes] [metabase-enterprise.content-translation.routes]
[metabase-enterprise.content-verification.api.routes] [metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.custom-viz-plugin.api] [metabase-enterprise.custom-viz-plugin.api]
[metabase-enterprise.data-apps.api]
[metabase-enterprise.data-complexity-score.api] [metabase-enterprise.data-complexity-score.api]
[metabase-enterprise.data-studio.api] [metabase-enterprise.data-studio.api]
[metabase-enterprise.database-replication.api :as database-replication.api] [metabase-enterprise.database-replication.api :as database-replication.api]
@@ -48,6 +49,7 @@
[metabase-enterprise.workspaces.api] [metabase-enterprise.workspaces.api]
[metabase.api.macros :as api.macros] [metabase.api.macros :as api.macros]
[metabase.api.util.handlers :as handlers] [metabase.api.util.handlers :as handlers]
[metabase.request.core :as request]
[metabase.util.i18n :refer [deferred-tru]])) [metabase.util.i18n :refer [deferred-tru]]))
(comment metabase-enterprise.advanced-config.api.logs/keep-me) (comment metabase-enterprise.advanced-config.api.logs/keep-me)
@@ -60,6 +62,7 @@
:collection-cleanup (deferred-tru "Collection Cleanup") :collection-cleanup (deferred-tru "Collection Cleanup")
:content-translation (deferred-tru "Content translation") :content-translation (deferred-tru "Content translation")
:custom-viz (deferred-tru "Custom Visualizations") :custom-viz (deferred-tru "Custom Visualizations")
:data-apps (deferred-tru "Data Apps")
:library (deferred-tru "Library") :library (deferred-tru "Library")
:dependencies (deferred-tru "Dependency Tracking") :dependencies (deferred-tru "Dependency Tracking")
:schema-viewer (deferred-tru "Schema Viewer") :schema-viewer (deferred-tru "Schema Viewer")
@@ -93,6 +96,10 @@
TODO -- Please fix them! See #22687" TODO -- Please fix them! See #22687"
{"/moderation-review" metabase-enterprise.content-verification.api.routes/routes {"/moderation-review" metabase-enterprise.content-verification.api.routes/routes
;; Data-app bundle hosting. Naughty because the FE route lives at `/apps/:slug`
;; (and `/api/apps/...`), NOT `/api/ee/...` — `/app/*` is reserved for static
;; assets, so we keep the public path stable here. Superuser-only inside the handler.
(str "/" request/data-app-url-segment) (premium-handler metabase-enterprise.data-apps.api/routes :data-apps)
"/mt" metabase-enterprise.sandbox.api.routes/sandbox-routes "/mt" metabase-enterprise.sandbox.api.routes/sandbox-routes
"/table" metabase-enterprise.sandbox.api.routes/sandbox-table-routes}) "/table" metabase-enterprise.sandbox.api.routes/sandbox-table-routes})

View File

@@ -11,6 +11,7 @@
[metabase-enterprise.audit-app.init] [metabase-enterprise.audit-app.init]
[metabase-enterprise.cache.init] [metabase-enterprise.cache.init]
[metabase-enterprise.custom-viz-plugin.init] [metabase-enterprise.custom-viz-plugin.init]
[metabase-enterprise.data-apps.init]
[metabase-enterprise.data-complexity-score.init] [metabase-enterprise.data-complexity-score.init]
[metabase-enterprise.database-replication.init] [metabase-enterprise.database-replication.init]
[metabase-enterprise.dependencies.init] [metabase-enterprise.dependencies.init]

View File

@@ -0,0 +1,183 @@
(ns metabase-enterprise.data-apps.api
"Data-app endpoints, mounted at `/api/apps`.
Data apps come from the repository connected via the remote-sync feature. The
repo's `data_apps/<dir>/data_app.yaml` files are discovered on sync and each is
materialized as one `data_app` row, caching the app's bundle. Users navigate to
`/apps/:slug`, which fetches `/api/apps/:slug/bundle` and evaluates the
bytes inside a Near Membrane sandbox.
Note: we can't use `/app/...` for the frontend or `/api/app/...` for the API
because Metabase's server reserves `/app/*` for serving static assets (see
`metabase.server.routes/static-files-handler`)."
(:require
[clojure.string :as str]
[metabase-enterprise.data-apps.models.data-app :as data-app]
[metabase-enterprise.data-apps.sync :as data-app.sync]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.util.json :as json]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2])
(:import
(java.io ByteArrayInputStream)))
(set! *warn-on-reflection* true)
;;; ------------------------------------------------ Constants ------------------------------------------------
;; Slug must not collide with the literal `repo-status` sub-route.
(def ^:private slug-regex #"(?!repo-status$)[^/]+")
(def ^:private bundle-response-headers
;; `no-cache` so the browser may cache but must revalidate via
;; the content-hash ETag — we answer If-None-Match with 304 below.
{"Content-Type" "application/javascript"
"X-Content-Type-Options" "nosniff"
"Cross-Origin-Resource-Policy" "same-origin"
"Referrer-Policy" "no-referrer"
"Cache-Control" "no-cache"})
;;; ------------------------------------------------ Helpers ------------------------------------------------
(defn- repo-status []
(let [url (data-app.sync/repo-url)]
{:configured (some? url)
:url url}))
(defn- if-none-match-hashes
"Parse an `If-None-Match` header into the set of bare hashes it lists, dropping
any `W/` weak prefix and surrounding quotes (handles a comma-separated list)."
[header]
(->> (some-> header (str/split #"\s*,\s*"))
(map #(-> % (str/replace-first #"^W/" "") (str/replace #"^\"|\"$" "")))
set))
;;; ------------------------------------------------ Schemas ------------------------------------------------
(def ^:private DataAppResponse
[:map
[:id ms/PositiveInt]
[:name ms/NonBlankString]
[:display_name ms/NonBlankString]
[:bundle_path ms/NonBlankString]
[:enabled :boolean]
[:allowed_hosts [:sequential :string]]
[:bundle_hash [:maybe :string]]
[:last_synced_sha [:maybe :string]]
[:last_synced_at [:maybe :any]]
[:sync_error [:maybe :string]]
[:created_at :any]
[:updated_at :any]])
(def ^:private PublicDataAppResponse
[:map {:closed true}
[:name ms/NonBlankString]
[:display_name ms/NonBlankString]])
(def ^:private RepoStatusResponse
[:map
[:configured :boolean]
[:url [:maybe :string]]])
;;; --------------------------------------------- Repo status ---------------------------------------------
(api.macros/defendpoint :get "/repo-status" :- RepoStatusResponse
"Status of the connected repository as it relates to data apps. Data apps are
pulled by the remote-sync import (manual \"Pull changes\", auto-import, or
startup); the connection is configured on the remote-sync settings page."
[]
(api/check-superuser)
(repo-status))
;;; ------------------------------------------------ Apps ------------------------------------------------
(defn- data-app-response
"Return full data-app metadata to superusers and only navigational fields to other users."
[app]
(if api/*is-superuser?*
app
(select-keys app [:name :display_name])))
(api.macros/defendpoint :get "/" :- [:sequential [:or DataAppResponse PublicDataAppResponse]]
"List the data apps provided by the connected repository. Pass `available=true`
to return only enabled apps without sync errors."
[_route-params
{:keys [available]} :- [:map [:available {:optional true} [:maybe :boolean]]]]
(->> (data-app/select-non-blob (cond-> {:order-by [[:display_name :asc]]}
available (assoc :where [:and
[:= :enabled true]
[:= :sync_error nil]])))
(map api/read-check)
(mapv data-app-response)))
;; NOTE on the `slug-regex` constraint: the default path-param matcher allows
;; slashes inside a segment, so `/:slug` would otherwise swallow `/x/bundle`.
;; The regex also excludes the literal `repo-status` sub-route above.
(api.macros/defendpoint :put ["/:slug" :slug slug-regex] :- DataAppResponse
"Enable or disable a single data app. Disabled apps are not served."
[{:keys [slug]} :- [:map [:slug ms/NonBlankString]]
_query-params
{:keys [enabled]} :- [:map [:enabled :boolean]]]
(api/check-superuser)
(let [app (api/check-404 (data-app/select-one-non-blob :name slug))]
(t2/update! :model/DataApp :id (:id app) {:enabled enabled})
(data-app/select-one-non-blob :id (:id app))))
(api.macros/defendpoint :delete ["/:slug" :slug slug-regex] :- :nil
"Remove a single data app (its row and cached bundle). Intended for clearing
out apps once their repository is no longer connected — a sync never deletes,
and while a repo is connected it would just re-materialize the app."
[{:keys [slug]} :- [:map [:slug ms/NonBlankString]]]
(api/check-superuser)
;; `t2/delete!` returns the row count; a 0 means the slug wasn't there → 404.
(api/check-404 (pos? (t2/delete! :model/DataApp :name slug)))
;; a `nil` body is rendered as a 204; matches the `:- :nil` response schema
;; above (returning `generic-204-no-content` would fail that validation).
nil)
(api.macros/defendpoint :get ["/:slug" :slug slug-regex] :- [:or DataAppResponse PublicDataAppResponse]
"Fetch metadata for a single enabled data app by its slug."
[{:keys [slug]} :- [:map [:slug ms/NonBlankString]]]
(data-app-response (api/read-check (data-app/select-one-non-blob :name slug :enabled true))))
(api.macros/defendpoint :get ["/:slug/bundle" :slug slug-regex] :- :any
"Serve the cached JS bundle for a single enabled data app by slug. Honors
`If-None-Match` against the content-hash ETag with a 304."
[{:keys [slug]} :- [:map [:slug ms/NonBlankString]]
_query-params
_body
request
respond
raise]
(try
(let [row (api/read-check (data-app/select-one-non-blob :name slug :enabled true))
hash (:bundle_hash row)
etag (some->> hash (format "\"%s\""))]
(cond
(and hash (contains? (if-none-match-hashes (get-in request [:headers "if-none-match"])) hash))
;; 304 carries only the cacheable headers (Cache-Control) + ETag — never
;; Content-Type or a body, per RFC 9110 §15.4.5.
(respond {:status 304, :headers {"Cache-Control" "no-cache", "ETag" etag}})
:else
(let [^bytes bundle (t2/select-one-fn :bundle :model/DataApp :id (:id row))]
(if (and bundle (pos? (alength bundle)))
(respond {:status 200
:headers (-> bundle-response-headers
;; JSON array of origins the sandboxed bundle may fetch/XHR; the
;; iframe reads this to configure its Near-Membrane fetch allowlist.
(assoc "X-Metabase-Data-App-Allowed-Hosts"
(json/encode (:allowed_hosts row)))
(cond-> etag (assoc "ETag" etag)))
:body (ByteArrayInputStream. bundle)})
(respond {:status 404
:headers {"Content-Type" "application/json"}
:body "{\"error\":\"Bundle not synced yet\"}"})))))
(catch Throwable e
(raise e))))
(def routes
"`/api/apps` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,140 @@
(ns metabase-enterprise.data-apps.config
"Parsing + validation of a per-app `data_app.yaml`. Each data app lives in its
own directory under `data_apps/` at the repo root, alongside its built bundle:
data_apps/
sales/ # the directory name is the slug: /apps/sales
data_app.yaml
dist/index.js
where `data_app.yaml` is:
name: Sales dashboard # display name
path: dist/index.js # bundle path, relative to this app's directory
allowed_hosts: # optional — origins the sandboxed bundle may fetch/XHR
- https://api.example.com
- https://*.internal.acme.com"
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.yaml :as yaml])
(:import
(java.io ByteArrayInputStream)))
(set! *warn-on-reflection* true)
(def config-file-name
"Name of the per-app config file (used in error messages)."
"data_app.yaml")
(def apps-dir
"Directory at the repo root that holds one subdirectory per data app."
"data_apps")
(def ^:private slug-pattern
"Lowercase letters/numbers separated by single dashes. An app *directory* must
match this: the name is used verbatim as the slug, never normalized. Folding
case here would reintroduce the collisions the directory naming rules out —
git happily holds both `data_apps/Sales` and `data_apps/sales`."
#"[a-z0-9]+(?:-[a-z0-9]+)*")
(def ^:private reserved-slugs
"Slugs that collide with literal `/api/apps/*` sub-routes (see the API's
`slug-regex`). An app with one of these would sync but be unreachable, so we
reject it during parsing."
#{"repo-status"})
(defn- normalize-path
"Trim and drop a leading `./` so the path is relative to the app directory."
[p]
(-> (str p) str/trim (str/replace #"^\./" "")))
(def ^:private allowed-host-re
"A single `allowed_hosts` entry: an origin the app's sandboxed bundle may
`fetch`/XHR — scheme + host, an optional `*.` subdomain wildcard, and an
optional port. No path/query/`*`/non-http(s) scheme: it names an
origin, e.g. `https://api.example.com` or `https://*.internal.acme.com`."
#"(?i)https?://(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)*(:\d+)?")
(defn- normalized-allowed-host
"Trim, lowercase, and drop trailing slashes from one `allowed_hosts` entry."
[entry]
(some-> entry str str/trim u/lower-case-en (str/replace #"/+$" "")))
(defn- parse-allowed-host
"Normalize + validate a single `allowed_hosts` entry (see [[allowed-host-re]]).
Throws an `ex-info` with `:status-code` 400 when it isn't an origin-only value."
[entry ^String dir]
(let [s (normalized-allowed-host entry)]
(when-not (and (seq s) (re-matches allowed-host-re s))
(throw (ex-info (tru "{0}/{1}: \"{2}\" is not a valid allowed_hosts entry — use an origin like https://api.example.com or https://*.example.com."
dir config-file-name (str entry))
{:status-code 400})))
s))
(defn- parse-allowed-hosts
"Validate + normalize the optional `allowed_hosts` list from a parsed config.
Each entry must be an origin-only value (see [[allowed-host-re]]); returns a
lowercased, de-duplicated vector, or `[]` when the key is absent. Throws an
`ex-info` with `:status-code` 400 on a non-list value or an invalid entry."
[parsed ^String dir]
(let [raw (:allowed_hosts parsed)]
(cond
(nil? raw) []
(not (sequential? raw))
(throw (ex-info (tru "{0}/{1}: \"allowed_hosts\" must be a list." dir config-file-name)
{:status-code 400}))
:else
(->> raw
(map #(parse-allowed-host % dir))
distinct
vec))))
(defn- path-traversal? [path]
(some #(= ".." %) (str/split path #"/")))
(defn- parse-yaml [^bytes bytes ^String dir]
(try
(with-open [r (io/reader (ByteArrayInputStream. bytes) :encoding "UTF-8")]
(yaml/parse-stream r))
(catch Exception e
(throw (ex-info (tru "Could not parse {0}/{1}: {2}" dir config-file-name (ex-message e))
{:status-code 400})))))
(defn- dir-slug
"The app's slug: the name of its directory (`data_apps/sales` -> `sales`)."
[^String dir]
(subs dir (inc (str/last-index-of dir "/"))))
(defn parse-app-config
"Parse the bytes of one `data_app.yaml` from the app directory `dir` (e.g.
`data_apps/sales`) into `{:slug ..., :display_name ..., :path ...,
:allowed_hosts [...]}`. The slug is the directory's name; `path` is relative to
the directory; `:allowed_hosts` is a (possibly empty) vector of origins the
sandboxed bundle may reach. Throws an `ex-info` with `:status-code` 400 on
malformed or incomplete content — including a directory whose name isn't a
usable slug, since that app has no URL to be served at."
[^bytes bytes ^String dir]
(let [parsed (parse-yaml bytes dir)
slug (dir-slug dir)
name (some-> (:name parsed) str str/trim not-empty)
path (some-> (:path parsed) normalize-path not-empty)
allowed-hosts (parse-allowed-hosts parsed dir)]
(when-not (re-matches slug-pattern slug)
(throw (ex-info (tru "{0}: the app directory''s name is its slug, so it must be lowercase letters, numbers, and dashes." dir)
{:status-code 400})))
(when (contains? reserved-slugs slug)
(throw (ex-info (tru "{0}: \"{1}\" is a reserved slug — rename the directory." dir slug)
{:status-code 400})))
(when-not name
(throw (ex-info (tru "{0}/{1}: \"name\" is required." dir config-file-name)
{:status-code 400})))
(when-not path
(throw (ex-info (tru "{0}/{1}: \"path\" is required." dir config-file-name)
{:status-code 400})))
(when (path-traversal? path)
(throw (ex-info (tru "{0}/{1}: \"path\" must not contain \"..\"." dir config-file-name)
{:status-code 400})))
{:slug slug, :display_name name, :path path, :allowed_hosts allowed-hosts}))

View File

@@ -0,0 +1,16 @@
(ns metabase-enterprise.data-apps.csp
"EE implementation of the data-app CSP `connect-src` host lookup declared in
[[metabase.server.middleware.security]]. Kept separate from the HTTP API so
the core security middleware's lookup doesn't pull in route code."
(:require
[metabase-enterprise.data-apps.models.data-app :as data-app]
[metabase.premium-features.core :refer [defenterprise]]))
(defenterprise data-app-connect-src-hosts
"Origins the enabled data app with `slug` may reach (its `allowed_hosts`), or
`[]` when there is no such enabled app. Drives the data-app iframe document's
CSP `connect-src` so the sandboxed bundle can fetch/XHR those origins."
:feature :data-apps
[slug]
(or (:allowed_hosts (data-app/select-one-non-blob :name slug :enabled true))
[]))

View File

@@ -0,0 +1,7 @@
(ns metabase-enterprise.data-apps.init
(:require
[metabase-enterprise.data-apps.api]
[metabase-enterprise.data-apps.config]
[metabase-enterprise.data-apps.csp]
[metabase-enterprise.data-apps.models.data-app]
[metabase-enterprise.data-apps.sync]))

View File

@@ -0,0 +1,78 @@
(ns metabase-enterprise.data-apps.models.data-app
(:require
[metabase.api.common :as api]
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2])
(:import
(java.sql Blob)))
(set! *warn-on-reflection* true)
(methodical/defmethod t2/table-name :model/DataApp [_model] :data_app)
(defn- blob->bytes ^bytes [v]
(cond
(nil? v) nil
(instance? Blob v) (let [^Blob b v] (.getBytes b 1 (int (.length b))))
:else v))
(def ^:private transform-bundle
"Coerce JDBC `Blob` values into plain byte arrays on read."
{:in identity
:out blob->bytes})
(t2/deftransforms :model/DataApp
{:bundle transform-bundle
;; JSON array of origins the sandboxed bundle may fetch/XHR (see config.clj).
:allowed_hosts mi/transform-json})
(doto :model/DataApp
(derive :metabase/model)
(derive :hook/timestamped?))
;; Reads always see `allowed_hosts` as a vector, never nil — a row synced before
;; the column existed has NULL until it's re-synced. Guard on `contains?` so
;; selects that don't fetch the column (e.g. `select-one-fn :bundle`) are left
;; untouched rather than gaining a spurious `:allowed_hosts` key.
(t2/define-after-select :model/DataApp
[app]
(cond-> app
(contains? app :allowed_hosts) (update :allowed_hosts #(or % []))))
(def non-blob-columns
"Columns to select for normal data-app metadata reads, excluding the raw bundle blob."
[:id :name :display_name :bundle_path :enabled :allowed_hosts
:bundle_hash :last_synced_sha :last_synced_at :sync_error
:created_at :updated_at])
(defn select-one-non-blob
"Like `t2/select-one` on `:model/DataApp`, but excludes the bundle blob."
[& conditions]
(apply t2/select-one (into [:model/DataApp] non-blob-columns) conditions))
(defn select-non-blob
"Like `t2/select` on `:model/DataApp`, but excludes the bundle blob."
[& conditions]
(apply t2/select (into [:model/DataApp] non-blob-columns) conditions))
;; Any signed-in user may view (open) a data app. The `/api/apps` endpoints
;; are `+auth`, so reaching a read-check already means the user is authenticated.
;; Data the app queries still runs through the QP with the user's own
;; permissions, so a viewer without data access simply sees no data.
(defmethod mi/can-read? :model/DataApp
([_instance] true)
([_model _pk] true))
(defmethod mi/can-write? :model/DataApp
([_instance] api/*is-superuser?*)
([_model _pk] api/*is-superuser?*))
(defmethod mi/can-create? :model/DataApp
[_model _instance]
api/*is-superuser?*)
(methodical/defmethod mi/to-json :model/DataApp
"Never include the raw bundle bytes in JSON."
[data-app json-generator]
(next-method (dissoc data-app :bundle) json-generator))

View File

@@ -0,0 +1,201 @@
(ns metabase-enterprise.data-apps.sync
"Materialize data apps from a synced repository snapshot.
Data apps ride the remote-sync import pipeline: whenever remote-sync pulls the
connected repository (manual \"Pull changes\", auto-import poll, or startup),
it calls [[sync-from-snapshot!]] with the just-imported snapshot. We discover
every `data_apps/<dir>/data_app.yaml` and materialize one `data_app` row per app
(caching the built bundle). A sync only upserts — it never deletes, so an app
whose directory is gone from the repo is kept until an admin removes it.
This namespace does no Git access of its own — the snapshot's `read-file` /
`list-files` come from remote-sync. The cached `bundle` blob is what serving
reads, so a failed sync never takes a working app offline, and the admin
`enabled` toggle is preserved across syncs."
(:require
[clojure.string :as str]
[metabase-enterprise.data-apps.config :as data-app.config]
[metabase.settings.core :as setting]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[toucan2.core :as t2])
(:import
(java.security MessageDigest)
(java.util.regex Pattern)
(org.apache.commons.codec.binary Hex)))
(set! *warn-on-reflection* true)
(def ^:const max-bundle-bytes
"Cap on a synced bundle's size (10 MiB)."
(* 10 1024 1024))
(defn- bytes-hash ^String [^bytes b]
(let [^MessageDigest md (MessageDigest/getInstance "SHA-256")]
(Hex/encodeHexString ^bytes (.digest md b))))
(defn- ->bytes ^bytes [^String s]
(.getBytes s "UTF-8"))
(defn repo-url
"The connected remote-sync repository URL, or nil when none is configured. Read
by keyword so this OSS namespace has no compile-time dependency on the
enterprise remote-sync module; returns nil when that module (and its setting)
isn't loaded."
[]
(let [url (try (setting/get :remote-sync-url) (catch Throwable _ nil))]
(when-not (str/blank? url)
url)))
;;; ----------------------------------------------------- Discovery -----------------------------------------------------
(def ^:private app-dir-regex
;; the data_apps/<dir> folder a repo path sits in (dir = a single path segment)
(re-pattern (format "(%s/[^/]+)/.*" (Pattern/quote data-app.config/apps-dir))))
(defn- discover-app-configs
"Given the snapshot's `list-files` and `read-file` fns (where `read-file`
returns file text or nil), iterate the folders under `data_apps/` and return one
entry per folder that is an app — it holds a `data_app.yaml`; a folder without
one is skipped. Each entry is a parsed app
`{:slug :display_name :bundle :allowed_hosts}` (with `:bundle` the repo-root
relative bundle path) or `{:config-error <message>}`. Parse/read failures are
isolated per app so one bad config can't abort the sync."
[list-files read-file]
(let [files (set (list-files))
app-dirs (distinct (keep #(second (re-matches app-dir-regex %)) files))]
(for [dir app-dirs
:let [config-path (files (str dir "/data_app.yaml"))]
:when config-path]
(try
(if-let [content (read-file config-path)]
(let [{:keys [slug display_name path allowed_hosts]} (data-app.config/parse-app-config (->bytes content) dir)]
{:slug slug, :display_name display_name, :bundle (str dir "/" path), :allowed_hosts allowed_hosts})
{:config-error (tru "Could not read {0}." config-path)})
(catch Throwable e
{:config-error (ex-message e)})))))
;;; ----------------------------------------------------- Materialize -----------------------------------------------------
(defn- upsert-by-name!
"Insert or update by slug. Never writes `:enabled`, so the admin toggle (and the
DB default of true for new rows) is preserved across syncs."
[slug row]
(if (t2/exists? :model/DataApp :name slug)
(t2/update! :model/DataApp :name slug row)
(t2/insert! :model/DataApp (assoc row :name slug))))
(defn- app-content-changed?
"Whether the just-synced content differs from the `existing` row (nil = a new
app). Compares only content-bearing fields — a `last_synced_sha` /
`last_synced_at` bump on an otherwise-identical app is NOT a change, so callers
can count real changes (e.g. for the remote-sync pull summary)."
[existing {:keys [display_name bundle_path bundle_hash allowed_hosts]}]
(or (nil? existing)
(some? (:sync_error existing))
(not= (:display_name existing) display_name)
(not= (:bundle_path existing) bundle_path)
(not= (:bundle_hash existing) bundle_hash)
(not= (vec (or (:allowed_hosts existing) []))
(vec (or allowed_hosts [])))))
(defn- sync-app!
"Materialize one app. On bundle failure, the row's metadata is still upserted
with `sync_error` set so the app appears in the list with its failure; the
previously cached bundle (if any) is kept. `existing` is the app's pre-sync row
(or nil); returns true when this sync actually changed the app's content (a new
app, differing bundle/metadata, or a new failure) so callers can count changes."
[existing {:keys [slug display_name bundle sha read-file allowed_hosts]}]
(try
(let [content (read-file bundle)
_ (when-not content
(throw (ex-info (tru "Bundle file \"{0}\" not found in the repository." bundle)
{:status-code 400})))
^bytes bytes (->bytes content)]
(when (> (alength bytes) max-bundle-bytes)
(throw (ex-info (tru "Bundle for \"{0}\" must be less than {1} MiB."
slug (quot max-bundle-bytes (* 1024 1024)))
{:status-code 413})))
(let [fields {:display_name display_name
:allowed_hosts allowed_hosts
:bundle_path bundle
:bundle_hash (bytes-hash bytes)}]
(upsert-by-name! slug (assoc fields
:bundle bytes
:last_synced_sha sha
:last_synced_at :%now
:sync_error nil))
(app-content-changed? existing fields)))
(catch Throwable e
(upsert-by-name! slug {:display_name display_name
:allowed_hosts allowed_hosts
:bundle_path bundle
:sync_error (ex-message e)})
(log/warnf e "[data-app] failed to sync app %s" slug)
;; a failing app counts as a change unless it was already failing identically
(or (nil? existing) (not= (:sync_error existing) (ex-message e))))))
(defn import-from-snapshot!
"Materialize data apps from a synced repo `snapshot`:
{:read-file (fn [path] -> <file-text-string> | nil)
:list-files (fn [] -> [<path-string> ...])
:sha <commit-sha-string>}
Discovers every `data_apps/<dir>/data_app.yaml` and upserts a row per app in a
single transaction. Apps not present in the snapshot are left untouched (a
sync never deletes — removal is an explicit admin action). Returns
`{:synced <n>, :changed <n>, :sha <sha>, :config-errors [<message> ...]}`,
where `:changed` is how many apps this sync actually created/updated
(a `last_synced_sha` bump on an unchanged app does not count). A malformed
`data_app.yaml` is isolated (collected into `:config-errors`, that app skipped)
rather than aborting the others; per-app bundle failures are recorded on the
row.
Two apps can't collide on a slug here: a slug *is* an app's directory name (see
the config namespace), a repo can't hold two `data_apps/<slug>` directories, and
discovery takes one config per directory (see [[discover-app-configs]])."
[{:keys [read-file list-files sha]}]
;; realize discovery once (it calls read-file/parse per config); `results` is
;; then walked several times below
(let [results (vec (discover-app-configs list-files read-file))
good (filter :slug results)
errors (vec (keep :config-error results))
;; pre-sync rows, so we can tell a real change from a sha/timestamp bump
existing (into {} (map (juxt :name identity))
(t2/select [:model/DataApp :name :display_name :allowed_hosts
:bundle_path :bundle_hash :sync_error]))
changed
;; Upsert-only: a sync never deletes. Apps materialized from a previous
;; repo are kept (they survive unlinking, and switching repos), and an
;; app whose directory name matches an existing app's is overridden in
;; place by `upsert-by-name!`. Rows are removed only by an explicit admin
;; action (see the API's DELETE).
(t2/with-transaction [_conn]
(reduce (fn [n cfg]
(cond-> n
(sync-app! (get existing (:slug cfg))
(assoc cfg :sha sha :read-file read-file))
inc))
0 good))]
(log/infof "[data-app] synced sha=%s apps=%d changed=%d errors=%d"
sha (count good) changed (count errors))
{:synced (count good), :changed changed, :sha sha, :config-errors errors}))
(defn sync-from-snapshot!
"Entry point for the remote-sync import pipeline. Materializes data apps from
the just-imported `snapshot` (see [[import-from-snapshot!]] for its shape).
Never throws, so it can't break the surrounding remote-sync import: a thrown
failure is logged and swallowed, and a malformed `data_app.yaml` is logged and
its app simply doesn't appear. Returns the [[import-from-snapshot!]] result, or
nil if the sync threw."
[snapshot]
(try
(let [{:keys [config-errors] :as result} (import-from-snapshot! snapshot)]
(when (seq config-errors)
(log/warnf "[data-app] %d data_app.yaml config(s) skipped: %s"
(count config-errors) (str/join "; " config-errors)))
result)
(catch Throwable e
(log/warn e "[data-app] sync from remote-sync snapshot failed")
nil)))

View File

@@ -3,6 +3,7 @@
[clojure.string :as str] [clojure.string :as str]
[diehard.core :as dh] [diehard.core :as dh]
[java-time.api :as t] [java-time.api :as t]
[metabase-enterprise.data-apps.sync :as data-apps.sync]
[metabase-enterprise.remote-sync.guards :as guards] [metabase-enterprise.remote-sync.guards :as guards]
[metabase-enterprise.remote-sync.merge :as remote-sync.merge] [metabase-enterprise.remote-sync.merge :as remote-sync.merge]
[metabase-enterprise.remote-sync.models.remote-sync-object :as remote-sync.object] [metabase-enterprise.remote-sync.models.remote-sync-object :as remote-sync.object]
@@ -311,6 +312,19 @@
(and (some? pre-task-branch) (and (some? pre-task-branch)
(not= pre-task-branch (settings/remote-sync-branch)))) (not= pre-task-branch (settings/remote-sync-branch))))
(defn- materialize-data-apps!
"After a successful content import, materialize data apps from the same
snapshot. Data apps live under `data_apps/` in the repo (outside the serdes
paths), so they ride this import rather than having their own sync. Adapts the
snapshot to plain reader fns; `data-apps.sync/sync-from-snapshot!` never throws."
[^SourceSnapshot snapshot]
;; `read-file` returns file text (a string) or nil; data-apps.sync converts to
;; bytes on its side, keeping all Java interop out of this namespace.
(data-apps.sync/sync-from-snapshot!
{:read-file (fn [path] (source.p/read-file snapshot path))
:list-files (fn [] (source.p/list-files snapshot))
:sha (source.p/version snapshot)}))
(defn load-snapshot! (defn load-snapshot!
"Loads a snapshot's serialized entities into the app DB and reconciles local state to match it: "Loads a snapshot's serialized entities into the app DB and reconciles local state to match it:
runs `load-metabase!`, toggles the `remote-sync-transforms` setting based on the snapshot's contents, runs `load-metabase!`, toggles the `remote-sync-transforms` setting based on the snapshot's contents,
@@ -383,6 +397,19 @@
(transduce (map count) + 0 (concat (vals (:by-entity-id imported-data)) (transduce (map count) + 0 (concat (vals (:by-entity-id imported-data))
(vals (:by-path imported-data))))) (vals (:by-path imported-data)))))
(defn- fold-data-app-changes
"Fold the count of data apps changed by a pull into its `:outcome`. Data apps
live under `data_apps/` — outside serdes — and are materialized separately (see
[[materialize-data-apps!]]), so they're invisible to [[pulled-change-count]].
Without this, a pull whose only changes are data apps reports `pull-skipped` /
`count 0`. Caller guarantees `da-changed` is positive."
[outcome da-changed]
(case (:kind outcome)
"pull-skipped" {:kind "pulled" :count da-changed :branch (settings/remote-sync-branch)}
"pulled" (update outcome :count (fnil + 0) da-changed)
"merged" (update outcome :pulled (fnil + 0) da-changed)
outcome))
(defn- incremental-import-plan (defn- incremental-import-plan
"What an incremental load would touch, or [[incremental-not-possible]] if the change must fall back to a full "What an incremental load would touch, or [[incremental-not-possible]] if the change must fall back to a full
import. On success returns `{:ingestable <ingestable-or-nil> :deleted-rsos <RemoteSyncObject rows>}`. Falls back import. On success returns `{:ingestable <ingestable-or-nil> :deleted-rsos <RemoteSyncObject rows>}`. Falls back
@@ -566,90 +593,101 @@
(not force-deletion?) (not force-deletion?)
(into (:deletion-conflicts @conflicts))))) (into (:deletion-conflicts @conflicts)))))
incremental-plan (delay (incremental-import-plan snapshot last-imported-version)) incremental-plan (delay (incremental-import-plan snapshot last-imported-version))
dirty? (delay (remote-sync.object/dirty?))] dirty? (delay (remote-sync.object/dirty?))
(cond result
;; --- Merge mode: fold remote changes into local, keeping un-pushed local changes. --- (cond
merge? ;; --- Merge mode: fold remote changes into local, keeping un-pushed local changes. ---
(cond merge?
;; No safe merge base (no prior sync, or base orphaned by a force-push/rebase) — can't 3-way merge. (cond
(nil? base-snapshot) ;; No safe merge base (no prior sync, or base orphaned by a force-push/rebase) — can't 3-way merge.
{:status :conflict (nil? base-snapshot)
:version snapshot-version {:status :conflict
:conflicts ["Remote history was rewritten (force-push or rebase); cannot merge automatically."] :version snapshot-version
:message "Cannot merge: the remote branch history was rewritten. Discard local changes and pull, or push to a new branch."} :conflicts ["Remote history was rewritten (force-push or rebase); cannot merge automatically."]
:message "Cannot merge: the remote branch history was rewritten. Discard local changes and pull, or push to a new branch."}
;; Remote hasn't advanced past the merge base — nothing to fold in; keep local changes dirty. ;; Remote hasn't advanced past the merge base — nothing to fold in; keep local changes dirty.
(= (source.p/version base-snapshot) snapshot-version) (= (source.p/version base-snapshot) snapshot-version)
(do (do
(log/info "Pull merge: remote has not advanced; keeping local changes unchanged") (log/info "Pull merge: remote has not advanced; keeping local changes unchanged")
{:status :success {:status :success
:version snapshot-version :version snapshot-version
:merge-summary {:added 0 :updated 0 :removed 0} :merge-summary {:added 0 :updated 0 :removed 0}
:outcome {:kind "pull-skipped"}}) :outcome {:kind "pull-skipped"}})
:else :else
(import-merged! snapshot base-snapshot task-id sync-timestamp)) (import-merged! snapshot base-snapshot task-id sync-timestamp))
;; --- Forced reload: bypasses the no-op/incremental guards. Deletion conflicts (when ;; --- Forced reload: bypasses the no-op/incremental guards. Deletion conflicts (when
;; force-deletion? is false) still block; otherwise a full reload. --- ;; force-deletion? is false) still block; otherwise a full reload. ---
force? force?
(cond (cond
(seq @blocking-conflicts) (seq @blocking-conflicts)
(let [message (format "Skipping import: snapshot version %s contains conflicts use force to override" snapshot-version)] (let [message (format "Skipping import: snapshot version %s contains conflicts use force to override" snapshot-version)]
(log/info message) (log/info message)
{:status :conflict {:status :conflict
:version snapshot-version :version snapshot-version
:conflicts (into #{} (map :category) @blocking-conflicts) :conflicts (into #{} (map :category) @blocking-conflicts)
:conflict-details @blocking-conflicts :conflict-details @blocking-conflicts
:message message}) :message message})
:else :else
(let [_ (log/info "Remote sync full import: forced") (let [_ (log/info "Remote sync full import: forced")
imported-data (load-snapshot! snapshot task-id sync-timestamp :finalize! finalize!)] imported-data (load-snapshot! snapshot task-id sync-timestamp :finalize! finalize!)]
(log/info "Successfully reloaded entities from git repository") (log/info "Successfully reloaded entities from git repository")
{:status :success {:status :success
:version snapshot-version :version snapshot-version
:outcome {:kind "pulled" :outcome {:kind "pulled"
:count (pulled-change-count imported-data) :count (pulled-change-count imported-data)
:branch (settings/remote-sync-branch)}})) :branch (settings/remote-sync-branch)}}))
;; --- Normal pull --- ;; --- Normal pull ---
;; Cheap no-op pull: nothing changed remotely, so nothing is loaded or deleted. ;; Cheap no-op pull: nothing changed remotely, so nothing is loaded or deleted.
(= last-imported-version snapshot-version) (= last-imported-version snapshot-version)
(do (do
(log/infof "Skipping import: snapshot version %s matches last imported version" snapshot-version) (log/infof "Skipping import: snapshot version %s matches last imported version" snapshot-version)
{:status :success {:status :success
:version snapshot-version :version snapshot-version
:outcome {:kind "pull-skipped"}}) :outcome {:kind "pull-skipped"}})
;; Incremental fast-path: no local drift and the change is incrementally loadable. Tested before the ;; Incremental fast-path: no local drift and the change is incrementally loadable. Tested before the
;; conflict. It touches only changed files, so it can't wholesale-delete unsynced transforms. ;; conflict. It touches only changed files, so it can't wholesale-delete unsynced transforms.
(and (not @dirty?) (and (not @dirty?)
(not= :remote-sync/incremental-not-possible @incremental-plan)) (not= :remote-sync/incremental-not-possible @incremental-plan))
(incremental-load-snapshot! @incremental-plan snapshot-version task-id sync-timestamp :finalize! finalize!) (incremental-load-snapshot! @incremental-plan snapshot-version task-id sync-timestamp :finalize! finalize!)
(seq @blocking-conflicts) (seq @blocking-conflicts)
(let [message (format "Skipping import: snapshot version %s contains conflicts use force to override" snapshot-version)] (let [message (format "Skipping import: snapshot version %s contains conflicts use force to override" snapshot-version)]
(log/info message) (log/info message)
{:status :conflict {:status :conflict
:version snapshot-version :version snapshot-version
:conflicts (into #{} (map :category) @blocking-conflicts) ; Backward compat: set of category names :conflicts (into #{} (map :category) @blocking-conflicts) ; Backward compat: set of category names
:conflict-details @blocking-conflicts :conflict-details @blocking-conflicts
:message message}) :message message})
:else ;; fall back to full import :else ;; fall back to full import
(let [reason (cond (let [reason (cond
@dirty? "local changes pending" @dirty? "local changes pending"
first-import? "first import" first-import? "first import"
:else "changes not incrementally loadable") :else "changes not incrementally loadable")
_ (log/infof "Remote sync full import: %s" reason) _ (log/infof "Remote sync full import: %s" reason)
imported-data (load-snapshot! snapshot task-id sync-timestamp :finalize! finalize!)] imported-data (load-snapshot! snapshot task-id sync-timestamp :finalize! finalize!)]
(log/info "Successfully reloaded entities from git repository") (log/info "Successfully reloaded entities from git repository")
{:status :success {:status :success
:version snapshot-version :version snapshot-version
:outcome {:kind "pulled" :outcome {:kind "pulled"
:count (pulled-change-count imported-data) :count (pulled-change-count imported-data)
:branch (settings/remote-sync-branch)}}))) :branch (settings/remote-sync-branch)}}))]
;; Data apps ride the pull: re-materialize from the real source snapshot
;; (the repo file tree under `data_apps/`), not the synthetic merged
;; snapshot `load-snapshot!` sees. They're counted outside serdes, so fold
;; their change count into the outcome — otherwise a data-app-only pull
;; would report `pull-skipped` / `count 0`.
(if (= :success (:status result))
(let [da-changed (:changed (materialize-data-apps! snapshot) 0)]
(cond-> result
(pos? da-changed) (update :outcome fold-data-app-changes da-changed)))
result))
(catch Exception e (catch Exception e
;; A cancellation isn't a failure: log it and return nil. Otherwise log the error, count it, and ;; A cancellation isn't a failure: log it and return nil. Otherwise log the error, count it, and
;; return a user-friendly :error result. ;; return a user-friendly :error result.
@@ -658,7 +696,7 @@
(do (do
(log/errorf e "Failed to reload from git repository: %s" (ex-message e)) (log/errorf e "Failed to reload from git repository: %s" (ex-message e))
(analytics/inc! :metabase-remote-sync/imports-failed) (analytics/inc! :metabase-remote-sync/imports-failed)
{:status :error {:status :error
:message (source-error-message e) :message (source-error-message e)
:version (source.p/version snapshot) :version (source.p/version snapshot)
:details {:error-type (type e)}}))) :details {:error-type (type e)}})))

View File

@@ -80,6 +80,7 @@
"ConnectionImpersonation" "ConnectionImpersonation"
"ContentTranslation" "ContentTranslation"
"DashboardBookmark" "DashboardBookmark"
"DataApp"
"DataComplexityScore" "DataComplexityScore"
"DataPermissions" "DataPermissions"
"DatabaseRouter" "DatabaseRouter"

View File

@@ -25,6 +25,7 @@
:config-text-file :config-text-file
:content-translation :content-translation
:content-verification :content-verification
:data-apps
:data-complexity-score :data-complexity-score
:dashboard-subscription-filters :dashboard-subscription-filters
:disable-password-login :disable-password-login
@@ -79,6 +80,7 @@
:config_text_file true :config_text_file true
:content_translation true :content_translation true
:content_verification true :content_verification true
:data-apps true
:data-complexity-score true :data-complexity-score true
:dashboard_subscription_filters true :dashboard_subscription_filters true
:disable_password_login true :disable_password_login true

View File

@@ -0,0 +1,298 @@
(ns metabase-enterprise.data-apps.api-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.data-apps.sync :as data-app.sync]
[metabase.test :as mt]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
;;; ---------------------------------------------- Helpers ----------------------------------------------
(defn- create-app! []
(t2/insert! :model/DataApp
:name "demo"
:display_name "Demo"
:bundle_path "data_apps/demo/index.js"
:bundle (.getBytes "BUNDLE" "UTF-8")
:bundle_hash "abc123"))
(def ^:private fake-sha "0123456789abcdef0123456789abcdef01234567")
(defn- snapshot
"Build a snapshot (as the remote-sync import passes one) from a path->content
map. `read-file` returns file text (a string) or nil."
[path->content & {:keys [sha] :or {sha fake-sha}}]
{:sha sha
:list-files (fn [] (vec (keys path->content)))
:read-file (fn [p] (get path->content p))})
(defn- app-config
"Render a per-app data_app.yaml from `{:name :path :allowed_hosts}`. No slug: an
app's slug is the name of the directory the config sits in."
[{:keys [name path allowed_hosts]}]
(str (format "name: %s\npath: %s\n" name path)
(when (seq allowed_hosts)
(apply str "allowed_hosts:\n"
(map #(format " - %s\n" %) allowed_hosts)))))
(defn- app-files
"Repo files for one data app under `data_apps/<dir>/`: its data_app.yaml plus a
bundle at `path` with `bundle` content."
[dir {:keys [path bundle] :as cfg}]
{(format "data_apps/%s/data_app.yaml" dir) (app-config cfg)
(format "data_apps/%s/%s" dir path) bundle})
;;; ---------------------------------------------- Permissions ----------------------------------------------
(deftest non-superuser-can-view-and-list-but-not-manage-test
;; global mode so the `:data-apps` premium feature is visible to the real-HTTP
;; `user-real-request` calls below (which run on Jetty threads that don't inherit
;; a thread-local `binding`).
(mt/test-helpers-set-global-values!
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(create-app!)
(testing "a non-superuser can view (open) a data app"
(is (= [{:name "demo" :display_name "Demo"}]
(mt/user-http-request :rasta :get 200 "apps")))
(is (= {:name "demo" :display_name "Demo"}
(mt/user-http-request :rasta :get 200 "apps/demo")))
(is (str/includes?
(str (mt/user-real-request :rasta :get 200 "apps/demo/bundle"))
"BUNDLE")))
(testing "but is still forbidden from managing data apps"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :get 403 "apps/repo-status")))
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :put 403 "apps/demo" {:enabled false}))))))))
(deftest superuser-can-manage-and-view-test
(mt/test-helpers-set-global-values!
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(create-app!)
(testing "a superuser can list, read metadata, and serve the bundle"
(is (=? [{:name "demo" :display_name "Demo"}]
(mt/user-http-request :crowberto :get 200 "apps")))
(is (=? {:name "demo"}
(mt/user-http-request :crowberto :get 200 "apps/demo")))
(is (str/includes?
(str (mt/user-real-request :crowberto :get 200 "apps/demo/bundle"))
"BUNDLE")))))))
(deftest list-available-apps-test
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(t2/insert! :model/DataApp :name "ready" :display_name "Ready" :bundle_path "data_apps/ready/index.js")
(t2/insert! :model/DataApp :name "disabled" :display_name "Disabled" :bundle_path "data_apps/disabled/index.js"
:enabled false)
(t2/insert! :model/DataApp :name "failed" :display_name "Failed" :bundle_path "data_apps/failed/index.js"
:sync_error "Could not read bundle")
(is (=? [{:name "ready" :display_name "Ready"}]
(mt/user-http-request :rasta :get 200 "apps?available=true"))))))
(deftest bundle-includes-allowed-hosts-header-test
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(t2/insert! :model/DataApp
:name "demo"
:display_name "Demo"
:bundle_path "data_apps/demo/index.js"
:bundle (.getBytes "BUNDLE" "UTF-8")
:bundle_hash "abc123"
:allowed_hosts ["https://api.example.com"])
(testing "the bundle response carries the app's allowed_hosts as a JSON header"
(let [resp (mt/user-http-request-full-response :crowberto :get 200 "apps/demo/bundle")]
(is (= "[\"https://api.example.com\"]"
(get-in resp [:headers "X-Metabase-Data-App-Allowed-Hosts"])))))
(testing "an app with no allowed_hosts still sends the header as an empty JSON array"
(t2/update! :model/DataApp :name "demo" {:allowed_hosts []})
(let [resp (mt/user-http-request-full-response :crowberto :get 200 "apps/demo/bundle")]
(is (= "[]"
(get-in resp [:headers "X-Metabase-Data-App-Allowed-Hosts"]))))))))
(deftest list-includes-allowed-hosts-test
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(t2/insert! :model/DataApp
:name "withhosts" :display_name "With"
:bundle_path "data_apps/withhosts/index.js"
:allowed_hosts ["https://api.example.com"])
;; inserted without the column → stored NULL, exercising the read coercion
(t2/insert! :model/DataApp
:name "nohosts" :display_name "No"
:bundle_path "data_apps/nohosts/index.js")
(testing "the list endpoint returns allowed_hosts, always a list (NULL → [])"
(let [by-name (->> (mt/user-http-request :crowberto :get 200 "apps")
(into {} (map (juxt :name identity))))]
(is (= ["https://api.example.com"]
(get-in by-name ["withhosts" :allowed_hosts])))
(is (= [] (get-in by-name ["nohosts" :allowed_hosts]))))))))
;;; ----------------------------------------------------- Sync -----------------------------------------------------
(deftest import-materializes-apps-test
(mt/with-model-cleanup [:model/DataApp]
(let [result (data-app.sync/import-from-snapshot!
(snapshot (merge (app-files "sales" {:name "Sales" :path "dist/index.js" :bundle "SALES-BUNDLE"})
(app-files "ops" {:name "Ops" :path "dist/app.js" :bundle "OPS-BUNDLE"}))))]
(is (=? {:synced 2} result))
(is (= #{"sales" "ops"} (t2/select-fn-set :name :model/DataApp)))
(let [sales (t2/select-one :model/DataApp :name "sales")]
(is (= "SALES-BUNDLE" (String. ^bytes (:bundle sales) "UTF-8")))
(is (= "Sales" (:display_name sales)))
(is (= "data_apps/sales/dist/index.js" (:bundle_path sales)))
(is (true? (:enabled sales)))
(is (= fake-sha (:last_synced_sha sales)))
(is (nil? (:sync_error sales)))))))
(deftest import-stores-allowed-hosts-test
(mt/with-model-cleanup [:model/DataApp]
(testing "allowed_hosts from data_app.yaml are persisted on the row"
(data-app.sync/import-from-snapshot!
(snapshot (app-files "sales" {:name "Sales" :path "dist/index.js" :bundle "B"
:allowed_hosts ["https://api.example.com" "https://*.acme.com"]})))
(is (= ["https://api.example.com" "https://*.acme.com"]
(:allowed_hosts (t2/select-one :model/DataApp :name "sales")))))
(testing "re-syncing without allowed_hosts clears them to an empty list"
(data-app.sync/import-from-snapshot!
(snapshot (app-files "sales" {:name "Sales" :path "dist/index.js" :bundle "B"})))
(is (= [] (:allowed_hosts (t2/select-one :model/DataApp :name "sales")))))))
(deftest import-keeps-apps-absent-from-snapshot-test
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (merge (app-files "keep" {:name "Keep" :path "index.js" :bundle "KEEP"})
(app-files "gone" {:name "Gone" :path "index.js" :bundle "GONE"}))))
(is (= #{"keep" "gone"} (t2/select-fn-set :name :model/DataApp)))
;; A sync never deletes: an app missing from a later snapshot is kept, not
;; pruned. Removal is an explicit admin action (DELETE /api/apps/:slug).
(data-app.sync/import-from-snapshot!
(snapshot (app-files "keep" {:name "Keep" :path "index.js" :bundle "KEEP"})))
(is (= #{"keep" "gone"} (t2/select-fn-set :name :model/DataApp))
"the app absent from the later snapshot is kept")))
(deftest delete-endpoint-test
(mt/test-helpers-set-global-values!
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(create-app!)
(testing "a non-superuser cannot remove an app"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :delete 403 "apps/demo")))
(is (t2/exists? :model/DataApp :name "demo")))
(testing "a superuser removes the app"
(is (nil? (mt/user-http-request :crowberto :delete 204 "apps/demo")))
(is (not (t2/exists? :model/DataApp :name "demo"))))
(testing "removing a non-existent app 404s"
(mt/user-http-request :crowberto :delete 404 "apps/missing"))))))
(deftest import-preserves-enabled-across-syncs-test
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (app-files "a" {:name "A" :path "index.js" :bundle "V1"})))
(t2/update! :model/DataApp :name "a" {:enabled false})
;; a new bundle must not flip the admin's enabled toggle back on
(data-app.sync/import-from-snapshot!
(snapshot (app-files "a" {:name "A" :path "index.js" :bundle "V2"})))
(let [a (t2/select-one :model/DataApp :name "a")]
(is (false? (:enabled a)) "the disabled toggle is preserved")
(is (= "V2" (String. ^bytes (:bundle a) "UTF-8")) "the bundle is still updated"))))
(deftest import-per-app-error-test
(testing "a missing bundle file fails just that app, not the whole import"
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (merge (app-files "good" {:name "Good" :path "index.js" :bundle "GOOD"})
;; "bad" declares a path that doesn't exist
{"data_apps/bad/data_app.yaml" (app-config {:name "Bad" :path "missing.js"})})))
(is (= #{"good" "bad"} (t2/select-fn-set :name :model/DataApp)))
(let [good (t2/select-one :model/DataApp :name "good")
bad (t2/select-one :model/DataApp :name "bad")]
(is (= "GOOD" (String. ^bytes (:bundle good) "UTF-8")))
(is (nil? (:sync_error good)))
(is (nil? (:bundle bad)))
(is (str/includes? (:sync_error bad) "missing.js"))))))
(deftest import-serves-each-app-from-its-directory-test
(testing "the directory an app lives in is the slug it's served at — two apps can't collide on one"
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (merge (app-files "one" {:name "One" :path "a.js" :bundle "A"})
(app-files "two" {:name "Two" :path "b.js" :bundle "B"}))))
(is (= #{"one" "two"} (t2/select-fn-set :name :model/DataApp))))))
(deftest import-isolates-bad-config-test
(testing "a malformed data_app.yaml is isolated: other apps still sync, the bad one is reported, nothing is pruned"
(mt/with-model-cleanup [:model/DataApp]
;; pre-existing app that must survive a sync where another config is broken
(data-app.sync/import-from-snapshot!
(snapshot (app-files "existing" {:name "Existing" :path "i.js" :bundle "E"})))
(let [result (data-app.sync/import-from-snapshot!
(snapshot (merge (app-files "good" {:name "Good" :path "i.js" :bundle "GOOD"})
{"data_apps/bad/data_app.yaml" "name: [unterminated"})))]
(is (=? {:synced 1} result))
(is (= 1 (count (:config-errors result))))
(is (= #{"existing" "good"} (t2/select-fn-set :name :model/DataApp))
"the good app is added and the pre-existing app is NOT pruned despite the bad config")))))
(deftest sync-from-snapshot!-never-throws-test
(testing "a malformed data_app.yaml is isolated into :config-errors; the app just doesn't appear, the sync doesn't throw"
(mt/with-model-cleanup [:model/DataApp]
(let [result (data-app.sync/sync-from-snapshot!
(snapshot {"data_apps/x/data_app.yaml" "name: [unterminated"}))]
(is (seq (:config-errors result)))
(is (empty? (t2/select-fn-set :name :model/DataApp))))))
(testing "a clean sync materializes the app with no config errors"
(mt/with-model-cleanup [:model/DataApp]
(let [result (data-app.sync/sync-from-snapshot!
(snapshot (app-files "a" {:name "A" :path "index.js" :bundle "A"})))]
(is (empty? (:config-errors result)))
(is (= #{"a"} (t2/select-fn-set :name :model/DataApp)))))))
;;; ----------------------------------------------------- API -----------------------------------------------------
(deftest list-and-bundle-endpoints-test
(mt/test-helpers-set-global-values!
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (app-files "demo" {:name "Demo app" :path "dist/index.js" :bundle "DEMOBUNDLE"})))
(testing "GET / lists the synced apps"
(is (=? [{:name "demo" :display_name "Demo app"
:bundle_path "data_apps/demo/dist/index.js" :enabled true}]
(mt/user-http-request :crowberto :get 200 "apps"))))
(testing "GET /:slug/bundle serves the cached bytes"
(is (str/includes?
(str (mt/user-real-request :crowberto :get 200 "apps/demo/bundle"))
"DEMOBUNDLE")))))))
(deftest repo-status-endpoint-test
(mt/with-premium-features #{:data-apps}
(testing "reports no repository when none is connected"
(mt/with-dynamic-fn-redefs [data-app.sync/repo-url (constantly nil)]
(is (=? {:configured false :url nil}
(mt/user-http-request :crowberto :get 200 "apps/repo-status")))))
(testing "reports the connected repository URL"
(mt/with-dynamic-fn-redefs [data-app.sync/repo-url (constantly "https://github.com/metabase/stats-remote-sync")]
(is (=? {:configured true :url "https://github.com/metabase/stats-remote-sync"}
(mt/user-http-request :crowberto :get 200 "apps/repo-status")))))))
(deftest enable-disable-endpoint-test
(mt/test-helpers-set-global-values!
(mt/with-premium-features #{:data-apps}
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (app-files "demo" {:name "Demo" :path "index.js" :bundle "BUNDLE"})))
(testing "PUT /:slug can disable an app"
(is (=? {:name "demo" :enabled false}
(mt/user-http-request :crowberto :put 200 "apps/demo" {:enabled false}))))
(testing "a disabled app is not served"
(is (= "Not found." (mt/user-http-request :crowberto :get 404 "apps/demo")))
(mt/user-real-request :crowberto :get 404 "apps/demo/bundle"))
(testing "re-enabling restores serving"
(is (=? {:enabled true}
(mt/user-http-request :crowberto :put 200 "apps/demo" {:enabled true})))
(is (=? {:name "demo"} (mt/user-http-request :crowberto :get 200 "apps/demo"))))))))

View File

@@ -0,0 +1,80 @@
(ns metabase-enterprise.data-apps.config-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-apps.config :as data-app.config]))
(set! *warn-on-reflection* true)
(defn- ->bytes ^bytes [^String s]
(.getBytes s "UTF-8"))
(defn- parse
"Parse a `data_app.yaml` as if it sat in `dir` (default `data_apps/sales`, so the
parsed slug is `sales` — the slug is the directory's name, never a config key)."
([s] (parse s "data_apps/sales"))
([s dir] (data-app.config/parse-app-config (->bytes s) dir)))
(deftest parse-valid-config-test
(is (= {:slug "sales" :display_name "Sales dashboard" :path "dist/index.js" :allowed_hosts []}
(parse "name: Sales dashboard
path: ./dist/index.js"))))
(deftest slug-comes-from-the-directory-test
(testing "the app's slug is the name of the directory it lives in"
(is (= "inventory" (:slug (parse "name: X\npath: dist/index.js" "data_apps/inventory")))))
(testing "a directory name that isn't a usable slug is rejected — the app would have no URL to be served at"
(doseq [bad ["Sales" "my_app" "sales app" "-sales" "sales-"]]
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"lowercase letters, numbers, and dashes"
(parse "name: X\npath: dist/index.js" (str "data_apps/" bad)))
(str "should reject the directory: " bad)))))
(deftest parse-allowed-hosts-test
(testing "valid entries are lowercased, trailing-slash-stripped, and de-duplicated"
(is (= ["https://api.example.com" "https://*.internal.acme.com"]
(:allowed_hosts
(parse "name: X\npath: dist/index.js\nallowed_hosts:\n - https://API.example.com/\n - https://*.internal.acme.com\n - https://api.example.com")))))
(testing "absent → empty vector"
(is (= [] (:allowed_hosts (parse "name: X\npath: dist/index.js")))))
(testing "a non-list value is rejected"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"allowed_hosts.*must be a list"
(parse "name: X\npath: dist/index.js\nallowed_hosts: https://api.example.com"))))
(testing "invalid entries (bare *, path, non-http scheme, no scheme) are rejected"
(doseq [bad ["*" "https://example.com/path" "ftp://example.com" "example.com"]]
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"not a valid allowed_hosts entry"
(parse (str "name: X\npath: dist/index.js\nallowed_hosts:\n - \"" bad "\"")))
(str "should reject: " bad)))))
(deftest parse-strips-leading-dot-slash-test
(is (= "dist/app.js"
(:path (parse "name: X\npath: ./dist/app.js")))))
(deftest unknown-fields-are-ignored-test
(testing "unknown keys don't fail the parse — including a stray `slug`, which the directory name overrides"
(is (= {:slug "sales" :display_name "X" :path "dist/index.js" :allowed_hosts []}
(parse "name: X\nslug: elsewhere\nfuture_option: 1\npath: dist/index.js")))))
(deftest parse-errors-test
(testing "missing name"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"name"
(parse "path: dist/index.js"))))
(testing "missing path"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"path"
(parse "name: X"))))
(testing "malformed yaml"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Could not parse"
(parse "name: [unterminated"))))
(testing "path traversal is rejected"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"must not contain"
(parse "name: X\npath: ../other/dist/index.js")))
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"must not contain"
(parse "name: X\npath: dist/../../escape.js"))))
(testing "a directory whose name collides with an API sub-route is rejected"
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"reserved slug"
(parse "name: X\npath: dist/index.js" "data_apps/repo-status")))))
(deftest parse-errors-carry-400-test
(try
(parse "name: X")
(is false "should have thrown")
(catch clojure.lang.ExceptionInfo e
(is (= 400 (:status-code (ex-data e)))))))

View File

@@ -0,0 +1,64 @@
(ns metabase-enterprise.data-apps.csp-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase.config.core :as config]
[metabase.server.middleware.security :as mw.security]
[metabase.test :as mt]))
(set! *warn-on-reflection* true)
(defn- connect-src-for!
"The `connect-src` directive of the CSP that the security middleware emits for
a request to `uri`."
[uri]
(with-redefs [config/is-dev? false]
(let [handler (mw.security/add-security-headers
(fn [_req respond _raise] (respond {:status 200 :headers {} :body "ok"})))
response (handler {:uri uri :headers {}} identity identity)
csp (get-in response [:headers "Content-Security-Policy"])]
(->> (str/split csp #";\s*")
(filter #(str/starts-with? % "connect-src "))
first))))
(deftest data-app-connect-src-allowed-hosts-test
(mt/with-premium-features #{:data-apps}
(mt/with-temp [:model/DataApp _ {:name "boba"
:display_name "Boba"
:bundle_path "data_apps/boba/dist/index.js"
:enabled true
:allowed_hosts ["https://api.example.com" "https://*.acme.com"]}]
(testing "the data-app iframe document's connect-src includes the app's allowed_hosts (and keeps 'self')"
(let [cs (connect-src-for! "/embed/apps/boba")]
(is (str/includes? cs "https://api.example.com"))
(is (str/includes? cs "https://*.acme.com"))
(is (str/includes? cs "'self'"))))
(testing "a deeper sub-route of the same app gets them too"
(is (str/includes? (connect-src-for! "/embed/apps/boba/sub/route")
"https://api.example.com")))
(testing "the main app's connect-src does NOT include them"
(is (not (str/includes? (connect-src-for! "/") "https://api.example.com"))))
(testing "an unknown app contributes no hosts"
(is (not (str/includes? (connect-src-for! "/embed/apps/nope")
"api.example.com")))))))
(deftest data-app-connect-src-hosts-fn-test
(mt/with-temp [:model/DataApp _ {:name "boba"
:display_name "Boba"
:bundle_path "x"
:enabled true
:allowed_hosts ["https://api.example.com"]}]
(testing "with the :data-apps feature the EE impl returns the app's allowed_hosts"
(mt/with-premium-features #{:data-apps}
(is (= ["https://api.example.com"] (mw.security/data-app-connect-src-hosts "boba")))))
(testing "without the feature it falls back to []"
(mt/with-premium-features #{}
(is (= [] (mw.security/data-app-connect-src-hosts "boba")))))
(testing "a disabled app contributes nothing"
(mt/with-premium-features #{:data-apps}
(mt/with-temp [:model/DataApp _ {:name "off"
:display_name "Off"
:bundle_path "x"
:enabled false
:allowed_hosts ["https://nope.example.com"]}]
(is (= [] (mw.security/data-app-connect-src-hosts "off"))))))))

View File

@@ -0,0 +1,69 @@
(ns metabase-enterprise.data-apps.models.data-app-test
"Unit coverage for the DataApp model: the blob-excluding reads, JSON that never
leaks the bundle bytes, the `allowed_hosts` NULL→[] read coercion, and the
superuser-only permission gating."
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-apps.models.data-app :as data-app]
[metabase.api.common :as api]
[metabase.models.interface :as mi]
[metabase.test :as mt]
[metabase.util.json :as json]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- insert-app! [& {:as extra}]
(t2/insert-returning-pk!
:model/DataApp
(merge {:name "m"
:display_name "M"
:bundle_path "data_apps/m/index.js"
:bundle (.getBytes "BUNDLEBYTES" "UTF-8")
:bundle_hash "hash"}
extra)))
(deftest to-json-never-includes-the-bundle-bytes-test
(mt/with-model-cleanup [:model/DataApp]
(insert-app!)
(let [app (t2/select-one :model/DataApp :name "m")
decoded (json/decode (json/encode app))]
(is (contains? app :bundle) "the selected instance still carries the raw bundle")
(is (not (contains? decoded "bundle"))
"but the JSON representation omits it")
(is (= "M" (get decoded "display_name"))))))
(deftest non-blob-selects-exclude-the-bundle-test
(mt/with-model-cleanup [:model/DataApp]
(insert-app! :allowed_hosts ["https://api.example.com"])
(testing "select-one-non-blob returns metadata without the bundle blob"
(let [app (data-app/select-one-non-blob :name "m")]
(is (not (contains? app :bundle)))
(is (= "M" (:display_name app)))
(is (= ["https://api.example.com"] (:allowed_hosts app)))))
(testing "select-non-blob returns a seq of blob-free rows"
(let [apps (data-app/select-non-blob :name "m")]
(is (= 1 (count apps)))
(is (not (contains? (first apps) :bundle)))))))
(deftest allowed-hosts-reads-as-a-vector-never-nil-test
(mt/with-model-cleanup [:model/DataApp]
(testing "a row stored with NULL allowed_hosts reads back as []"
(insert-app!)
(is (= [] (:allowed_hosts (t2/select-one :model/DataApp :name "m")))))
(testing "a stored list round-trips through the JSON transform"
(insert-app! :name "n" :allowed_hosts ["https://a.com" "https://b.com"])
(is (= ["https://a.com" "https://b.com"]
(:allowed_hosts (t2/select-one :model/DataApp :name "n")))))))
(deftest permissions-test
(testing "a superuser can read, write, and create"
(binding [api/*is-superuser?* true]
(is (mi/can-read? :model/DataApp 1))
(is (mi/can-write? :model/DataApp 1))
(is (mi/can-create? :model/DataApp {}))))
(testing "any signed-in user can read (view), but write/create stay superuser-only"
(binding [api/*is-superuser?* false]
(is (mi/can-read? :model/DataApp 1))
(is (not (mi/can-write? :model/DataApp 1)))
(is (not (mi/can-create? :model/DataApp {}))))))

View File

@@ -0,0 +1,117 @@
(ns metabase-enterprise.data-apps.sync-test
"Unit coverage for the materialization edge cases not exercised through the API
suite: the `:changed` accounting (content vs. sha/timestamp bumps) and the
oversized-bundle guard (rejected with a sync_error, previously cached bundle
retained)."
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.data-apps.sync :as data-app.sync]
[metabase.test :as mt]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(def ^:private fake-sha "0123456789abcdef0123456789abcdef01234567")
(defn- snapshot
[path->content & {:keys [sha] :or {sha fake-sha}}]
{:sha sha
:list-files (fn [] (vec (keys path->content)))
:read-file (fn [p] (get path->content p))})
(defn- app-files
"The repo files for one app in `data_apps/<dir>`. `dir` is the app's slug — the
config declares no slug, it is the directory's name."
[dir {:keys [name path bundle]}]
{(format "data_apps/%s/data_app.yaml" dir)
(format "name: %s\npath: %s\n" name path)
(format "data_apps/%s/%s" dir path) bundle})
(deftest changed-count-tracks-content-not-sha-bumps-test
(mt/with-model-cleanup [:model/DataApp]
(let [files (app-files "a" {:name "A" :path "index.js" :bundle "V1"})]
(testing "the first sync counts the new app"
(is (=? {:synced 1 :changed 1}
(data-app.sync/import-from-snapshot! (snapshot files)))))
(testing "re-syncing identical content at a new sha is not a change"
(is (=? {:synced 1 :changed 0}
(data-app.sync/import-from-snapshot!
(snapshot files :sha "ffffffffffffffffffffffffffffffffffffffff")))))
(testing "a metadata-only change counts"
(is (=? {:changed 1}
(data-app.sync/import-from-snapshot!
(snapshot (app-files "a" {:name "A renamed" :path "index.js" :bundle "V1"}))))))
(testing "a bundle content change counts"
(is (=? {:changed 1}
(data-app.sync/import-from-snapshot!
(snapshot (app-files "a" {:name "A renamed" :path "index.js" :bundle "V2"})))))))))
(deftest sync-across-repos-keeps-overrides-and-adds-test
(testing "linking repo A, unlinking, then linking repo B: keep A-only apps, override shared slugs with B, add B-only apps"
(mt/with-model-cleanup [:model/DataApp]
;; Repo A: Foo + Bar
(data-app.sync/import-from-snapshot!
(snapshot (merge (app-files "foo" {:name "Foo" :path "index.js" :bundle "FOO"})
(app-files "bar" {:name "Bar A" :path "index.js" :bundle "BAR-A"}))))
(is (= #{"foo" "bar"} (t2/select-fn-set :name :model/DataApp)))
;; Unlinking A doesn't sync (nothing prunes). Linking repo B (Bar + Baz)
;; and importing it keeps Foo, overrides Bar by slug, and adds Baz.
(data-app.sync/import-from-snapshot!
(snapshot (merge (app-files "bar" {:name "Bar B" :path "index.js" :bundle "BAR-B"})
(app-files "baz" {:name "Baz" :path "index.js" :bundle "BAZ"}))))
(is (= #{"foo" "bar" "baz"} (t2/select-fn-set :name :model/DataApp))
"Foo (from repo A) is kept and Baz (from repo B) is added")
(is (= "Foo" (:display_name (t2/select-one :model/DataApp :name "foo")))
"Foo is untouched by repo B")
(let [bar (t2/select-one :model/DataApp :name "bar")]
(is (= "Bar B" (:display_name bar))
"Bar is overridden in place by repo B (slug conflict)")
(is (= "BAR-B" (String. ^bytes (:bundle bar) "UTF-8"))
"Bar's cached bundle is repo B's")))))
(defn- oversized-bundle ^String []
(.repeat "a" (int (inc data-app.sync/max-bundle-bytes))))
(deftest oversized-bundle-is-rejected-test
(testing "a bundle over the size cap is rejected with a sync_error, no bundle cached"
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (app-files "big" {:name "Big" :path "index.js"
:bundle (oversized-bundle)})))
(let [app (t2/select-one :model/DataApp :name "big")]
(is (some? app) "the app still appears in the list")
(is (nil? (:bundle app)) "no oversized bundle was cached")
(is (str/includes? (:sync_error app) "MiB"))))))
(deftest oversized-resync-keeps-the-previous-bundle-test
(testing "an oversized re-sync sets sync_error but keeps the last good bundle"
(mt/with-model-cleanup [:model/DataApp]
(data-app.sync/import-from-snapshot!
(snapshot (app-files "app" {:name "App" :path "index.js" :bundle "GOOD"})))
(data-app.sync/import-from-snapshot!
(snapshot (app-files "app" {:name "App" :path "index.js"
:bundle (oversized-bundle)})))
(let [app (t2/select-one :model/DataApp :name "app")]
(is (= "GOOD" (String. ^bytes (:bundle app) "UTF-8"))
"the previously cached bundle is retained")
(is (str/includes? (:sync_error app) "MiB"))))))
(deftest a-directory-without-a-config-is-not-an-app-test
(testing "a data_apps/<dir> that ships a bundle but no data_app.yaml is not discovered — no app, no error"
(mt/with-model-cleanup [:model/DataApp]
(let [result (data-app.sync/import-from-snapshot!
(snapshot {"data_apps/orphan/index.js" "BUNDLE"}))]
(is (=? {:synced 0 :changed 0 :config-errors []} result))
(is (empty? (t2/select-fn-set :name :model/DataApp)))))))
(deftest an-unreadable-config-is-a-config-error-test
(testing "a data_app.yaml the snapshot lists but can't read is isolated as a config-error, not a crash"
(mt/with-model-cleanup [:model/DataApp]
(let [result (data-app.sync/import-from-snapshot!
{:sha fake-sha
:list-files (fn [] ["data_apps/ghost/data_app.yaml"])
:read-file (fn [_] nil)})]
(is (= 1 (count (:config-errors result))))
(is (str/includes? (first (:config-errors result)) "data_apps/ghost/data_app.yaml"))
(is (empty? (t2/select-fn-set :name :model/DataApp)))))))

View File

@@ -0,0 +1,89 @@
(ns metabase-enterprise.remote-sync.data-app-pull-test
"A remote-sync pull's outcome (the \"N changes\" summary the admin sees) must
count data-app changes. Data apps live under `data_apps/` — outside serdes — and
are materialized separately (`metabase-enterprise.data-apps.sync`), so without
`impl/fold-data-app-changes` a pull whose only changes are data apps reports
`pull-skipped` / `count 0`. These cover the three pull shapes: data-app-only,
mixed (serdes + data apps), and serdes-only."
(:require
[clojure.test :refer :all]
[metabase-enterprise.remote-sync.impl :as impl]
[metabase-enterprise.remote-sync.source.protocol :as source.p]
[metabase-enterprise.remote-sync.test-helpers :as rs.test]
[metabase.search.test-util :as search.tu]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(use-fixtures :once (fixtures/initialize :db))
(use-fixtures :each rs.test/clean-remote-sync-state rs.test/commit-with-temp)
(defn- import-at!
"Run `import!` against the source's snapshot at `version`, complete the task (so
`last-version` advances for the next pull), and return the result."
[src version & {:keys [force?] :or {force? false}}]
(let [task (t2/insert-returning-pk! :model/RemoteSyncTask
{:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
result (impl/import! (source.p/snapshot-at src version) task :force? force?)]
(impl/handle-task-result! result task)
result))
(defn- app-tree
"Repo files for one data app: its `data_app.yaml` + a bundle at `dist/index.js`."
[slug bundle]
{(str "data_apps/" slug "/data_app.yaml") (str "name: " slug "\npath: dist/index.js\n")
(str "data_apps/" slug "/dist/index.js") bundle})
;; One self-contained serdes entity (a bare collection — no DB deps), used as the
;; "data" change. Reuses the path/content shape the mock harness imports cleanly.
(def ^:private coll-path
"collections/M-Q4pcV0qkiyJ0kiSWECl_some_collection/M-Q4pcV0qkiyJ0kiSWECl_some_collection.yaml")
(def ^:private coll-file
{coll-path (rs.test/generate-collection-yaml "M-Q4pcV0qkiyJ0kiSWECl" "Some Collection")})
(defn- pull-outcome!
"Establish `v0` as the baseline, then pull `v1` (a normal, non-forced pull) and
return its `:outcome`."
[v0 v1]
(let [src (rs.test/versioned-source :trees {"v0" v0 "v1" v1} :current "v0")]
(is (= :success (:status (import-at! src "v0" :force? true))) "baseline import succeeds")
(let [result (import-at! src "v1")]
(is (= :success (:status result)) "pull succeeds")
(:outcome result))))
(deftest data-app-only-pull-counts-app-changes-test
(testing "a pull whose only change is a data app is reported as a real pull, not pull-skipped / 0 changes"
(search.tu/with-index-disabled
(mt/with-premium-features #{:data-apps}
(mt/with-temporary-setting-values [remote-sync-type :read-write remote-sync-transforms false]
(mt/with-model-cleanup [:model/DataApp]
(let [outcome (pull-outcome! (app-tree "sales" "BUNDLE-V1")
(app-tree "sales" "BUNDLE-V2"))]
(is (= "pulled" (:kind outcome)) "not reported as skipped")
(is (= 1 (:count outcome)) "the one changed data app is counted"))))))))
(deftest data-only-pull-excludes-unchanged-apps-test
(testing "a serdes-only pull counts serdes content; unchanged data apps add nothing"
(search.tu/with-index-disabled
(mt/with-premium-features #{:data-apps}
(mt/with-temporary-setting-values [remote-sync-type :read-write remote-sync-transforms false]
(mt/with-model-cleanup [:model/DataApp :model/Collection]
;; v1 adds a collection; the data app is byte-for-byte the same as v0.
(let [app (app-tree "sales" "BUNDLE")
outcome (pull-outcome! app (merge coll-file app))]
(is (= "pulled" (:kind outcome)))
(is (= 1 (:count outcome)) "the collection counts; the unchanged app adds 0"))))))))
(deftest mixed-pull-counts-serdes-and-apps-test
(testing "a mixed pull counts serdes content AND the changed data apps"
(search.tu/with-index-disabled
(mt/with-premium-features #{:data-apps}
(mt/with-temporary-setting-values [remote-sync-type :read-write remote-sync-transforms false]
(mt/with-model-cleanup [:model/DataApp :model/Collection]
;; v1 adds a collection AND changes the data app's bundle.
(let [outcome (pull-outcome! (app-tree "ops" "BUNDLE")
(merge coll-file (app-tree "ops" "BUNDLE-V2")))]
(is (= "pulled" (:kind outcome)))
(is (= 2 (:count outcome)) "the collection (1) plus the changed data app (1)"))))))))

View File

@@ -0,0 +1,60 @@
(ns metabase-enterprise.remote-sync.data-apps-test
"Integration coverage for the data-apps hook in the remote-sync import pipeline:
importing a repo materializes data apps from its `data_apps/` directory."
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-apps.models.data-app]
[metabase-enterprise.remote-sync.impl :as impl]
[metabase-enterprise.remote-sync.source.protocol :as source.p]
[metabase-enterprise.remote-sync.test-helpers :as test-helpers]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(use-fixtures :each (fn [f] (test-helpers/clean-remote-sync-state f)))
(comment metabase-enterprise.data-apps.models.data-app/keep-me)
(defn- import! [files]
(let [source (test-helpers/create-mock-source :initial-files files)
task-id (t2/insert-returning-pk! :model/RemoteSyncTask
{:sync_task_type "import" :initiated_by (mt/user->id :rasta)})
;; force: the mock source reports a constant version, so without this the
;; second import in a test would hit the "version unchanged" skip branch
result (impl/import! (source.p/snapshot source) task-id :force? true)]
;; calling import! directly leaves the task "running"; mark it ended so a
;; subsequent import in the same test isn't blocked by the running-task guard
(t2/update! :model/RemoteSyncTask task-id {:ended_at :%now})
result))
(deftest import-materializes-data-apps-test
(testing "a remote-sync import materializes data apps from data_apps/ alongside serdes content"
(mt/with-model-cleanup [:model/DataApp]
(let [files {"main" {"collections/c/c.yaml"
(test-helpers/generate-collection-yaml "data-apps-test-collx" "DA Coll")
"data_apps/sales/data_app.yaml" "name: Sales\npath: ./dist/index.js\n"
"data_apps/sales/dist/index.js" "SALESBUNDLE"}}
result (import! files)]
(is (= :success (:status result)))
(let [app (t2/select-one :model/DataApp :name "sales")]
(is (some? app) "the data app from the repo was materialized by the import")
(is (= "Sales" (:display_name app)))
(is (= "data_apps/sales/dist/index.js" (:bundle_path app)))
(is (true? (:enabled app)))
(is (= "SALESBUNDLE" (String. ^bytes (:bundle app) "UTF-8"))))))))
(deftest import-keeps-data-apps-absent-from-repo-test
(testing "an import whose repo no longer has an app dir keeps that app (a sync never deletes)"
(mt/with-model-cleanup [:model/DataApp]
(import! {"main" {"data_apps/gone/data_app.yaml" "name: Gone\npath: ./i.js\n"
"data_apps/gone/i.js" "X"
"data_apps/kept/data_app.yaml" "name: Kept\npath: ./i.js\n"
"data_apps/kept/i.js" "K"}})
(is (= #{"gone" "kept"} (t2/select-fn-set :name :model/DataApp)))
(import! {"main" {"data_apps/kept/data_app.yaml" "name: Kept\npath: ./i.js\n"
"data_apps/kept/i.js" "K"}})
(is (some? (t2/select-one :model/DataApp :name "gone"))
"the app absent from the later import is kept, not pruned")
(is (some? (t2/select-one :model/DataApp :name "kept"))
"the still-present app is kept"))))

View File

@@ -54,6 +54,7 @@
:model/CollectionBookmark :model/CollectionBookmark
:model/ContentTranslation :model/ContentTranslation
:model/DashboardBookmark :model/DashboardBookmark
:model/DataApp
:model/DataComplexityScore :model/DataComplexityScore
:model/DataPermissions :model/DataPermissions
:model/DatabaseRouter :model/DatabaseRouter

View File

@@ -29,10 +29,8 @@ import type { MetabaseAuthConfig } from "embedding-sdk-shared/types/auth-config"
import type { SdkAuthState } from "embedding-sdk-shared/types/auth-state"; import type { SdkAuthState } from "embedding-sdk-shared/types/auth-state";
import { SDK_AUTH_STATE_KEY } from "embedding-sdk-shared/types/auth-state"; import { SDK_AUTH_STATE_KEY } from "embedding-sdk-shared/types/auth-state";
import { requestSessionTokenFromEmbedJs } from "metabase/embedding/embedding-iframe-sdk/utils"; import { requestSessionTokenFromEmbedJs } from "metabase/embedding/embedding-iframe-sdk/utils";
import { import { getSessionTokenHeaders } from "metabase/embedding/lib/auth/get-session-token-headers";
sessionTokenHeaders, import { setApiKeyHeader } from "metabase/embedding/lib/auth/set-api-key-header";
setApiKeyHeader,
} from "metabase/embedding/lib/embedding-request-auth";
import { import {
EMBEDDING_SDK_IFRAME_EMBEDDING_CONFIG, EMBEDDING_SDK_IFRAME_EMBEDDING_CONFIG,
isEmbeddingEajs, isEmbeddingEajs,
@@ -74,7 +72,7 @@ PLUGIN_EMBEDDING_SDK_AUTH.initAuth = async (
// applies to the very request that triggered the refresh. // applies to the very request that triggered the refresh.
const sessionTokenHandler = async () => { const sessionTokenHandler = async () => {
const session = await dispatch(getOrRefreshSession(authConfig)).unwrap(); const session = await dispatch(getOrRefreshSession(authConfig)).unwrap();
return session?.id ? sessionTokenHeaders(session.id) : undefined; return session?.id ? getSessionTokenHeaders(session.id) : undefined;
}; };
// Check if we can use the auth pre-fetched by the bootstrap chunk // Check if we can use the auth pre-fetched by the bootstrap chunk

View File

@@ -34,6 +34,24 @@ export * from "./main.bundle.js";
writeToFile("nextjs.cjs", nextjs_cjs); writeToFile("nextjs.cjs", nextjs_cjs);
writeToFile("nextjs.js", nextjs_js); writeToFile("nextjs.js", nextjs_js);
// Data-app specific imports
const dataApp_cjs = `
const DataApp = require("./data-app.bundle");
module.exports = DataApp;
`.trim();
const dataApp_js = `
export * from "./data-app.esm.js";
`.trim();
writeToFile("data-app.cjs", dataApp_cjs);
writeToFile("data-app.js", dataApp_js);
// The data-app dev preset (`data-app-dev.js`, ESM-only) and the dev entry
// (`data-app-dev-entry.js`) are emitted directly by the rspack builds, so they
// need no wrappers here.
// Development mode entry point. // Development mode entry point.
// When the host app bundler resolves the "development" exports condition, // When the host app bundler resolves the "development" exports condition,
// this file sets a window global so the SDK bundle can detect dev mode. // this file sets a window global so the SDK bundle can detect dev mode.
@@ -46,7 +64,7 @@ try {
console.warn("Metabase SDK: Failed to set dev mode flag", e); console.warn("Metabase SDK: Failed to set dev mode flag", e);
} }
module.exports = require("./main.bundle"); export * from "./main.esm.js";
`.trim(); `.trim();
writeToFile("main.development.js", devMode_js); writeToFile("main.development.js", devMode_js);

View File

@@ -0,0 +1,21 @@
import type { DataAppLinkProps } from "embedding-sdk-bundle/lib/data-app/router";
import { getWindow } from "embedding-sdk-shared/lib/get-window";
export const DataAppLink = ({ to, children, ...rest }: DataAppLinkProps) => {
const BundleDataAppLink =
getWindow()?.METABASE_EMBEDDING_SDK_BUNDLE?.DataAppLink;
if (!BundleDataAppLink) {
return (
<a href={to} {...rest}>
{children}
</a>
);
}
return (
<BundleDataAppLink to={to} {...rest}>
{children}
</BundleDataAppLink>
);
};

View File

@@ -0,0 +1 @@
export { DataAppLink } from "./DataAppLink";

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { getWindow } from "embedding-sdk-shared/lib/get-window";
export const DataAppRouter = ({ children }: { children?: ReactNode }) => {
const BundleDataAppRouter =
getWindow()?.METABASE_EMBEDDING_SDK_BUNDLE?.DataAppRouter;
if (!BundleDataAppRouter) {
return <>{children}</>;
}
return <BundleDataAppRouter>{children}</BundleDataAppRouter>;
};

View File

@@ -0,0 +1 @@
export { DataAppRouter } from "./DataAppRouter";

View File

@@ -0,0 +1,107 @@
import * as React from "react";
import * as ReactDOM from "react-dom";
import * as ReactDOMClient from "react-dom/client";
import * as ReactDOMServer from "react-dom/server";
import * as ReactJsxDevRuntime from "react/jsx-dev-runtime";
import * as ReactJsxRuntime from "react/jsx-runtime";
import * as sdkExports from "@metabase/embedding-sdk-react";
import * as dataAppExports from "@metabase/embedding-sdk-react/data-app";
import {
DataAppDevProvider,
DevToolbar,
createDataAppSandbox,
installDevDiagnostics,
} from "@metabase/embedding-sdk-react/data-app-dev";
import {
allowedHosts,
appSlug,
bundleUrl,
rebuiltEvent,
} from "virtual:metabase-data-app-dev-config";
import { createRoot } from "react-dom/client";
// The same baseline reset the production iframe loads (`iframe-vendors.ts`), so the
// dev preview matches production. style-loader injects it at runtime.
import "metabase-enterprise/data_apps/sandbox/iframe-baseline.css";
// The data-app dev entry. rspack bundles this file into the SDK dist as
// `data-app-dev-entry.js` (see `rspack.embedding-sdk-package.config.js`),
// inlining the sandbox + dev toolbar so they aren't part of the package's public
// API. `react`, `react-dom`, `@metabase/embedding-sdk-react/*`, and the dev
// plugin's `virtual:metabase-data-app-dev-config` are left EXTERNAL, so the
// consumer's Vite resolves them — the bundle runs against the consumer's single
// React/SDK instance (the same ones the app bundle is endowed with), and the dev
// plugin provides the config (the app's `allowed_hosts` + the bundle URL/event).
//
// It mounts the diagnostics toolbar, builds the Near-Membrane sandbox, then
// fetches + evaluates the app's IIFE bundle and renders it under
// `DataAppDevProvider`. Load failures go through `console.error`, so the toolbar
// surfaces them.
const authConfig = {
metabaseInstanceUrl: import.meta.env.DATA_APP_MB_URL,
apiKey: import.meta.env.DATA_APP_MB_API_KEY,
};
installDevDiagnostics();
const toolbarRoot = document.createElement("div");
document.body.appendChild(toolbarRoot);
createRoot(toolbarRoot).render(<DevToolbar />);
const root = document.getElementById("root");
if (!root) {
throw new Error("#root not found");
}
const appRoot = createRoot(root);
const sandbox = createDataAppSandbox({
label: "dev",
targetWindow: window,
allowedHosts,
endowments: {
React,
reactDom: ReactDOM,
reactDomClient: ReactDOMClient,
reactDomServer: ReactDOMServer,
reactJsxRuntime: ReactJsxRuntime,
reactJsxDevRuntime: ReactJsxDevRuntime,
sdkExports,
dataAppExports,
},
});
async function loadAndRender() {
const res = await fetch(bundleUrl, { cache: "no-store" });
if (!res.ok) {
throw new Error(
`Failed to load the data-app bundle: ${res.status} ${res.statusText}.`,
);
}
const code = await res.text();
const { component: Component, providerProps } = sandbox.evaluate(code)();
appRoot.render(
<DataAppDevProvider
appSlug={appSlug}
authConfig={authConfig}
{...providerProps}
>
<Component />
</DataAppDevProvider>,
);
}
loadAndRender().catch((error) => {
console.error(error);
});
if (import.meta.hot) {
import.meta.hot.on(rebuiltEvent, () => {
loadAndRender().catch((error) => {
console.error(error);
});
});
}

View File

@@ -0,0 +1,5 @@
// Node subpath `@metabase/embedding-sdk-react/data-app-dev/config` with the Vite app config
export {
dataAppConfig,
type DataAppConfigOverrides,
} from "./data-app-dev/index";

View File

@@ -0,0 +1,6 @@
// Browser subpath `@metabase/embedding-sdk-react/data-app-dev` with dev app client exports.
// The Node Vite config preset lives at `/data-app-dev/config` (data-app-dev.config.ts).
export { DataAppDevProvider } from "./data-app-dev/components/DataAppDevProvider/DataAppDevProvider";
export { DevToolbar } from "./data-app-dev/components/DevToolbar/DevToolbar";
export { installDevDiagnostics } from "./data-app-dev/components/DevToolbar/diagnostics";
export { createDataAppSandbox } from "metabase-enterprise/data_apps/sandbox/sandbox";

View File

@@ -0,0 +1,22 @@
/**
* Build/runtime constants shared by the data-app dev server preset and its dev
* plugin. The sandbox globals contract itself lives with the sandbox
* (`metabase-enterprise/data_apps/sandbox/globals.ts`); it's re-exported here
* so the dev preset keeps a single import site.
*/
export {
DATA_APP_EXTERNALS,
DATA_APP_FACTORY_GLOBAL,
DATA_APP_GLOBALS,
DATA_APP_GLOBAL_NAMES,
} from "metabase-enterprise/data_apps/sandbox/globals";
/** The app entry the IIFE is built from. */
export const DATA_APP_ENTRY = "src/index.tsx";
/** Dev-only URL the dev entry fetches the freshly-built IIFE bundle from. */
export const DATA_APP_BUNDLE_URL = "/@data-app-bundle.js";
/** Custom HMR event the dev plugin emits on rebuild so the dev entry soft-reloads. */
export const DATA_APP_REBUILT_EVENT = "data-app:rebuilt";

View File

@@ -0,0 +1,20 @@
import { DATA_APP_EXTERNALS, DATA_APP_GLOBALS } from "./bundle";
describe("data-app bundle externals", () => {
it("externalizes React DOM imports to endowed globals", () => {
expect(DATA_APP_GLOBALS).toEqual(
expect.objectContaining({
"react-dom": "__react_dom__",
"react-dom/client": "__react_dom_client__",
"react-dom/server": "__react_dom_server__",
}),
);
expect(DATA_APP_EXTERNALS).toEqual(
expect.arrayContaining([
"react-dom",
"react-dom/client",
"react-dom/server",
]),
);
});
});

View File

@@ -0,0 +1,33 @@
import { once } from "underscore";
import type { MetabaseProviderProps } from "embedding-sdk-bundle/types/metabase-provider";
import { MetabaseProvider } from "embedding-sdk-package/components/public/MetabaseProvider/MetabaseProvider";
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
const registerDataAppDevContext = once((appSlug: string) => {
ensureMetabaseProviderPropsStore().updateInternalProps({
dataApp: { name: appSlug, isDev: true },
});
});
export type DataAppDevProviderProps = MetabaseProviderProps & {
appSlug: string;
};
/**
* Provider for the data-app local dev preview (`npm run dev`). Wraps
* `MetabaseProvider` and reports the page's traffic as the data app, so query
* executions are attributed to the app (as "data-app-preview") instead of the
* React SDK.
*
* Intended only for the local Vite dev server.
*/
export const DataAppDevProvider = ({
appSlug,
children,
...props
}: DataAppDevProviderProps) => {
registerDataAppDevContext(appSlug);
return <MetabaseProvider {...props}>{children}</MetabaseProvider>;
};

View File

@@ -0,0 +1,32 @@
import { render, screen } from "@testing-library/react";
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
import { DataAppDevProvider } from "./DataAppDevProvider";
jest.mock(
"embedding-sdk-package/components/public/MetabaseProvider/MetabaseProvider",
() => ({
MetabaseProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}),
);
describe("DataAppDevProvider", () => {
it("registers the data-app dev context for the bundle realm and renders children", () => {
render(
<DataAppDevProvider
appSlug="sales"
authConfig={{ metabaseInstanceUrl: "http://localhost:3000" }}
>
<div>app content</div>
</DataAppDevProvider>,
);
expect(screen.getByText("app content")).toBeInTheDocument();
expect(
ensureMetabaseProviderPropsStore().getState().internalProps.dataApp,
).toEqual({ name: "sales", isDev: true });
});
});

View File

@@ -0,0 +1,103 @@
/* The dev toolbar renders in the host page, potentially before the SDK bundle
and its theme variables load, so every color falls back to a literal. */
.DevToolbar {
position: fixed;
right: 16px;
bottom: 16px;
/* Stay above any host-app content. */
z-index: 2147483647;
font-family:
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
font-size: 13px;
}
.Toggle {
display: block;
margin-left: auto;
padding: 8px 14px;
border: none;
border-radius: 8px;
cursor: pointer;
color: #fff;
background: var(--mb-color-brand, #509ee3);
box-shadow: 0 2px 8px var(--mb-color-shadow, rgba(0, 0, 0, 0.13));
font-weight: 700;
}
.ToggleHasErrors {
background: var(--mb-color-error, #e35050);
}
.Panel {
width: 380px;
max-height: 60vh;
margin-bottom: 8px;
display: flex;
flex-direction: column;
background: var(--mb-color-background-primary, #fff);
color: var(--mb-color-text-primary, #4c5773);
border: 1px solid var(--mb-color-border, #eeecec);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 24px var(--mb-color-shadow, rgba(0, 0, 0, 0.13));
}
.Header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--mb-color-background-secondary, #f9fbfc);
border-bottom: 1px solid var(--mb-color-border, #eeecec);
}
.Title {
font-weight: 700;
}
.Spacer {
flex: 1;
}
.Action {
border: none;
background: none;
color: var(--mb-color-brand, #509ee3);
cursor: pointer;
padding: 0;
font-size: 13px;
font-weight: 600;
}
.Body {
overflow-y: auto;
padding: 4px;
}
.Empty {
padding: 12px;
color: var(--mb-color-text-secondary, #696e7b);
}
.Entry {
padding: 8px 10px;
border-bottom: 1px solid var(--mb-color-border, #eeecec);
}
.EntryTime {
color: var(--mb-color-text-tertiary, #949aab);
font-size: 11px;
margin-bottom: 2px;
}
.EntryMessage {
white-space: pre-wrap;
word-break: break-word;
font-family: Monaco, ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--mb-color-text-primary, #4c5773);
}

View File

@@ -0,0 +1,72 @@
/* eslint-disable i18next/no-literal-string */
import cx from "classnames";
import { useState, useSyncExternalStore } from "react";
import S from "./DevToolbar.module.css";
import {
clearDevDiagnostics,
getDevDiagnostics,
subscribeDevDiagnostics,
} from "./diagnostics";
export function DevToolbar() {
const entries = useSyncExternalStore(
subscribeDevDiagnostics,
getDevDiagnostics,
);
const [open, setOpen] = useState(false);
const count = entries.length;
return (
<div className={S.DevToolbar}>
{open && (
<div className={S.Panel}>
<div className={S.Header}>
<span className={S.Title}>Data app diagnostics</span>
<span className={S.Spacer} />
<button
type="button"
className={S.Action}
onClick={clearDevDiagnostics}
>
Clear
</button>
<button
type="button"
className={S.Action}
onClick={() => setOpen(false)}
>
Close
</button>
</div>
<div className={S.Body}>
{count === 0 ? (
<div className={S.Empty}>No errors captured.</div>
) : (
entries
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className={S.Entry}>
<div className={S.EntryTime}>
{new Date(entry.time).toLocaleTimeString()}
</div>
<div className={S.EntryMessage}>{entry.message}</div>
</div>
))
)}
</div>
</div>
)}
<button
type="button"
className={cx(S.Toggle, { [S.ToggleHasErrors]: count > 0 })}
onClick={() => setOpen((value) => !value)}
title="Data app diagnostics"
>
⚠ Diagnostics{count > 0 ? ` (${count})` : ""}
</button>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import userEvent from "@testing-library/user-event";
import { act, render, screen } from "__support__/ui";
import { DevToolbar } from "./DevToolbar";
import { clearDevDiagnostics, installDevDiagnostics } from "./diagnostics";
const recordError = (message: string) => {
act(() => {
console.error(message);
});
};
let originalConsoleError: typeof console.error;
beforeAll(() => {
originalConsoleError = console.error;
console.error = () => {};
installDevDiagnostics();
});
afterAll(() => {
console.error = originalConsoleError;
});
beforeEach(() => {
clearDevDiagnostics();
});
const getToggle = () => screen.getByRole("button", { name: /Diagnostics/ });
describe("DevToolbar", () => {
it("renders a collapsed diagnostics toggle with no count when there are no errors", () => {
render(<DevToolbar />);
expect(getToggle()).toHaveTextContent(/^⚠ Diagnostics$/);
expect(screen.queryByText("Data app diagnostics")).not.toBeInTheDocument();
});
it("opens the panel showing the empty state, then closes via the toggle", async () => {
render(<DevToolbar />);
await userEvent.click(getToggle());
expect(screen.getByText("Data app diagnostics")).toBeInTheDocument();
expect(screen.getByText("No errors captured.")).toBeInTheDocument();
await userEvent.click(getToggle());
expect(screen.queryByText("Data app diagnostics")).not.toBeInTheDocument();
});
it("shows the captured count and lists entries newest first", async () => {
render(<DevToolbar />);
recordError("first error");
recordError("second error");
expect(getToggle()).toHaveTextContent("⚠ Diagnostics (2)");
await userEvent.click(getToggle());
const messages = screen.getAllByText(/error$/);
expect(messages[0]).toHaveTextContent("second error");
expect(messages[1]).toHaveTextContent("first error");
});
it("clears entries when Clear is clicked, leaving the panel open", async () => {
render(<DevToolbar />);
recordError("boom");
await userEvent.click(getToggle());
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
expect(screen.getByText("No errors captured.")).toBeInTheDocument();
expect(getToggle()).toHaveTextContent(/^⚠ Diagnostics$/);
});
it("closes the panel when Close is clicked", async () => {
render(<DevToolbar />);
await userEvent.click(getToggle());
expect(screen.getByText("Data app diagnostics")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByText("Data app diagnostics")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,114 @@
// Dev diagnostics store for the data-app dev toolbar. Captures errors —
// including the sandbox's `[data-app …] blocked API call: …` logs, which surface
// through `console.error` — so the toolbar can show what went wrong (the sandbox
// otherwise reports blocked APIs only as an opaque `#<Object>`).
//
// Capture is opt-in: it only patches `console.error` / listens for uncaught
// errors once `installDevDiagnostics()` is called. Nothing here runs on import,
// so a host that imports `createDataAppSandbox` from the same entry doesn't pull
// any of this in unless it's actually used.
export type DevDiagnosticLevel = "error";
export interface DevDiagnosticEntry {
id: number;
time: number;
level: DevDiagnosticLevel;
message: string;
}
const MAX_ENTRIES = 200;
let entries: DevDiagnosticEntry[] = [];
let nextId = 1;
let installed = false;
const listeners = new Set<() => void>();
const emit = () => {
for (const listener of listeners) {
listener();
}
};
const formatArg = (arg: unknown): string => {
if (typeof arg === "string") {
return arg;
}
if (arg instanceof Error) {
return arg.stack ?? `${arg.name}: ${arg.message}`;
}
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
};
/**
* Format a CSP violation for the toolbar. The dev server mirrors Metabase's
* production CSP, so a violation here means the app would be blocked once
* sandboxed in Metabase too — e.g. a native `<form action="…">` hitting
* `form-action 'none'`, or a `fetch` to a host outside `allowed_hosts`. These
* never reach `console.error`, so the toolbar wouldn't see them otherwise.
*/
const formatCspViolation = (event: SecurityPolicyViolationEvent): string => {
const directive = event.effectiveDirective || event.violatedDirective;
const target = event.blockedURI || "inline content";
return `Content Security Policy (${directive}) blocked ${target}`;
};
const record = (level: DevDiagnosticLevel, message: string) => {
// New array reference each time so `useSyncExternalStore` re-renders.
entries = [...entries, { id: nextId++, time: Date.now(), level, message }];
if (entries.length > MAX_ENTRIES) {
entries = entries.slice(-MAX_ENTRIES);
}
emit();
};
export const getDevDiagnostics = (): readonly DevDiagnosticEntry[] => entries;
export const subscribeDevDiagnostics = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const clearDevDiagnostics = (): void => {
entries = [];
emit();
};
/**
* Start capturing errors into the diagnostics store: wraps `console.error` and
* listens for uncaught errors / unhandled rejections. Idempotent. Call it before
* the sandbox runs so nothing is missed.
*/
export const installDevDiagnostics = (): void => {
if (installed || typeof window === "undefined") {
return;
}
installed = true;
const originalError = console.error.bind(console);
console.error = (...args: unknown[]) => {
record("error", args.map(formatArg).join(" "));
originalError(...args);
};
window.addEventListener("error", (event) => {
if (event.error != null || event.message) {
record("error", formatArg(event.error ?? event.message));
}
});
window.addEventListener("unhandledrejection", (event) => {
record("error", `Unhandled rejection: ${formatArg(event.reason)}`);
});
window.addEventListener("securitypolicyviolation", (event) => {
record("error", formatCspViolation(event));
});
};

View File

@@ -0,0 +1,147 @@
import {
type DevDiagnosticEntry,
clearDevDiagnostics,
getDevDiagnostics,
installDevDiagnostics,
subscribeDevDiagnostics,
} from "./diagnostics";
const last = (entries: readonly DevDiagnosticEntry[]) =>
entries[entries.length - 1];
let forwarded: unknown[][] = [];
let originalConsoleError: typeof console.error;
beforeAll(() => {
originalConsoleError = console.error;
console.error = (...args: unknown[]) => {
forwarded.push(args);
};
installDevDiagnostics();
});
afterAll(() => {
console.error = originalConsoleError;
});
beforeEach(() => {
clearDevDiagnostics();
forwarded = [];
});
describe("dev diagnostics store", () => {
it("starts empty", () => {
expect(getDevDiagnostics()).toEqual([]);
});
it("records console.error calls as error entries with id/time/message", () => {
console.error("boom", { code: 1 });
const entries = getDevDiagnostics();
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
level: "error",
message: 'boom {"code":1}',
});
expect(typeof entries[0].id).toBe("number");
expect(typeof entries[0].time).toBe("number");
});
it("still forwards to the original console.error", () => {
console.error("passed through");
expect(forwarded).toContainEqual(["passed through"]);
});
it("formats Error arguments using their message", () => {
console.error(new Error("kaboom"));
expect(last(getDevDiagnostics()).message).toContain("kaboom");
});
it("captures uncaught window errors", () => {
const event = Object.assign(new Event("error"), {
message: "window blew up",
});
window.dispatchEvent(event);
expect(last(getDevDiagnostics()).message).toContain("window blew up");
});
it("captures unhandled promise rejections", () => {
const event = Object.assign(new Event("unhandledrejection"), {
reason: "nope",
});
window.dispatchEvent(event);
expect(last(getDevDiagnostics()).message).toBe("Unhandled rejection: nope");
});
it("captures CSP form-action violations (e.g. a blocked native form submit)", () => {
const event = Object.assign(new Event("securitypolicyviolation"), {
effectiveDirective: "form-action",
violatedDirective: "form-action",
blockedURI: "https://example.com/",
originalPolicy: "connect-src 'self'; form-action 'none'",
} satisfies Partial<SecurityPolicyViolationEvent>);
window.dispatchEvent(event);
expect(last(getDevDiagnostics()).message).toBe(
"Content Security Policy (form-action) blocked https://example.com/",
);
});
it("formats other CSP violations generically (e.g. connect-src)", () => {
const event = Object.assign(new Event("securitypolicyviolation"), {
effectiveDirective: "connect-src",
violatedDirective: "connect-src",
blockedURI: "https://evil.test/",
originalPolicy: "connect-src 'self'; form-action 'none'",
} satisfies Partial<SecurityPolicyViolationEvent>);
window.dispatchEvent(event);
expect(last(getDevDiagnostics()).message).toBe(
"Content Security Policy (connect-src) blocked https://evil.test/",
);
});
it("notifies subscribers on record and clear, and stops after unsubscribe", () => {
const listener = jest.fn();
const unsubscribe = subscribeDevDiagnostics(listener);
console.error("one");
expect(listener).toHaveBeenCalledTimes(1);
clearDevDiagnostics();
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
console.error("two");
expect(listener).toHaveBeenCalledTimes(2);
});
it("returns a fresh array reference per record (so useSyncExternalStore re-renders)", () => {
const before = getDevDiagnostics();
console.error("change");
expect(getDevDiagnostics()).not.toBe(before);
});
it("is idempotent — installing again does not double-record", () => {
installDevDiagnostics();
console.error("once");
expect(getDevDiagnostics()).toHaveLength(1);
});
it("caps stored entries at 200, keeping the most recent", () => {
for (let i = 0; i < 205; i++) {
console.error(`error ${i}`);
}
const entries = getDevDiagnostics();
expect(entries).toHaveLength(200);
expect(entries[0].message).toBe("error 5");
expect(last(entries).message).toBe("error 204");
});
});

View File

@@ -0,0 +1,46 @@
import type { LibraryFormats } from "vite";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import svgr from "vite-plugin-svgr";
import {
DATA_APP_ENTRY,
DATA_APP_EXTERNALS,
DATA_APP_FACTORY_GLOBAL,
DATA_APP_GLOBALS,
} from "../bundle";
/**
* The data-app bundle contract, shared by `vite build` (production) and the dev
* server's in-memory membrane build so both emit the same shape: one IIFE that
* assigns the app factory to `__dataAppFactory__`, with React + the SDK left
* external and mapped to the globals the sandbox endows. `fileName` is the only
* difference — the shipped `index.js` vs the dev `data-app-bundle.js`.
*
* `assetsInlineLimit: () => true` forces every imported asset (images, fonts, …)
* to be base64-inlined: the backend serves one bundle file, so the build must
* emit a single self-contained `.js` with no sidecar assets.
*/
export function dataAppLibBuild(fileName: string) {
return {
assetsInlineLimit: () => true,
lib: {
entry: DATA_APP_ENTRY,
formats: ["iife"] satisfies LibraryFormats[],
name: DATA_APP_FACTORY_GLOBAL,
fileName: () => fileName,
},
rollupOptions: {
external: DATA_APP_EXTERNALS,
output: { globals: DATA_APP_GLOBALS },
},
};
}
/**
* Plugins every data-app build needs: inline imported CSS into the JS (the IIFE
* has no HTML to link a stylesheet), and let SVGs be imported as React
* components via `vite-plugin-svgr`.
*/
export function dataAppBuildPlugins() {
return [cssInjectedByJsPlugin(), svgr()];
}

View File

@@ -0,0 +1,8 @@
// Libraries such as react-datepicker require a process.NODE_ENV define.
export function getDataAppDefine(mode: string): Record<string, string> {
return {
"process.env.NODE_ENV": JSON.stringify(
mode === "production" ? "production" : "development",
),
};
}

View File

@@ -0,0 +1,15 @@
import { getDataAppDefine } from "./define";
describe("getDataAppDefine", () => {
it("defines process.env.NODE_ENV for production builds", () => {
expect(getDataAppDefine("production")).toEqual({
"process.env.NODE_ENV": '"production"',
});
});
it("defines process.env.NODE_ENV for development builds", () => {
expect(getDataAppDefine("development")).toEqual({
"process.env.NODE_ENV": '"development"',
});
});
});

View File

@@ -0,0 +1,44 @@
const toOrigin = (url: string | undefined): string | undefined => {
try {
return url ? new URL(url).origin : undefined;
} catch {
return undefined;
}
};
/**
* Dev-server CSP mirroring what Metabase emits for a data app in production:
*
* - `connect-src`: the app may reach its `allowed_hosts` (plus the Metabase
* instance, for the SDK's own calls) and the Vite dev server / HMR websocket
* — nothing else. So a `fetch`/XHR a production data app couldn't make is
* blocked in `npm run dev` too, instead of silently working locally.
* - `form-action`: restricts native `<form action="…">` submits to the app's
* `allowed_hosts` (mirroring `connect-src`); with none declared it is
* `'none'`, blocking every native submit. Client-side `onSubmit` handlers
* still work — they preventDefault, so no submission is ever checked.
* - `frame-src`: the app may embed / navigate to `'self'` and its
* `allowed_hosts` (mirroring production, where declared hosts are added to
* `frame-src`), so an `<iframe>`/navigation a production app couldn't make
* is blocked in `npm run dev` too.
*/
export const buildDevCsp = (
allowedHosts: string[],
metabaseUrl: string | undefined,
): string => {
const instanceOrigin = toOrigin(metabaseUrl);
const sources = [
"'self'",
"ws://localhost:*",
"wss://localhost:*",
"ws://127.0.0.1:*",
"wss://127.0.0.1:*",
...(instanceOrigin ? [instanceOrigin] : []),
...allowedHosts,
];
const formAction =
allowedHosts.length > 0 ? allowedHosts.join(" ") : "'none'";
const frameSrc = ["'self'", ...allowedHosts].join(" ");
return `connect-src ${sources.join(" ")}; form-action ${formAction}; frame-src ${frameSrc}`;
};

View File

@@ -0,0 +1,52 @@
import { buildDevCsp } from "./dev-connect-src";
/** Extract a single directive's value from a CSP header string. */
function directive(csp: string, name: string): string | undefined {
return csp
.split(";")
.map((part) => part.trim())
.find((part) => part === name || part.startsWith(`${name} `))
?.slice(name.length)
.trim();
}
describe("buildDevCsp", () => {
it("blocks native submits and limits framing to 'self' when no allowed_hosts", () => {
const csp = buildDevCsp([], undefined);
expect(directive(csp, "connect-src")).toBe(
"'self' ws://localhost:* wss://localhost:* ws://127.0.0.1:* wss://127.0.0.1:*",
);
expect(directive(csp, "form-action")).toBe("'none'");
expect(directive(csp, "frame-src")).toBe("'self'");
});
it("adds allowed_hosts to connect-src, form-action and frame-src", () => {
const hosts = ["https://api.example.com", "https://*.trusted.test"];
const csp = buildDevCsp(hosts, undefined);
for (const host of hosts) {
expect(directive(csp, "connect-src")).toContain(host);
}
expect(directive(csp, "form-action")).toBe(hosts.join(" "));
expect(directive(csp, "frame-src")).toBe(`'self' ${hosts.join(" ")}`);
});
it("adds the Metabase instance origin to connect-src only, normalized to an origin", () => {
const csp = buildDevCsp([], "http://localhost:3000/app?token=secret#x");
expect(directive(csp, "connect-src")).toContain("http://localhost:3000");
// Only the origin — never the path/query/fragment.
expect(csp).not.toContain("/app");
expect(csp).not.toContain("secret");
// The instance origin is for fetch, not for submitting/framing.
expect(directive(csp, "form-action")).toBe("'none'");
expect(directive(csp, "frame-src")).toBe("'self'");
});
it("ignores an unparseable Metabase URL", () => {
const csp = buildDevCsp([], "not a url");
expect(directive(csp, "connect-src")).not.toContain("not a url");
});
});

View File

@@ -0,0 +1,16 @@
import type { ConfigEnv } from "vite";
/**
* The env-var prefix Vite exposes to `import.meta.env` for a data app.
*
* The app's `DATA_APP_*` vars (including the dev `DATA_APP_MB_API_KEY` kept in
* `.env.local`) are exposed to the bundle in the dev preview ONLY. A production
* `vite build` gets `undefined` (Vite's default `VITE_` prefix), so those dev
* secrets can never be statically inlined into the shipped `dist/index.js` that
* Metabase serves to users — even if app code reads `import.meta.env.DATA_APP_*`.
*/
export function dataAppEnvPrefix(
command: ConfigEnv["command"],
): string[] | undefined {
return command === "serve" ? ["DATA_APP_"] : undefined;
}

View File

@@ -0,0 +1,11 @@
import { dataAppEnvPrefix } from "./env-prefix";
describe("dataAppEnvPrefix", () => {
it("exposes the app's DATA_APP_ env vars in the dev preview (serve)", () => {
expect(dataAppEnvPrefix("serve")).toEqual(["DATA_APP_"]);
});
it("exposes no DATA_APP_ env vars in a production build", () => {
expect(dataAppEnvPrefix("build")).toBeUndefined();
});
});

View File

@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
// How many parent dirs above the app to search for `.env.local`. Covers the
// deepest supported layout where the app is synced into a parent repo as
// `<repo>/data_apps/<app>` (2 levels deep). `.git` is a hard stop — `.env.local`
// lives at the repo root, so we never search above it.
const MAX_ENV_SEARCH_DEPTH = 2;
/** Walk up from `start` to the dir holding `.env.local` (or the git root). */
export function findEnvRoot(start: string): string {
let dir = start;
for (let i = 0; i <= MAX_ENV_SEARCH_DEPTH; i++) {
if (
fs.existsSync(path.join(dir, ".env.local")) ||
fs.existsSync(path.join(dir, ".git"))
) {
return dir;
}
dir = path.dirname(dir);
}
return start;
}

View File

@@ -0,0 +1,61 @@
import fs from "node:fs";
import path from "node:path";
import { findEnvRoot } from "./find-env-root";
jest.mock("node:fs");
const mockedFs = jest.mocked(fs);
type SetupOpts = {
existing?: string[];
start: string;
};
const setup = ({ existing = [], start }: SetupOpts) => {
const set = new Set(existing);
mockedFs.existsSync.mockImplementation((p) => set.has(p.toString()));
return { root: findEnvRoot(start) };
};
describe("findEnvRoot", () => {
afterEach(() => jest.resetAllMocks());
it("returns the start dir when it holds .env.local", () => {
const start = "/repo/data_apps/sales";
expect(
setup({ existing: [path.join(start, ".env.local")], start }).root,
).toBe(start);
});
it("walks up to the dir holding .env.local", () => {
const { root } = setup({
existing: [path.join("/repo", ".env.local")],
start: "/repo/data_apps/sales",
});
expect(root).toBe("/repo");
});
it("stops at the git root even without an .env.local", () => {
const { root } = setup({
existing: [path.join("/repo", ".git")],
start: "/repo/data_apps/sales",
});
expect(root).toBe("/repo");
});
it("returns the start dir when nothing is found within the search depth", () => {
expect(setup({ start: "/a/b/c" }).root).toBe("/a/b/c");
});
it("does not search above the max depth (2 parents)", () => {
// .env.local sits 3 levels up — beyond MAX_ENV_SEARCH_DEPTH.
const { root } = setup({
existing: [path.join("/root", ".env.local")],
start: "/root/one/two/three",
});
expect(root).toBe("/root/one/two/three");
});
});

View File

@@ -0,0 +1,77 @@
import fs from "node:fs";
import path from "node:path";
import { load as parseYaml } from "js-yaml";
/**
* The app's parsed `data_app.yaml` manifest, validated and normalized:
* `allowed_hosts` is guaranteed to be a list of strings.
*/
export type DataAppManifest = {
allowed_hosts?: string[];
};
const isString = (value: unknown): value is string => typeof value === "string";
const parseAllowedHosts = (
value: unknown,
manifestPath: string,
): string[] | undefined => {
if (value == null) {
return undefined;
}
if (!Array.isArray(value)) {
throw new Error(`${manifestPath}: "allowed_hosts" must be a list.`);
}
if (!value.every(isString)) {
const nonString = value.find((host) => !isString(host));
throw new Error(
`${manifestPath}: every "allowed_hosts" entry must be a string, got ${JSON.stringify(
nonString,
)}.`,
);
}
return value;
};
/**
* Read, parse, and normalize the app's `data_app.yaml` manifest.
* Returns null when the app has no manifest.
*/
export const readManifest = (
appRoot: string,
): { manifestPath: string; manifest: DataAppManifest } | null => {
const manifestPath = [path.join(appRoot, "data_app.yaml")].find((candidate) =>
fs.existsSync(candidate),
);
if (!manifestPath) {
return null;
}
let parsed: unknown;
try {
parsed = parseYaml(fs.readFileSync(manifestPath, "utf8"));
} catch (error) {
throw new Error(
`Could not parse ${manifestPath}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const raw: { allowed_hosts?: unknown } =
typeof parsed === "object" && parsed !== null ? parsed : {};
return {
manifestPath,
manifest: {
allowed_hosts: parseAllowedHosts(raw.allowed_hosts, manifestPath),
},
};
};

View File

@@ -0,0 +1,80 @@
import fs from "node:fs";
import path from "node:path";
import { readManifest } from "./read-manifest";
jest.mock("node:fs");
const mockedFs = jest.mocked(fs);
const APP_ROOT = "/app";
const YAML_PATH = path.join(APP_ROOT, "data_app.yaml");
const setup = (files: Record<string, string>) => {
mockedFs.existsSync.mockImplementation((p) => p.toString() in files);
mockedFs.readFileSync.mockImplementation((p) => {
const content = files[p.toString()];
if (content == null) {
throw new Error(`unexpected read: ${p}`);
}
return content;
});
};
afterEach(() => jest.resetAllMocks());
describe("readManifest", () => {
it("returns null when no manifest exists", () => {
setup({});
expect(readManifest(APP_ROOT)).toBeNull();
});
it("returns an empty manifest for non-object yaml", () => {
setup({ [YAML_PATH]: "just a string\n" });
expect(readManifest(APP_ROOT)?.manifest).toEqual({});
});
it("throws on unparseable yaml", () => {
setup({ [YAML_PATH]: "allowed_hosts: [unclosed\n" });
expect(() => readManifest(APP_ROOT)).toThrow(/Could not parse/);
});
describe("allowed_hosts", () => {
it("reads allowed_hosts as a list of strings", () => {
setup({
[YAML_PATH]:
"allowed_hosts:\n - https://api.example.com\n - https://*.acme.com\n",
});
expect(readManifest(APP_ROOT)?.manifest.allowed_hosts).toEqual([
"https://api.example.com",
"https://*.acme.com",
]);
});
it("is undefined when the manifest has no allowed_hosts key", () => {
setup({ [YAML_PATH]: "name: My App\n" });
expect(readManifest(APP_ROOT)?.manifest.allowed_hosts).toBeUndefined();
});
it("throws when allowed_hosts is not a list", () => {
setup({ [YAML_PATH]: "allowed_hosts: nope\n" });
expect(() => readManifest(APP_ROOT)).toThrow(
/"allowed_hosts" must be a list/,
);
});
it("throws on a non-string entry", () => {
setup({ [YAML_PATH]: "allowed_hosts:\n - https://ok.com\n - 42\n" });
expect(() => readManifest(APP_ROOT)).toThrow(/must be a string/);
});
});
});

View File

@@ -0,0 +1,244 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type Plugin, type Rollup, build } from "vite";
// Build-time string constants shared with the rspack config; bundled as values,
// so this references no app runtime code (cf. `use-load-sdk-bundle.ts`). A
// namespace import stays single-line so the disable covers the reported line.
// eslint-disable-next-line metabase/no-external-references-for-sdk-package-code
import * as dataAppVirtualModules from "build-configs/embedding-sdk/constants/data-app-virtual-modules";
import { DATA_APP_BUNDLE_URL, DATA_APP_REBUILT_EVENT } from "../bundle";
import { dataAppBuildPlugins, dataAppLibBuild } from "../config/build-config";
import { getDataAppDefine } from "../config/define";
// Virtual modules the dev server provides. The dev server serves a synthetic
// `index.html` (below) that imports the dev entry; the dev entry imports the
// config (the app's allowed hosts + the bundle URL/event). The dev entry is the
// prebuilt `data-app-dev-entry.js` (bundled by the SDK's browser build, shipped
// next to this bundle in `dist`); it keeps React + `@metabase/embedding-sdk-react`
// external so the consumer's Vite resolves them to its single instance.
const { DATA_APP_DEV_CONFIG_VIRTUAL_ID, DATA_APP_DEV_ENTRY_VIRTUAL_ID } =
dataAppVirtualModules;
const DEV_ENTRY_SOURCE_PATH = fileURLToPath(
new URL("data-app-dev-entry.js", import.meta.url),
);
// The dev server's HTML shell, served synthetically so the app scaffold needs no
// `index.html`. It just boots the dev entry (resolved + served as a virtual
// module below), which injects the baseline reset CSS itself (the same file the
// production iframe loads).
const INDEX_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Data App Dev Preview</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import ${JSON.stringify(DATA_APP_DEV_ENTRY_VIRTUAL_ID)};
</script>
</body>
</html>
`;
// Rollup/Vite's virtual-module marker: a leading NUL byte tells Rollup core and
// other plugins that an id is synthetic, so they don't try to resolve/load it
// from disk. It's a convention (not a public export), so we spell it out; Vite
// encodes it as `__x00__` in dev URLs.
const RESOLVED_PREFIX = "\0";
/**
* Makes `npm run dev` run the app through the real Near-Membrane sandbox instead
* of mounting it un-sandboxed, so dev behaves like production — including for
* third-party libraries the app bundles.
*
* The membrane evaluates a code string, not Vite's module graph, so the app is
* built in-memory as the production IIFE on server start and on every `src/`
* change, served at `DATA_APP_BUNDLE_URL`. Instead of a full page reload it emits
* `DATA_APP_REBUILT_EVENT`, and the dev entry re-evaluates the new bundle in the
* live sandbox and re-renders in place — preserving the loaded SDK + auth (a
* soft reload; component state still resets, since the app is an opaque bundle).
*/
export function dataAppSandboxDevPlugin(
appSlug: string,
allowedHosts: string[],
): Plugin {
let bundleCode = "";
const rebuild = async (root: string, mode: string) => {
const result = await build({
root,
mode,
configFile: false,
define: getDataAppDefine(mode),
logLevel: "warn",
plugins: dataAppBuildPlugins(),
build: {
write: false,
minify: mode === "production",
...dataAppLibBuild("data-app-bundle.js"),
},
});
const outputs = Array.isArray(result) ? result : [result];
bundleCode =
outputs
.flatMap((output) => ("output" in output ? output.output : []))
.find((chunk): chunk is Rollup.OutputChunk => chunk.type === "chunk")
?.code ?? "";
};
return {
name: "metabase-data-app-dev",
apply: "serve",
resolveId(id) {
if (
id === DATA_APP_DEV_ENTRY_VIRTUAL_ID ||
id === DATA_APP_DEV_CONFIG_VIRTUAL_ID
) {
return RESOLVED_PREFIX + id;
}
},
load(id) {
if (id === RESOLVED_PREFIX + DATA_APP_DEV_ENTRY_VIRTUAL_ID) {
return fs.readFileSync(DEV_ENTRY_SOURCE_PATH, "utf8");
}
if (id === RESOLVED_PREFIX + DATA_APP_DEV_CONFIG_VIRTUAL_ID) {
return [
`export const allowedHosts = ${JSON.stringify(allowedHosts)};`,
`export const appSlug = ${JSON.stringify(appSlug)};`,
`export const bundleUrl = ${JSON.stringify(DATA_APP_BUNDLE_URL)};`,
`export const rebuiltEvent = ${JSON.stringify(DATA_APP_REBUILT_EVENT)};`,
].join("\n");
}
},
async configureServer(server) {
const { root, mode } = server.config;
const safeRebuild = async (): Promise<boolean> => {
try {
await rebuild(root, mode);
return true;
} catch (error) {
server.config.logger.error(
`[data-app-dev] failed to build the app bundle: ${
error instanceof Error ? error.message : String(error)
}`,
);
return false;
}
};
// Coalesce rebuilds: at most one Vite build runs at a time, and changes
// that arrive mid-build collapse into a single follow-up — so a burst of
// saves can't back up a queue of full rebuilds (or fire a soft reload each).
let building = false;
let pending = false;
const rebuildAndNotify = async () => {
if (building) {
pending = true;
return;
}
building = true;
try {
let built = false;
do {
pending = false;
built = await safeRebuild();
} while (pending);
if (built) {
server.ws.send({ type: "custom", event: DATA_APP_REBUILT_EVENT });
}
} finally {
building = false;
}
};
// Initial build; no client is connected yet, so nothing to notify.
await safeRebuild();
server.middlewares.use((req, res, next) => {
if (req.url?.split("?")[0] !== DATA_APP_BUNDLE_URL) {
next();
return;
}
if (!bundleCode) {
res.statusCode = 503;
res.setHeader("Content-Type", "text/plain");
res.end("data-app bundle is not built — see the dev server logs.");
return;
}
res.setHeader("Content-Type", "text/javascript");
res.end(bundleCode);
});
const srcDir = `${path.sep}src${path.sep}`;
server.watcher.on("change", async (file) => {
if (file.includes(srcDir)) {
await rebuildAndNotify();
}
});
// Serve the synthetic index.html as a POST middleware (after Vite's
// transform/asset middlewares) so it only catches navigation requests —
// the initial load and any deep link / SPA route, not module/asset fetches.
// `transformIndexHtml` injects the HMR client + resolves the entry import.
return () => {
server.middlewares.use(async (req, res, next) => {
if (
req.method !== "GET" ||
!req.headers.accept?.includes("text/html")
) {
next();
return;
}
try {
const html = await server.transformIndexHtml(
req.url ?? "/",
INDEX_HTML,
);
res.statusCode = 200;
// Apply the configured `server.headers` (our CSP) to this document.
const configuredHeaders = server.config.server.headers;
if (configuredHeaders) {
for (const [name, value] of Object.entries(configuredHeaders)) {
if (value != null) {
res.setHeader(name, value);
}
}
}
res.setHeader("Content-Type", "text/html");
res.end(html);
} catch (error) {
next(error);
}
});
};
},
};
}

View File

@@ -0,0 +1,290 @@
import fs from "node:fs";
import path from "node:path";
import { build } from "vite";
import {
DATA_APP_DEV_CONFIG_VIRTUAL_ID,
DATA_APP_DEV_ENTRY_VIRTUAL_ID,
} from "build-configs/embedding-sdk/constants/data-app-virtual-modules";
import { DATA_APP_BUNDLE_URL, DATA_APP_REBUILT_EVENT } from "../bundle";
import { dataAppSandboxDevPlugin } from "./plugin";
jest.mock("vite", () => ({ build: jest.fn() }));
// Mock at the app's own config boundary rather than the individual Vite plugins
// it pulls in (`vite-plugin-css-injected-by-js`, `vite-plugin-svgr`): `build`
// is stubbed out anyway, so the plugin never uses these values — the test only
// needs `build-config` not to import those ESM-only packages at load time.
jest.mock("../config/build-config", () => ({
dataAppBuildPlugins: () => [],
dataAppLibBuild: () => ({}),
}));
jest.mock("node:fs");
const mockedBuild = jest.mocked(build);
const mockedFs = jest.mocked(fs);
const RESOLVED = (id: string) => `\0${id}`;
type FakeServer = {
config: {
root: string;
mode: string;
logger: { error: jest.Mock };
server: { headers: Record<string, string | null> };
};
ws: { send: jest.Mock };
middlewares: { use: jest.Mock };
watcher: { on: jest.Mock };
transformIndexHtml: jest.Mock;
};
type TestPlugin = {
name: string;
apply: string;
resolveId: (id: string) => string | undefined;
load: (id: string) => string | undefined;
configureServer: (server: FakeServer) => Promise<() => void>;
};
const APP_SLUG = "test-app";
function makePlugin(appSlug: string, allowedHosts: string[] = []): TestPlugin {
// Vite declares every hook on `Plugin` as an `ObjectHook` union (a function or
// a `{ handler }` object) bound to Rollup's plugin context. The tests call the
// hooks we return as plain functions, which no assignable type expresses.
return dataAppSandboxDevPlugin(
appSlug,
allowedHosts,
) as unknown as TestPlugin;
}
describe("dataAppSandboxDevPlugin", () => {
afterEach(() => jest.clearAllMocks());
it("only applies to the dev server", () => {
const plugin = makePlugin(APP_SLUG);
expect(plugin.name).toBe("metabase-data-app-dev");
expect(plugin.apply).toBe("serve");
});
describe("virtual modules", () => {
const setup = (allowedHosts: string[] = []) => {
return { plugin: makePlugin(APP_SLUG, allowedHosts) };
};
it("resolves the dev-entry and config virtual ids to synthetic ids", () => {
const { plugin } = setup();
expect(plugin.resolveId(DATA_APP_DEV_ENTRY_VIRTUAL_ID)).toBe(
RESOLVED(DATA_APP_DEV_ENTRY_VIRTUAL_ID),
);
expect(plugin.resolveId(DATA_APP_DEV_CONFIG_VIRTUAL_ID)).toBe(
RESOLVED(DATA_APP_DEV_CONFIG_VIRTUAL_ID),
);
});
it("ignores unrelated ids", () => {
expect(setup().plugin.resolveId("react")).toBeUndefined();
});
it("serves the prebuilt dev entry source for the entry virtual id", () => {
mockedFs.readFileSync.mockReturnValue("DEV_ENTRY_SOURCE");
const { plugin } = setup();
expect(plugin.load(RESOLVED(DATA_APP_DEV_ENTRY_VIRTUAL_ID))).toBe(
"DEV_ENTRY_SOURCE",
);
expect(mockedFs.readFileSync).toHaveBeenCalledWith(
expect.stringContaining("data-app-dev-entry.js"),
"utf8",
);
});
it("generates the config module with the app's allowed hosts and bundle constants", () => {
const { plugin } = setup(["https://api.example.com"]);
const source = plugin.load(RESOLVED(DATA_APP_DEV_CONFIG_VIRTUAL_ID));
expect(source).toContain(
`export const allowedHosts = ${JSON.stringify(["https://api.example.com"])};`,
);
expect(source).toContain(`export const appSlug = "test-app";`);
expect(source).toContain(
`export const bundleUrl = ${JSON.stringify(DATA_APP_BUNDLE_URL)};`,
);
expect(source).toContain(
`export const rebuiltEvent = ${JSON.stringify(DATA_APP_REBUILT_EVENT)};`,
);
});
it("does not load unrelated ids", () => {
expect(setup().plugin.load("\0other")).toBeUndefined();
});
});
describe("dev server wiring", () => {
const DEV_CSP = "connect-src 'self'; form-action 'none'; frame-src 'self'";
function makeServer(): FakeServer {
return {
config: {
root: "/app",
mode: "development",
logger: { error: jest.fn() },
server: { headers: { "Content-Security-Policy": DEV_CSP } },
},
ws: { send: jest.fn() },
middlewares: { use: jest.fn() },
watcher: { on: jest.fn() },
transformIndexHtml: jest.fn(async (_url: string, html: string) => html),
};
}
async function setup(bundleCode = "BUNDLE_CODE") {
// Rollup's `OutputChunk` declares ~20 required fields; the plugin only
// reads `type` and `code`, so the stub deliberately omits the rest.
mockedBuild.mockResolvedValue({
output: [{ type: "chunk", code: bundleCode }],
} as unknown as Awaited<ReturnType<typeof build>>);
const server = makeServer();
// `configureServer` returns a hook Vite runs after its own middlewares;
// call it so the document (index.html) middleware is registered too.
const registerLateMiddleware =
await makePlugin(APP_SLUG).configureServer(server);
registerLateMiddleware();
return { server };
}
it("builds the bundle on startup and serves it at the bundle URL", async () => {
const { server } = await setup("BUNDLE_CODE");
expect(mockedBuild).toHaveBeenCalledTimes(1);
const bundleMiddleware = server.middlewares.use.mock.calls[0][0];
const res = { statusCode: 0, setHeader: jest.fn(), end: jest.fn() };
const next = jest.fn();
bundleMiddleware({ url: DATA_APP_BUNDLE_URL }, res, next);
expect(res.setHeader).toHaveBeenCalledWith(
"Content-Type",
"text/javascript",
);
expect(res.end).toHaveBeenCalledWith("BUNDLE_CODE");
expect(next).not.toHaveBeenCalled();
});
it("passes non-bundle requests through to the next middleware", async () => {
const { server } = await setup();
const bundleMiddleware = server.middlewares.use.mock.calls[0][0];
const next = jest.fn();
bundleMiddleware(
{ url: "/some/asset.js" },
{ setHeader: jest.fn(), end: jest.fn() },
next,
);
expect(next).toHaveBeenCalledTimes(1);
});
it("rebuilds and notifies the client when a src file changes", async () => {
const { server } = await setup();
expect(mockedBuild).toHaveBeenCalledTimes(1);
const [event, onChange] = server.watcher.on.mock.calls[0];
expect(event).toBe("change");
await onChange(`/app${path.sep}src${path.sep}index.tsx`);
expect(mockedBuild).toHaveBeenCalledTimes(2);
expect(server.ws.send).toHaveBeenCalledWith({
type: "custom",
event: DATA_APP_REBUILT_EVENT,
});
});
it("ignores changes outside src/", async () => {
const { server } = await setup();
const onChange = server.watcher.on.mock.calls[0][1];
await onChange("/app/README.md");
expect(mockedBuild).toHaveBeenCalledTimes(1);
expect(server.ws.send).not.toHaveBeenCalled();
});
describe("synthetic index.html document", () => {
// The document middleware is the second one registered: the bundle
// middleware from `configureServer` (index 0), then this one from the
// returned late hook (index 1).
const getDocumentMiddleware = (server: FakeServer) =>
server.middlewares.use.mock.calls[1][0];
it("serves the transformed shell with the configured CSP for navigations", async () => {
const { server } = await setup();
const res = { statusCode: 0, setHeader: jest.fn(), end: jest.fn() };
const next = jest.fn();
await getDocumentMiddleware(server)(
{ method: "GET", headers: { accept: "text/html" }, url: "/" },
res,
next,
);
// The shell HTML goes through Vite's transform (HMR client, etc.).
expect(server.transformIndexHtml).toHaveBeenCalledWith(
"/",
expect.stringContaining('<div id="root">'),
);
// The dev CSP configured on the server is copied onto the document.
expect(res.setHeader).toHaveBeenCalledWith(
"Content-Security-Policy",
DEV_CSP,
);
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/html");
expect(res.end).toHaveBeenCalledWith(
expect.stringContaining("<!doctype html>"),
);
expect(next).not.toHaveBeenCalled();
});
it("leaves non-HTML requests for the next middleware", async () => {
const { server } = await setup();
const next = jest.fn();
await getDocumentMiddleware(server)(
{
method: "GET",
headers: { accept: "application/javascript" },
url: "/some/asset.js",
},
{ setHeader: jest.fn(), end: jest.fn() },
next,
);
expect(server.transformIndexHtml).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
it("forwards transformIndexHtml errors to the next middleware", async () => {
const { server } = await setup();
const error = new Error("transform failed");
server.transformIndexHtml.mockRejectedValueOnce(error);
const next = jest.fn();
await getDocumentMiddleware(server)(
{ method: "GET", headers: { accept: "text/html" }, url: "/" },
{ statusCode: 0, setHeader: jest.fn(), end: jest.fn() },
next,
);
expect(next).toHaveBeenCalledWith(error);
});
});
});
});

View File

@@ -0,0 +1,112 @@
import path from "node:path";
import react from "@vitejs/plugin-react";
import {
type ConfigEnv,
type PluginOption,
type UserConfig,
loadEnv,
} from "vite";
import { dataAppBuildPlugins, dataAppLibBuild } from "./config/build-config";
import { getDataAppDefine } from "./config/define";
import { buildDevCsp } from "./config/dev-connect-src";
import { dataAppEnvPrefix } from "./config/env-prefix";
import { findEnvRoot } from "./config/find-env-root";
import { readManifest } from "./config/read-manifest";
import { dataAppSandboxDevPlugin } from "./dev-plugin/plugin";
/**
* The contract plugin array used by {@link dataAppConfig}. It bundles the React
* plugin, inlines imported CSS, adds SVG-as-component support, runs `npm run dev`
* through the real Near-Membrane sandbox (so dev behaves like production), serves
* the dev entry, and applies the bundle contract — the IIFE entry/format + the
* React/SDK externals/globals — through a `config` hook that Vite merges *over*
* the user's config, so it can't be accidentally overridden.
*
* Internal: apps configure through {@link dataAppConfig}, not this directly.
*/
function dataAppVitePlugin(): PluginOption[] {
const appRoot = process.cwd();
const envDir = findEnvRoot(appRoot);
const manifestResult = readManifest(appRoot);
if (!manifestResult) {
throw new Error(
`No data_app.yaml found in ${appRoot}. Run vite from the data app's own ` +
`directory (data_apps/<slug>/).`,
);
}
const appSlug = path.basename(appRoot);
const allowedHosts = manifestResult.manifest.allowed_hosts ?? [];
return [
react(),
...dataAppBuildPlugins(),
dataAppSandboxDevPlugin(appSlug, allowedHosts),
{
name: "metabase-data-app",
// Merged over the user's config (`mergeConfig(userConfig, this)`), so these
// win — the bundle Metabase loads always matches what dev runs. `loadEnv`
// needs the mode, which is only known here in the `config` hook.
config: (_config: UserConfig, env: ConfigEnv): UserConfig => ({
define: getDataAppDefine(env.mode),
envDir,
// Dev preview only — keeps `.env.local` secrets out of prod builds. See
// `dataAppEnvPrefix`.
envPrefix: dataAppEnvPrefix(env.command),
// The dev plugin serves a synthetic index.html, so there's no file on
// disk for Vite's default HTML/SPA middleware to find.
appType: "custom",
build: {
outDir: "dist",
emptyOutDir: true,
...dataAppLibBuild("index.js"),
},
server: {
host: "localhost",
headers: {
"Content-Security-Policy": buildDevCsp(
allowedHosts,
loadEnv(env.mode, envDir, "").DATA_APP_MB_URL,
),
},
},
}),
},
];
}
/** Default dev server port; override via `dataAppConfig({ port })`. */
const DEFAULT_DEV_PORT = 5174;
export interface DataAppConfigOverrides {
/** Dev server port. Defaults to 5174. */
port?: number;
}
/**
* The complete Vite config for a Metabase data app — the only thing a template
* `vite.config.ts` needs:
*
* ```ts
* import { dataAppConfig } from "@metabase/embedding-sdk-react/data-app-dev/config";
*
* export default dataAppConfig();
* ```
*
* `overrides` exposes only a curated set of knobs (currently just `port`, which
* defaults to 5174). The contract plugin that enforces the bundle format +
* sandbox is always applied and is intentionally NOT overridable, so a data app
* can't drift from what Metabase loads.
*/
export function dataAppConfig({
port = DEFAULT_DEV_PORT,
}: DataAppConfigOverrides = {}): UserConfig {
return {
plugins: [dataAppVitePlugin()],
server: { port },
};
}

View File

@@ -0,0 +1,53 @@
import { readManifest } from "./config/read-manifest";
import { dataAppConfig } from "./index";
jest.mock("vite", () => ({ loadEnv: jest.fn(() => ({})) }));
jest.mock("@vitejs/plugin-react", () => ({
__esModule: true,
default: () => ({ name: "mock-react" }),
}));
jest.mock("./config/build-config", () => ({
dataAppBuildPlugins: () => [],
dataAppLibBuild: () => ({}),
}));
jest.mock("./config/find-env-root", () => ({
findEnvRoot: (appRoot: string) => appRoot,
}));
jest.mock("./dev-plugin/plugin", () => ({
dataAppSandboxDevPlugin: () => ({ name: "mock-sandbox-dev" }),
}));
jest.mock("./config/read-manifest");
const mockedReadManifest = jest.mocked(readManifest);
afterEach(() => jest.resetAllMocks());
describe("dataAppConfig", () => {
it("throws when the cwd has no data_app.yaml (run from the wrong directory)", () => {
mockedReadManifest.mockReturnValue(null);
expect(() => dataAppConfig()).toThrow(/No data_app\.yaml found/);
});
it("builds the config when a manifest is present", () => {
mockedReadManifest.mockReturnValue({
manifestPath: "/app/data_app.yaml",
manifest: { allowed_hosts: [] },
});
const config = dataAppConfig();
expect(config.server?.port).toBe(5174);
expect(config.plugins).toBeDefined();
});
it("uses the port override", () => {
mockedReadManifest.mockReturnValue({
manifestPath: "/app/data_app.yaml",
manifest: {},
});
expect(dataAppConfig({ port: 4000 }).server?.port).toBe(4000);
});
});

View File

@@ -0,0 +1,24 @@
// Ambient types for `dev-entry.tsx` (bundled by rspack, but type-checked by the
// main program). The dev app's env vars + Vite's `import.meta.env`/`.hot` and the
// dev plugin's config virtual module are declared here so `dev-entry.tsx` checks
// against real shapes. No other code declares `ImportMeta`, so this is the sole
// (global) declaration; the interfaces merge with the built-in `ImportMeta.url`.
interface ImportMetaEnv {
readonly DATA_APP_MB_URL: string;
readonly DATA_APP_MB_API_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
readonly hot?: {
on(event: string, cb: (...args: unknown[]) => void): void;
};
}
declare module "virtual:metabase-data-app-dev-config" {
export const allowedHosts: string[];
export const appSlug: string;
export const bundleUrl: string;
export const rebuiltEvent: string;
}

View File

@@ -0,0 +1,42 @@
// eslint-disable-next-line metabase/no-external-references-for-sdk-package-code
import { EMBEDDING_SDK_CONFIG } from "metabase/embedding-sdk/config";
// eslint-disable-next-line metabase/no-external-references-for-sdk-package-code
import { defineBuildInfo } from "metabase/embedding-sdk/lib/define-build-info";
// eslint-disable-next-line metabase/no-external-references-for-sdk-package-code
import { defineGlobalDependencies } from "metabase/embedding-sdk/lib/define-global-dependencies";
EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
defineBuildInfo("METABASE_EMBEDDING_SDK_PACKAGE_BUILD_INFO");
defineGlobalDependencies();
export { DataAppRouter } from "./components/public/DataAppRouter";
export { DataAppLink } from "./components/public/DataAppLink";
export { copy } from "./lib/public/copy";
export { useDataAppLocation } from "./hooks/public/use-data-app-location";
export {
aggregations,
breakout,
filter,
orderBy,
useMetabaseQuery,
useMetabaseQueryObject,
} from "./hooks/public/use-metabase-query";
export type {
LocalFieldReference,
MetabaseBreakout,
MetabaseOrderBy,
MetabaseQueryOptions,
MetabaseQueryObject,
OrderByDirection,
UseMetabaseQueryObjectResult,
UseMetabaseQueryResult,
} from "./hooks/public/use-metabase-query";
export type {
ActionKindFromDataAppSchema,
ActionParametersFromDataAppSchema,
} from "./hooks/public/use-action";
export type {
DataAppFactory,
DataAppMetabaseProviderProps,
} from "metabase-enterprise/data_apps/sandbox/types";

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "<projectFolder>/resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/data-app.d.ts",
"bundledPackages": [],
"apiReport": {
"enabled": false
},
"docModel": {
"enabled": false
},
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/resources/embedding-sdk/dist/data-app.d.ts"
},
"tsdocMetadata": {
"enabled": false
},
"messages": {
"extractorMessageReporting": {
"ae-wrong-input-file-type": {
"logLevel": "none"
}
}
}
}

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "<projectFolder>/resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/data-app-dev.d.ts",
"bundledPackages": [],
"apiReport": {
"enabled": false
},
"docModel": {
"enabled": false
},
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/resources/embedding-sdk/dist/data-app-dev.d.ts"
},
"tsdocMetadata": {
"enabled": false
},
"messages": {
"extractorMessageReporting": {
"ae-wrong-input-file-type": {
"logLevel": "none"
}
}
}
}

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "<projectFolder>/resources/embedding-sdk/dist/enterprise/frontend/src/embedding-sdk-package/data-app-dev.config.d.ts",
"bundledPackages": [],
"apiReport": {
"enabled": false
},
"docModel": {
"enabled": false
},
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/resources/embedding-sdk/dist/data-app-dev.config.d.ts"
},
"tsdocMetadata": {
"enabled": false
},
"messages": {
"extractorMessageReporting": {
"ae-wrong-input-file-type": {
"logLevel": "none"
}
}
}
}

View File

@@ -4,6 +4,10 @@ import { useEffect } from "react";
import { SDK_BUNDLE_FULL_PATH } from "build-configs/embedding-sdk/constants/sdk-bundle"; import { SDK_BUNDLE_FULL_PATH } from "build-configs/embedding-sdk/constants/sdk-bundle";
import { SDK_BUNDLE_SCRIPT_DATA_ATTRIBUTE_PASCAL_CASED } from "embedding-sdk-package/constants/sdk-bundle-script-data-attribute-name"; import { SDK_BUNDLE_SCRIPT_DATA_ATTRIBUTE_PASCAL_CASED } from "embedding-sdk-package/constants/sdk-bundle-script-data-attribute-name";
import { getSdkBundleScriptElement } from "embedding-sdk-package/lib/private/get-sdk-bundle-script-element"; import { getSdkBundleScriptElement } from "embedding-sdk-package/lib/private/get-sdk-bundle-script-element";
import {
SDK_BUNDLE_ERROR,
SDK_BUNDLE_LOADED,
} from "embedding-sdk-shared/constants/event-names";
import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store"; import { ensureMetabaseProviderPropsStore } from "embedding-sdk-shared/lib/ensure-metabase-provider-props-store";
import { import {
SdkLoadingError, SdkLoadingError,
@@ -21,7 +25,7 @@ const SDK_PACKAGE_VERSION = process.env.VERSION || "unknown";
* *
* - On script `load`: if `window.METABASE_EMBEDDING_SDK_BUNDLE` is already * - On script `load`: if `window.METABASE_EMBEDDING_SDK_BUNDLE` is already
* set, the monolithic bundle ran synchronously → resolve. * set, the monolithic bundle ran synchronously → resolve.
* - On `"metabase-sdk-bundle-loaded"` CustomEvent: the bootstrap loaded all * - On `SDK_BUNDLE_LOADED` CustomEvent: the bootstrap loaded all
* chunks and `index.ts` executed → resolve. * chunks and `index.ts` executed → resolve.
* - First signal wins; all listeners are cleaned up after settling. * - First signal wins; all listeners are cleaned up after settling.
*/ */
@@ -62,14 +66,14 @@ const waitForScriptLoading = (script: HTMLScriptElement) => {
}; };
const cleanup = () => { const cleanup = () => {
document.removeEventListener("metabase-sdk-bundle-loaded", onBundleEvent); document.removeEventListener(SDK_BUNDLE_LOADED, onBundleEvent);
document.removeEventListener("metabase-sdk-bundle-error", onBundleError); document.removeEventListener(SDK_BUNDLE_ERROR, onBundleError);
script.removeEventListener("load", onScriptLoad); script.removeEventListener("load", onScriptLoad);
script.removeEventListener("error", onScriptError); script.removeEventListener("error", onScriptError);
}; };
document.addEventListener("metabase-sdk-bundle-loaded", onBundleEvent); document.addEventListener(SDK_BUNDLE_LOADED, onBundleEvent);
document.addEventListener("metabase-sdk-bundle-error", onBundleError); document.addEventListener(SDK_BUNDLE_ERROR, onBundleError);
script.addEventListener("load", onScriptLoad); script.addEventListener("load", onScriptLoad);
script.addEventListener("error", onScriptError); script.addEventListener("error", onScriptError);
}); });

View File

@@ -0,0 +1,99 @@
import type { QueryQuestionResult } from "embedding-sdk-bundle/lib/query-question";
import type {
FieldSchema,
MeasureSchema,
MetricSchema,
QuestionSchema,
SchemaColumn,
SchemaJavaScriptType,
SegmentSchema,
TableSchema,
} from "embedding-sdk-shared/lib/create-metabase-query/schema";
import type { DatasetColumn, RowValues } from "metabase-types/api";
export type {
FieldSchema,
MeasureSchema,
MetricSchema,
QuestionSchema,
SchemaColumn,
SchemaJavaScriptType,
SegmentSchema,
TableSchema,
};
/**
* A single value returned by Metabase query results or action responses.
*/
export type RowValue = string | number | null | boolean | object;
export type SchemaValue<TColumn extends SchemaColumn> =
TColumn["jsType"] extends "string"
? string | null
: TColumn["jsType"] extends "number"
? number | null
: TColumn["jsType"] extends "boolean"
? boolean | null
: TColumn["jsType"] extends "Date"
? string | Date | null
: RowValue | null;
/** @notExported SchemaValue */
export type SchemaRow<TSchema extends { columns: readonly SchemaColumn[] }> = {
[TColumn in TSchema["columns"][number] as TColumn["name"]]: SchemaValue<TColumn>;
};
/**
* @notExported DatasetColumn
* @notExported RowValues
*/
export type QueryData<TRow> = {
id?: QueryQuestionResult["id"] | null;
name?: QueryQuestionResult["name"] | null;
description?: QueryQuestionResult["description"];
entityId?: QueryQuestionResult["entityId"] | null;
rowCount: number | null;
runningTime: number | null;
columns: DatasetColumn[];
rows: TRow[];
rawRows: RowValues[];
};
export type InferSchema<TSchema, TFallback> = TSchema extends {
columns: readonly SchemaColumn[];
}
? SchemaRow<TSchema>
: TFallback;
export function getSchemaId<TEntity extends { id: unknown }>(
entity: TEntity | TEntity["id"] | null,
): TEntity["id"] | null {
if (entity == null) {
return null;
}
if (typeof entity === "object" && "id" in entity) {
// `TEntity["id"]` is unconstrained, so it may itself be an object with an
// `id`: the guard cannot narrow the `TEntity | TEntity["id"]` union for TS.
return entity.id as TEntity["id"];
}
// Not an entity object, so it is already the id — but TS still cannot rule
// `TEntity` out of the union at the type level.
return entity as TEntity["id"];
}
export function mapRowsToObjects<TRow>(
columns: DatasetColumn[],
rows: RowValues[],
): TRow[] {
return rows.map((row) => {
// Rows are keyed by column names known only at runtime; `TRow` is the
// schema-derived shape the caller expects, so the two can't be tied in TS.
return columns.reduce<Record<string, RowValue>>((acc, column, index) => {
const key = column.name || column.display_name || `column_${index}`;
acc[key] = row[index];
return acc;
}, {}) as TRow;
});
}

View File

@@ -2,12 +2,17 @@ export { useAction } from "./use-action";
export type { UseActionResult } from "./use-action"; export type { UseActionResult } from "./use-action";
export type { export type {
ActionExecuteError, ActionExecuteError,
ActionImplicitKind,
ActionKind, ActionKind,
ActionKindFromDataAppSchema,
ActionParameterSchema,
ActionParametersFromDataAppSchema,
ActionResultForBulk, ActionResultForBulk,
ActionResultForCreate, ActionResultForCreate,
ActionResultForDelete, ActionResultForDelete,
ActionResultForKind, ActionResultForKind,
ActionResultForSql, ActionResultForSql,
ActionResultForUpdate, ActionResultForUpdate,
ActionSchema,
AnyActionResult, AnyActionResult,
} from "./types"; } from "./types";

View File

@@ -1,14 +1,19 @@
/** /**
* Public types for the `useAction` hook surface. * Public types for the `useAction` hook surface.
* *
* The hook is schema-agnostic — these types describe just the action kind * The hook itself is schema-agnostic — the `ActionKind` union and the
* union and the discriminated `result` shape it drives. A future Data App * discriminated `ActionResultForKind` shape it drives describe just the
* integration will layer schema-derived helpers (`ActionParametersFromDataAppSchema`, * kind / response surface. The `…FromDataAppSchema` helpers below layer on
* `ActionKindFromDataAppSchema`) on top; they are NOT shipped from this * top, deriving the hook's `TParameters` / `TKind` generics from a
* branch. * generated `metabase.data.ts` schema entry; the hook never sees the
* schema directly.
*/ */
import type { RowValue } from "metabase-types/api"; import type {
RowValue,
SchemaColumn,
SchemaJavaScriptType,
} from "../data-schema";
/** /**
* Flat public kind union. Maps onto the backend's namespaced * Flat public kind union. Maps onto the backend's namespaced
@@ -128,3 +133,128 @@ export type ActionResultForKind<TKind extends ActionKind | undefined> =
: TKind extends "sql" : TKind extends "sql"
? ActionResultForSql ? ActionResultForSql
: AnyActionResult; : AnyActionResult;
// ============================================================================
// Data App schema-derived helpers
// ----------------------------------------------------------------------------
// These layer on top of the schema-agnostic types above. They read the shape
// of a single action entry in a generated `metabase.data.ts` schema and
// derive the hook's `TParameters` / `TKind` generics, so a Data App author
// writes `useAction<ActionParametersFromDataAppSchema<typeof action>,
// ActionKindFromDataAppSchema<typeof action>>(action.id)` and gets
// compile-time parameter checking plus a typed `result` with no casts.
// Mode-1 (raw SDK) callers don't import these — they declare `TParameters`
// by hand and pass `TKind` as a literal.
// ============================================================================
/**
* Backend-namespaced `implicitKind` values for basic actions. Mapped onto
* the flat `ActionKind` union by `ActionKindFromDataAppSchema`.
*
* @category useAction
*/
export type ActionImplicitKind =
| "row/create"
| "row/update"
| "row/delete"
| "bulk/create"
| "bulk/update"
| "bulk/delete";
/**
* Shape of a single action parameter as it appears in a generated
* `metabase.data.ts` schema entry.
*
* @category useAction
*/
export type ActionParameterSchema = SchemaColumn & {
slug: string;
required?: boolean;
};
/**
* Shape of a single action entry in a generated `metabase.data.ts` schema.
* The `id` is the numeric action id passed to `useAction`; `parameters`
* drives `ActionParametersFromDataAppSchema`; `implicitKind` / `type` drive
* `ActionKindFromDataAppSchema`.
*
* @category useAction
*/
export type ActionSchema = {
kind?: "action";
id: number;
name?: string;
type?: "query" | "implicit";
implicitKind?: ActionImplicitKind;
parameters: readonly ActionParameterSchema[];
description?: string;
entityId?: string;
};
/** TS value type for a single action parameter, derived from its `jsType`. */
type ActionParamValue<TParam extends { jsType?: SchemaJavaScriptType }> =
TParam["jsType"] extends "string"
? string | null
: TParam["jsType"] extends "number"
? number | null
: TParam["jsType"] extends "boolean"
? boolean | null
: TParam["jsType"] extends "Date"
? string | Date | null
: RowValue | null;
/**
* Walks a schema entry's `parameters` tuple and produces an object keyed by
* slug. Parameters with `required: true` become required keys; everything
* else is optional. Feeds the hook's `TParameters` generic so a Data App
* author gets compile-time errors on missing required keys, wrong value
* types, and unknown slugs. Falls back to `Record<string, unknown>` if
* `TAction` is not a schema entry (e.g. a bare numeric id).
*
* @category useAction
* @notExported ActionParamValue
*/
export type ActionParametersFromDataAppSchema<TAction> = TAction extends {
parameters: readonly ActionParameterSchema[];
}
? {
[P in TAction["parameters"][number] as P extends { required: true }
? P["slug"]
: never]: ActionParamValue<P>;
} & {
[P in TAction["parameters"][number] as P extends { required: true }
? never
: P["slug"]]?: ActionParamValue<P>;
}
: Record<string, unknown>;
/**
* Maps a schema entry's `implicitKind` (or `type === "query"`) to the flat
* `ActionKind` union. Feeds the hook's `TKind` generic so `result` is
* typed via `ActionResultForKind<TKind>` with no cast — schema-derived,
* but the value the hook sees is just a string literal.
*
* row/create → "create"
* row/update → "update"
* row/delete → "delete"
* bulk/* → "bulk"
* type=query → "sql"
*
* Falls back to the full `ActionKind` union if `TAction` is not a schema
* entry (e.g. a bare numeric id).
*
* @category useAction
*/
export type ActionKindFromDataAppSchema<TAction> = TAction extends {
implicitKind: "row/create";
}
? "create"
: TAction extends { implicitKind: "row/update" }
? "update"
: TAction extends { implicitKind: "row/delete" }
? "delete"
: TAction extends { implicitKind: `bulk/${string}` }
? "bulk"
: TAction extends { type: "query" }
? "sql"
: ActionKind;

View File

@@ -0,0 +1,4 @@
export {
useDataAppLocation,
type UseDataAppLocationResult,
} from "./use-data-app-location";

View File

@@ -0,0 +1,75 @@
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import { SDK_BUNDLE_LOADED } from "embedding-sdk-shared/constants/event-names";
import { getWindow } from "embedding-sdk-shared/lib/get-window";
export type UseDataAppLocationResult = {
pathname: string;
navigate: (to: string) => void;
};
const computeSubPath = (basename: string): string => {
if (typeof window === "undefined") {
return "/";
}
const pathname = window.location.pathname;
const subPath =
basename && pathname.startsWith(basename)
? pathname.slice(basename.length)
: pathname;
return subPath || "/";
};
const useDataAppRouting = () =>
useSyncExternalStore(
(notify) => {
const target = typeof document !== "undefined" ? document : null;
const handler = () => notify();
target?.addEventListener(SDK_BUNDLE_LOADED, handler);
return () => {
target?.removeEventListener(SDK_BUNDLE_LOADED, handler);
};
},
() => getWindow()?.METABASE_EMBEDDING_SDK_BUNDLE?.dataAppRouting,
() => undefined,
);
export const useDataAppLocation = (): UseDataAppLocationResult => {
const dataAppRouting = useDataAppRouting();
const basename = useMemo(
() => dataAppRouting?.getBasename() ?? "",
[dataAppRouting],
);
const [pathname, setPathname] = useState(() => computeSubPath(basename));
useEffect(() => {
if (!dataAppRouting) {
return;
}
setPathname(computeSubPath(dataAppRouting.getBasename()));
return dataAppRouting.subscribe(() => {
setPathname(computeSubPath(dataAppRouting.getBasename()));
});
}, [dataAppRouting]);
const navigate = useCallback(
(to: string) => {
dataAppRouting?.navigate(to);
},
[dataAppRouting],
);
return { pathname, navigate };
};

Some files were not shown because too many files have changed in this diff Show More