@greensecurity/javascript-sdk 0.46.0-beta.10 → 0.46.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/__tests__/apps/contract.test.js +288 -4
- package/dist/commonjs/__tests__/apps/contract.test.js.map +1 -1
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.js +86 -0
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/example.test.js +84 -4
- package/dist/commonjs/__tests__/apps/kit/example.test.js.map +1 -1
- package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/pages.test.js +119 -0
- package/dist/commonjs/__tests__/apps/kit/pages.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/render.test.js +24 -0
- package/dist/commonjs/__tests__/apps/kit/render.test.js.map +1 -1
- package/dist/commonjs/__tests__/apps/kit/store.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/kit/store.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/store.test.js +123 -0
- package/dist/commonjs/__tests__/apps/kit/store.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.js +110 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/routes.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/routes.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/routes.test.js +238 -0
- package/dist/commonjs/__tests__/apps/routes.test.js.map +1 -0
- package/dist/commonjs/__tests__/vendors.test.js +1 -1
- package/dist/commonjs/__tests__/webhooks.test.js +1 -1
- package/dist/commonjs/__tests__/webhooks.test.js.map +1 -1
- package/dist/commonjs/__tests__/zones.test.js +2 -9
- package/dist/commonjs/__tests__/zones.test.js.map +1 -1
- package/dist/commonjs/apps/components.d.ts +321 -0
- package/dist/commonjs/apps/components.d.ts.map +1 -1
- package/dist/commonjs/apps/components.js +168 -2
- package/dist/commonjs/apps/components.js.map +1 -1
- package/dist/commonjs/apps/context.d.ts +24 -0
- package/dist/commonjs/apps/context.d.ts.map +1 -1
- package/dist/commonjs/apps/context.js.map +1 -1
- package/dist/commonjs/apps/devpreview.d.ts +20 -3
- package/dist/commonjs/apps/devpreview.d.ts.map +1 -1
- package/dist/commonjs/apps/devpreview.js +18 -7
- package/dist/commonjs/apps/devpreview.js.map +1 -1
- package/dist/commonjs/apps/hooks/index.d.ts +33 -2
- package/dist/commonjs/apps/hooks/index.d.ts.map +1 -1
- package/dist/commonjs/apps/hooks/index.js +41 -1
- package/dist/commonjs/apps/hooks/index.js.map +1 -1
- package/dist/commonjs/apps/hooks/store.d.ts +48 -0
- package/dist/commonjs/apps/hooks/store.d.ts.map +1 -0
- package/dist/commonjs/apps/hooks/store.js +87 -0
- package/dist/commonjs/apps/hooks/store.js.map +1 -0
- package/dist/commonjs/apps/manifest.d.ts +64 -3
- package/dist/commonjs/apps/manifest.d.ts.map +1 -1
- package/dist/commonjs/apps/manifest.js +128 -27
- package/dist/commonjs/apps/manifest.js.map +1 -1
- package/dist/commonjs/apps/protocol.d.ts +1 -1
- package/dist/commonjs/apps/protocol.d.ts.map +1 -1
- package/dist/commonjs/apps/route.d.ts +11 -1
- package/dist/commonjs/apps/route.d.ts.map +1 -1
- package/dist/commonjs/apps/route.js +13 -7
- package/dist/commonjs/apps/route.js.map +1 -1
- package/dist/commonjs/apps/routes.d.ts +65 -0
- package/dist/commonjs/apps/routes.d.ts.map +1 -0
- package/dist/commonjs/apps/routes.js +202 -0
- package/dist/commonjs/apps/routes.js.map +1 -0
- package/dist/commonjs/apps/surfaces.d.ts +20 -2
- package/dist/commonjs/apps/surfaces.d.ts.map +1 -1
- package/dist/commonjs/apps/surfaces.js +45 -7
- package/dist/commonjs/apps/surfaces.js.map +1 -1
- package/dist/commonjs/apps/testing/index.d.ts +17 -1
- package/dist/commonjs/apps/testing/index.d.ts.map +1 -1
- package/dist/commonjs/apps/testing/index.js +50 -1
- package/dist/commonjs/apps/testing/index.js.map +1 -1
- package/dist/commonjs/apps/ui/index.d.ts +13 -0
- package/dist/commonjs/apps/ui/index.d.ts.map +1 -1
- package/dist/commonjs/apps/ui/index.js +19 -2
- package/dist/commonjs/apps/ui/index.js.map +1 -1
- package/dist/commonjs/lib/config.d.ts +2 -2
- package/dist/commonjs/lib/config.js +2 -2
- package/dist/esm/__tests__/apps/contract.test.js +289 -5
- package/dist/esm/__tests__/apps/contract.test.js.map +1 -1
- package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/devpreview-routes.test.js +84 -0
- package/dist/esm/__tests__/apps/devpreview-routes.test.js.map +1 -0
- package/dist/esm/__tests__/apps/kit/example.test.js +85 -5
- package/dist/esm/__tests__/apps/kit/example.test.js.map +1 -1
- package/dist/esm/__tests__/apps/kit/pages.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/kit/pages.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/kit/pages.test.js +117 -0
- package/dist/esm/__tests__/apps/kit/pages.test.js.map +1 -0
- package/dist/esm/__tests__/apps/kit/render.test.js +25 -1
- package/dist/esm/__tests__/apps/kit/render.test.js.map +1 -1
- package/dist/esm/__tests__/apps/kit/store.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/kit/store.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/kit/store.test.js +121 -0
- package/dist/esm/__tests__/apps/kit/store.test.js.map +1 -0
- package/dist/esm/__tests__/apps/post-install-page.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/post-install-page.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/post-install-page.test.js +108 -0
- package/dist/esm/__tests__/apps/post-install-page.test.js.map +1 -0
- package/dist/esm/__tests__/apps/routes.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/routes.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/routes.test.js +236 -0
- package/dist/esm/__tests__/apps/routes.test.js.map +1 -0
- package/dist/esm/__tests__/vendors.test.js +1 -1
- package/dist/esm/__tests__/webhooks.test.js +1 -1
- package/dist/esm/__tests__/webhooks.test.js.map +1 -1
- package/dist/esm/__tests__/zones.test.js +2 -9
- package/dist/esm/__tests__/zones.test.js.map +1 -1
- package/dist/esm/apps/components.d.ts +321 -0
- package/dist/esm/apps/components.d.ts.map +1 -1
- package/dist/esm/apps/components.js +163 -1
- package/dist/esm/apps/components.js.map +1 -1
- package/dist/esm/apps/context.d.ts +24 -0
- package/dist/esm/apps/context.d.ts.map +1 -1
- package/dist/esm/apps/context.js.map +1 -1
- package/dist/esm/apps/devpreview.d.ts +20 -3
- package/dist/esm/apps/devpreview.d.ts.map +1 -1
- package/dist/esm/apps/devpreview.js +19 -8
- package/dist/esm/apps/devpreview.js.map +1 -1
- package/dist/esm/apps/hooks/index.d.ts +33 -2
- package/dist/esm/apps/hooks/index.d.ts.map +1 -1
- package/dist/esm/apps/hooks/index.js +37 -1
- package/dist/esm/apps/hooks/index.js.map +1 -1
- package/dist/esm/apps/hooks/store.d.ts +48 -0
- package/dist/esm/apps/hooks/store.d.ts.map +1 -0
- package/dist/esm/apps/hooks/store.js +84 -0
- package/dist/esm/apps/hooks/store.js.map +1 -0
- package/dist/esm/apps/manifest.d.ts +64 -3
- package/dist/esm/apps/manifest.d.ts.map +1 -1
- package/dist/esm/apps/manifest.js +116 -26
- package/dist/esm/apps/manifest.js.map +1 -1
- package/dist/esm/apps/protocol.d.ts +1 -1
- package/dist/esm/apps/protocol.d.ts.map +1 -1
- package/dist/esm/apps/route.d.ts +11 -1
- package/dist/esm/apps/route.d.ts.map +1 -1
- package/dist/esm/apps/route.js +14 -8
- package/dist/esm/apps/route.js.map +1 -1
- package/dist/esm/apps/routes.d.ts +65 -0
- package/dist/esm/apps/routes.d.ts.map +1 -0
- package/dist/esm/apps/routes.js +194 -0
- package/dist/esm/apps/routes.js.map +1 -0
- package/dist/esm/apps/surfaces.d.ts +20 -2
- package/dist/esm/apps/surfaces.d.ts.map +1 -1
- package/dist/esm/apps/surfaces.js +39 -2
- package/dist/esm/apps/surfaces.js.map +1 -1
- package/dist/esm/apps/testing/index.d.ts +17 -1
- package/dist/esm/apps/testing/index.d.ts.map +1 -1
- package/dist/esm/apps/testing/index.js +49 -1
- package/dist/esm/apps/testing/index.js.map +1 -1
- package/dist/esm/apps/ui/index.d.ts +13 -0
- package/dist/esm/apps/ui/index.d.ts.map +1 -1
- package/dist/esm/apps/ui/index.js +17 -1
- package/dist/esm/apps/ui/index.js.map +1 -1
- package/dist/esm/lib/config.d.ts +2 -2
- package/dist/esm/lib/config.js +2 -2
- package/examples/apps/hello-green/src/store.ts +15 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/apps/contract.test.ts +305 -4
- package/src/__tests__/apps/devpreview-routes.test.ts +103 -0
- package/src/__tests__/apps/kit/example.test.ts +94 -5
- package/src/__tests__/apps/kit/pages.test.ts +137 -0
- package/src/__tests__/apps/kit/render.test.ts +35 -1
- package/src/__tests__/apps/kit/store.test.ts +149 -0
- package/src/__tests__/apps/post-install-page.test.ts +126 -0
- package/src/__tests__/apps/routes.test.ts +318 -0
- package/src/__tests__/vendors.test.ts +1 -1
- package/src/__tests__/webhooks.test.ts +1 -1
- package/src/__tests__/zones.test.ts +2 -9
- package/src/apps/components.ts +183 -1
- package/src/apps/context.ts +25 -0
- package/src/apps/devpreview.ts +20 -7
- package/src/apps/hooks/index.ts +42 -2
- package/src/apps/hooks/store.ts +115 -0
- package/src/apps/manifest.ts +151 -27
- package/src/apps/protocol.ts +1 -1
- package/src/apps/route.ts +22 -10
- package/src/apps/routes.ts +230 -0
- package/src/apps/surfaces.ts +45 -12
- package/src/apps/testing/index.ts +48 -2
- package/src/apps/ui/index.ts +18 -1
- package/src/lib/config.ts +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Hand-written. Keeps examples/apps/hello-green (the C15 template) valid against the C0
|
|
3
|
-
* manifest contract. Building it and printing its SRI is `npm run build` in that folder.
|
|
3
|
+
* manifest contract, and renders its settings view and its pages. Building it and printing its SRI is `npm run build` in that folder.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
@@ -9,7 +9,7 @@ import { join } from "node:path";
|
|
|
9
9
|
import { describe, expect, test, vi } from "vitest";
|
|
10
10
|
import { createElement as h, type ComponentType } from "react";
|
|
11
11
|
import { validateManifest } from "../../../apps/manifest.js";
|
|
12
|
-
import { renderForTest } from "../../../apps/testing/index.js";
|
|
12
|
+
import { mockPageRoute, renderForTest } from "../../../apps/testing/index.js";
|
|
13
13
|
|
|
14
14
|
// The example imports the SDK by package name, which resolves to the built `dist/` copy. Point
|
|
15
15
|
// those imports at the sources under test so the example and this harness share one runtime.
|
|
@@ -105,14 +105,103 @@ describe("examples/apps/hello-green", () => {
|
|
|
105
105
|
60_000,
|
|
106
106
|
);
|
|
107
107
|
|
|
108
|
-
test("every manifest view is registered with defineApp", () => {
|
|
108
|
+
test("every manifest view and page is registered with defineApp", () => {
|
|
109
109
|
const result = validateManifest(manifest);
|
|
110
110
|
if (!result.ok) throw new Error("invalid manifest");
|
|
111
111
|
const entry = readFileSync(join(EXAMPLE_DIR, "src", "index.tsx"), "utf8");
|
|
112
112
|
const views = /defineApp\(\{\s*views:\s*\{([^}]*)\}/.exec(entry)?.[1] ?? "";
|
|
113
113
|
const registered = views.split(",").map((v) => v.trim()).filter(Boolean);
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
const components = [
|
|
115
|
+
...(result.manifest.ui_extensions?.views ?? []).map((v) => v.component),
|
|
116
|
+
...(result.manifest.ui_extensions?.routes ?? []).map((r) => r.component),
|
|
117
|
+
];
|
|
118
|
+
expect(components).toEqual(expect.arrayContaining(["Reports", "ReportDetail"]));
|
|
119
|
+
for (const component of components) {
|
|
120
|
+
expect(registered).toContain(component);
|
|
116
121
|
}
|
|
117
122
|
});
|
|
123
|
+
|
|
124
|
+
test("it declares a listed reports page and a detail page", () => {
|
|
125
|
+
const result = validateManifest(manifest);
|
|
126
|
+
if (!result.ok) throw new Error("invalid manifest");
|
|
127
|
+
expect(result.manifest.ui_extensions?.routes).toEqual([
|
|
128
|
+
{ path: "/reports", component: "Reports", title: "Reports", navigation: { label: "Hello reports" } },
|
|
129
|
+
{ path: "/reports/:id", component: "ReportDetail", title: "Report" },
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("hello-green pages", () => {
|
|
135
|
+
// Through a variable, for the same reason as AppSettings above.
|
|
136
|
+
const pagesModule = (name: string) => `../../../../examples/apps/hello-green/src/${name}.js`;
|
|
137
|
+
|
|
138
|
+
test("Reports links each row to its /reports/:id page by concrete path", async () => {
|
|
139
|
+
const { Reports } = (await import(/* @vite-ignore */ pagesModule("Reports"))) as { Reports: ComponentType };
|
|
140
|
+
const view = await renderForTest(h(Reports), {
|
|
141
|
+
context: { environment: { route: mockPageRoute("/reports") } },
|
|
142
|
+
});
|
|
143
|
+
expect(view.root.component).toBe("PageView");
|
|
144
|
+
expect(view.root.props["title"]).toBe("Reports");
|
|
145
|
+
await view.find("List")!.trigger("onAction", "8");
|
|
146
|
+
await view.root.slotted("actions")[0]!.trigger("onPress");
|
|
147
|
+
expect(view.host.callsTo("navigate")).toEqual([
|
|
148
|
+
[{ type: "appPage", path: "/reports/8" }],
|
|
149
|
+
[{ type: "appPage", path: "/reports/7" }],
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("the vendor panel and the Reports page share notes through the store, and the panel passes the vendor as navParams", async () => {
|
|
154
|
+
const { VendorPanel } = (await import(/* @vite-ignore */ pagesModule("VendorPanel"))) as { VendorPanel: ComponentType };
|
|
155
|
+
const { Reports } = (await import(/* @vite-ignore */ pagesModule("Reports"))) as { Reports: ComponentType };
|
|
156
|
+
const originalFetch = globalThis.fetch;
|
|
157
|
+
globalThis.fetch = (async () =>
|
|
158
|
+
new Response(JSON.stringify({ error: "Not found", success: false, code: 404 }), {
|
|
159
|
+
status: 404,
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
})) as typeof fetch;
|
|
162
|
+
try {
|
|
163
|
+
const panel = await renderForTest(h(VendorPanel), {
|
|
164
|
+
context: { environment: { surfaceId: "gs.company.vendor.detail", objectContext: { object: "vendor", id: 42 } } },
|
|
165
|
+
});
|
|
166
|
+
const reports = await renderForTest(h(Reports), {
|
|
167
|
+
context: { environment: { route: mockPageRoute("/reports"), navParams: { vendorId: "42" } } },
|
|
168
|
+
});
|
|
169
|
+
expect(reports.root.slotted("banner")[0]?.props["title"]).toBe("Opened from vendor #42");
|
|
170
|
+
expect(reports.text()).toContain("No notes saved this session.");
|
|
171
|
+
|
|
172
|
+
await panel.find("TextField")!.trigger("onChange", "Badge expired");
|
|
173
|
+
await panel.find("FocusView")!.slotted("primaryAction")[0]!.trigger("onPress");
|
|
174
|
+
await reports.settle();
|
|
175
|
+
expect(panel.text()).toContain("Notes saved this session: 1");
|
|
176
|
+
expect(reports.text()).toContain("Notes saved this session: 1. Latest, on vendor #42: Badge expired");
|
|
177
|
+
|
|
178
|
+
await panel.find("Button", (b) => b.text === "Open reports")!.trigger("onPress");
|
|
179
|
+
// `navigate()` without `{ host }` goes through the most recently mounted view's host, here
|
|
180
|
+
// the page's; both belong to the same app.
|
|
181
|
+
expect([...panel.host.callsTo("navigate"), ...reports.host.callsTo("navigate")]).toEqual([
|
|
182
|
+
[{ type: "appPage", path: "/reports", envParams: { vendorId: "42" } }],
|
|
183
|
+
]);
|
|
184
|
+
expect([...panel.errors, ...reports.errors]).toEqual([]);
|
|
185
|
+
} finally {
|
|
186
|
+
globalThis.fetch = originalFetch;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("ReportDetail reads its id from usePageRoute and follows a route change", async () => {
|
|
191
|
+
const { ReportDetail } = (await import(/* @vite-ignore */ pagesModule("ReportDetail"))) as { ReportDetail: ComponentType };
|
|
192
|
+
const view = await renderForTest(h(ReportDetail), {
|
|
193
|
+
context: { environment: { route: mockPageRoute("/reports/:id", "/reports/7") } },
|
|
194
|
+
});
|
|
195
|
+
expect(view.root.props["title"]).toBe("Weekly check-ins");
|
|
196
|
+
|
|
197
|
+
await view.updateContext({ environment: { ...view.context.environment, route: mockPageRoute("/reports/:id", "/reports/8") } });
|
|
198
|
+
expect(view.root.props["title"]).toBe("Expiring credentials");
|
|
199
|
+
|
|
200
|
+
await view.updateContext({ environment: { ...view.context.environment, route: mockPageRoute("/reports/:id", "/reports/99") } });
|
|
201
|
+
expect(view.root.props["title"]).toBe("Report not found");
|
|
202
|
+
|
|
203
|
+
await view.root.slotted("actions")[0]!.trigger("onPress");
|
|
204
|
+
expect(view.host.callsTo("navigate")).toEqual([[{ type: "appPage", path: "/reports" }]]);
|
|
205
|
+
expect(view.errors).toEqual([]);
|
|
206
|
+
});
|
|
118
207
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Hand-written. App pages in the developer kit (docs/third-party-apps/fullpageview-plan.md, P3):
|
|
3
|
+
* the PageView root, `usePageRoute()`, `navigate({ type: "appPage" })` through the hooks, and the
|
|
4
|
+
* testing kit's route support (`getMockContext`, `mockPageRoute`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createElement as h } from "react";
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import type { PageRoute } from "../../../apps/context.js";
|
|
10
|
+
import { navigate, useHostApi, usePageRoute } from "../../../apps/hooks/index.js";
|
|
11
|
+
import type { RouteDescriptor } from "../../../apps/route.js";
|
|
12
|
+
import { getMockContext, mockPageRoute, renderForTest } from "../../../apps/testing/index.js";
|
|
13
|
+
import { Button, ContextView, PageView, Text, type UiProps } from "../../../apps/ui/index.js";
|
|
14
|
+
|
|
15
|
+
function ReportDetail() {
|
|
16
|
+
const route = usePageRoute();
|
|
17
|
+
const host = useHostApi();
|
|
18
|
+
return h(
|
|
19
|
+
PageView,
|
|
20
|
+
{ title: `Report ${route?.params["id"] ?? "?"}`, actions: h(Button, { onPress: () => navigate({ type: "appPage", path: "/reports" }, { host }) }, "All reports") },
|
|
21
|
+
h(Text, null, route ? `${route.path} at ${route.pathname}` : "no route"),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("PageView", () => {
|
|
26
|
+
test("is a view root with the catalog's props and slots (checked by tsc)", () => {
|
|
27
|
+
const props: UiProps<"PageView"> = { title: "Reports", description: "All reports", actions: h(Button, null, "New"), banner: h(Text, null, "Beta") };
|
|
28
|
+
// @ts-expect-error title is required
|
|
29
|
+
const untitled: UiProps<"PageView"> = { description: "x" };
|
|
30
|
+
// @ts-expect-error PageView has no primaryAction slot (ContextView's)
|
|
31
|
+
const wrongSlot: UiProps<"PageView"> = { title: "t", primaryAction: h(Button, null, "Go") };
|
|
32
|
+
expect([props, untitled, wrongSlot]).toHaveLength(3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("renders as the root with its slots", async () => {
|
|
36
|
+
const view = await renderForTest(h(ReportDetail), {
|
|
37
|
+
context: { environment: { route: mockPageRoute("/reports/:id", "/reports/7") } },
|
|
38
|
+
});
|
|
39
|
+
expect(view.root.component).toBe("PageView");
|
|
40
|
+
expect(view.root.props["title"]).toBe("Report 7");
|
|
41
|
+
expect(view.root.slotted("actions").map((e) => e.text)).toEqual(["All reports"]);
|
|
42
|
+
expect(view.find("Text")!.text).toBe("/reports/:id at /reports/7");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("usePageRoute", () => {
|
|
47
|
+
test("is undefined on a view that is not a page", async () => {
|
|
48
|
+
const Panel = () => h(ContextView, { title: "Panel" }, h(Text, null, usePageRoute() === undefined ? "none" : "route"));
|
|
49
|
+
const view = await renderForTest(h(Panel));
|
|
50
|
+
expect(view.find("Text")!.text).toBe("none");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("re-renders with the new route when the host pushes one through updateContext", async () => {
|
|
54
|
+
const view = await renderForTest(h(ReportDetail), {
|
|
55
|
+
context: { environment: { route: mockPageRoute("/reports/:id", "/reports/7") } },
|
|
56
|
+
});
|
|
57
|
+
expect(view.root.props["title"]).toBe("Report 7");
|
|
58
|
+
|
|
59
|
+
await view.updateContext({ environment: { ...view.context.environment, route: mockPageRoute("/reports/:id", "/reports/8") } });
|
|
60
|
+
expect(view.root.props["title"]).toBe("Report 8");
|
|
61
|
+
expect(view.find("Text")!.text).toBe("/reports/:id at /reports/8");
|
|
62
|
+
expect(view.errors).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("throws outside a mounted view", () => {
|
|
66
|
+
expect(() => usePageRoute()).toThrow();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("navigate to an app page", () => {
|
|
71
|
+
test("sends a concrete path to the host", async () => {
|
|
72
|
+
const view = await renderForTest(h(ReportDetail), {
|
|
73
|
+
context: { environment: { route: mockPageRoute("/reports/:id", "/reports/7") } },
|
|
74
|
+
});
|
|
75
|
+
await view.root.slotted("actions")[0]!.trigger("onPress");
|
|
76
|
+
expect(view.host.callsTo("navigate")).toEqual([[{ type: "appPage", path: "/reports" }]]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rejects a declared (param) path, a path that climbs out and a missing path, before the host sees them", async () => {
|
|
80
|
+
const view = await renderForTest(h(ReportDetail), {
|
|
81
|
+
context: { environment: { route: mockPageRoute("/reports/:id", "/reports/7") } },
|
|
82
|
+
});
|
|
83
|
+
const bad: unknown[] = [
|
|
84
|
+
{ type: "appPage", path: "/reports/:id" },
|
|
85
|
+
{ type: "appPage", path: "/../settings" },
|
|
86
|
+
{ type: "appPage", path: "reports" },
|
|
87
|
+
{ type: "appPage" },
|
|
88
|
+
];
|
|
89
|
+
for (const to of bad) {
|
|
90
|
+
await expect(navigate(to as RouteDescriptor, { host: view.host })).rejects.toThrow(TypeError);
|
|
91
|
+
}
|
|
92
|
+
expect(view.host.callsTo("navigate")).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("type-checks through the hook API", () => {
|
|
96
|
+
const ok: Parameters<typeof navigate>[0] = { type: "appPage", path: "/reports/7", envParams: { tab: "summary" } };
|
|
97
|
+
// @ts-expect-error appPage needs a path
|
|
98
|
+
const noPath: Parameters<typeof navigate>[0] = { type: "appPage" };
|
|
99
|
+
// @ts-expect-error an app can only open its own pages: there is no appId
|
|
100
|
+
const otherApp: Parameters<typeof navigate>[0] = { type: "appPage", path: "/x", appId: 2 };
|
|
101
|
+
expect([ok, noPath, otherApp]).toHaveLength(3);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("testing kit routes", () => {
|
|
106
|
+
test("mockPageRoute decodes params the way the host does", () => {
|
|
107
|
+
expect(mockPageRoute("/reports/:id", "/reports/7")).toEqual({ path: "/reports/:id", pathname: "/reports/7", params: { id: "7" } });
|
|
108
|
+
expect(mockPageRoute("/vendors/:vendor_id/notes/:note", "/vendors/a%20b/notes/3")).toEqual({
|
|
109
|
+
path: "/vendors/:vendor_id/notes/:note",
|
|
110
|
+
pathname: "/vendors/a%20b/notes/3",
|
|
111
|
+
params: { vendor_id: "a b", note: "3" },
|
|
112
|
+
});
|
|
113
|
+
expect(mockPageRoute("/reports")).toEqual({ path: "/reports", pathname: "/reports", params: {} });
|
|
114
|
+
expect(() => mockPageRoute("/reports/:id", "/vendors/7")).toThrow(/does not match/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("getMockContext has no route by default", () => {
|
|
118
|
+
const ctx = getMockContext();
|
|
119
|
+
expect(ctx.environment.route).toBeUndefined();
|
|
120
|
+
expect(ctx.environment.surfaceId).toBe("gs.company.drawer.default");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("getMockContext takes a route, puts the view on the page surface and completes a partial one", () => {
|
|
124
|
+
const route: PageRoute = mockPageRoute("/reports/:id", "/reports/7");
|
|
125
|
+
const ctx = getMockContext({ environment: { route } });
|
|
126
|
+
expect(ctx.environment.route).toEqual(route);
|
|
127
|
+
expect(ctx.environment.surfaceId).toBe("page");
|
|
128
|
+
expect(ctx.environment.portal).toBe("company");
|
|
129
|
+
|
|
130
|
+
expect(getMockContext({ environment: { route: { path: "/reports" } } }).environment.route).toEqual({
|
|
131
|
+
path: "/reports",
|
|
132
|
+
pathname: "/reports",
|
|
133
|
+
params: {},
|
|
134
|
+
});
|
|
135
|
+
expect(getMockContext({ environment: { surfaceId: "custom", route: { path: "/" } } }).environment.surfaceId).toBe("custom");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createElement as h, Fragment, startTransition, useLayoutEffect, useState, type ReactNode } from "react";
|
|
8
8
|
import { flushSync } from "react-dom";
|
|
9
9
|
import { describe, expect, test } from "vitest";
|
|
10
|
-
import { COMPONENTS, type ComponentName } from "../../../apps/components.js";
|
|
10
|
+
import { COMPONENTS, type ChartClickEvent, type ComponentName } from "../../../apps/components.js";
|
|
11
11
|
import { navigate, showToast, useExtensionContext, useHostApi } from "../../../apps/hooks/index.js";
|
|
12
12
|
import { defineRemoteElements, ELEMENTS, ExtensionContextProvider } from "../../../apps/runtime/index.js";
|
|
13
13
|
import { sessionForContext } from "../../../apps/runtime/session.js";
|
|
@@ -15,7 +15,9 @@ import { createMockHost, getMockContext, mockTokens, renderForTest, type MockHos
|
|
|
15
15
|
import * as ui from "../../../apps/ui/index.js";
|
|
16
16
|
import {
|
|
17
17
|
Badge,
|
|
18
|
+
BarChart,
|
|
18
19
|
Button,
|
|
20
|
+
ComboChart,
|
|
19
21
|
ContextView,
|
|
20
22
|
FocusView,
|
|
21
23
|
Link,
|
|
@@ -199,6 +201,38 @@ describe("ui wrappers", () => {
|
|
|
199
201
|
});
|
|
200
202
|
});
|
|
201
203
|
|
|
204
|
+
describe("charts", () => {
|
|
205
|
+
test("data reaches the host and onClick receives the ChartClickEvent detail", async () => {
|
|
206
|
+
const clicks: ChartClickEvent[] = [];
|
|
207
|
+
const data = [{ label: "Mon", value: 3 }, { label: "Tue", value: 5 }];
|
|
208
|
+
const combo = { labels: ["Q1", "Q2"], bars: { name: "Visits", values: [4, 6] }, line: { name: "Average", values: [2, 3] } };
|
|
209
|
+
const view = await renderForTest(
|
|
210
|
+
h(ContextView, { title: "Charts" },
|
|
211
|
+
h(BarChart, { data, theme: "vibrant", interactive: true, onClick: (e: ChartClickEvent) => { clicks.push(e); return e.index; } }),
|
|
212
|
+
h(ComboChart, { data: combo, onClick: (e: ChartClickEvent) => clicks.push(e) }),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
const bar = view.find("BarChart")!;
|
|
216
|
+
expect(bar.props).toEqual({ data, theme: "vibrant", interactive: true });
|
|
217
|
+
expect(bar.events).toEqual(["click"]);
|
|
218
|
+
const detail: ChartClickEvent = { index: 1, label: "Tue", value: 5 };
|
|
219
|
+
await expect(bar.trigger("onClick", detail)).resolves.toBe(1);
|
|
220
|
+
|
|
221
|
+
const comboNode = view.find("ComboChart")!;
|
|
222
|
+
expect(comboNode.props["data"]).toEqual(combo);
|
|
223
|
+
const lineDetail: ChartClickEvent = { index: 0, label: "Q1", value: 2, series: "Average" };
|
|
224
|
+
await comboNode.trigger("onClick", lineDetail);
|
|
225
|
+
expect(clicks).toEqual([detail, lineDetail]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("chart props are typed (checked by tsc)", () => {
|
|
229
|
+
const ok: UiProps<"BarChart"> = { data: [{ label: "a", value: 1 }], onClick: (e: ChartClickEvent) => e.label };
|
|
230
|
+
// @ts-expect-error styling props are not accepted
|
|
231
|
+
const styled: UiProps<"BarChart"> = { data: [{ label: "a", value: 1 }], color: "red" };
|
|
232
|
+
expect([ok, styled]).toHaveLength(2);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
202
236
|
describe("FocusView", () => {
|
|
203
237
|
function Panel(props: { onShownChange?: (shown: boolean) => void }) {
|
|
204
238
|
const [shown, setShown] = useState(false);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Hand-written. `createAppStore` (state shared by the views of one app) and `useNavParams`, through
|
|
3
|
+
* the real runtime. The host mounts every view of an app into one sandbox; here each
|
|
4
|
+
* `renderForTest` is its own runtime in the same module graph, which shares module state the
|
|
5
|
+
* same way.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createElement as h } from "react";
|
|
9
|
+
import { describe, expect, test, vi } from "vitest";
|
|
10
|
+
import { createAppStore, useNavParams } from "../../../apps/hooks/index.js";
|
|
11
|
+
import { getMockContext, mockPageRoute, renderForTest } from "../../../apps/testing/index.js";
|
|
12
|
+
import { Button, ContextView, PageView, Text } from "../../../apps/ui/index.js";
|
|
13
|
+
|
|
14
|
+
describe("createAppStore", () => {
|
|
15
|
+
test("setState merges fields, keeps the object when nothing changes, and tells listeners", () => {
|
|
16
|
+
const store = createAppStore({ count: 0, label: "a" });
|
|
17
|
+
const listener = vi.fn();
|
|
18
|
+
const unsubscribe = store.subscribe(listener);
|
|
19
|
+
const first = store.getState();
|
|
20
|
+
|
|
21
|
+
store.setState({ count: 1 });
|
|
22
|
+
expect(store.getState()).toEqual({ count: 1, label: "a" });
|
|
23
|
+
expect(listener).toHaveBeenCalledWith({ count: 1, label: "a" }, first);
|
|
24
|
+
|
|
25
|
+
const second = store.getState();
|
|
26
|
+
store.setState({ count: 1 });
|
|
27
|
+
store.setState((s) => ({ label: s.label }));
|
|
28
|
+
expect(store.getState()).toBe(second);
|
|
29
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
30
|
+
|
|
31
|
+
store.setState((s) => ({ count: s.count + 1 }));
|
|
32
|
+
expect(store.getState().count).toBe(2);
|
|
33
|
+
unsubscribe();
|
|
34
|
+
store.setState({ count: 3 });
|
|
35
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("two mounted views see each other's updates", async () => {
|
|
39
|
+
const store = createAppStore({ saved: 0, last: "" });
|
|
40
|
+
|
|
41
|
+
function Panel() {
|
|
42
|
+
const saved = store.useStore((s) => s.saved);
|
|
43
|
+
return h(
|
|
44
|
+
ContextView,
|
|
45
|
+
{ title: "Panel" },
|
|
46
|
+
h(Text, null, `saved ${saved}`),
|
|
47
|
+
h(Button, { onPress: () => store.setState((s) => ({ saved: s.saved + 1, last: "from panel" })) }, "Save"),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
function Page() {
|
|
51
|
+
const state = store.useStore();
|
|
52
|
+
return h(
|
|
53
|
+
PageView,
|
|
54
|
+
{ title: "Page" },
|
|
55
|
+
h(Text, null, `saved ${state.saved}, last ${state.last || "none"}`),
|
|
56
|
+
h(Button, { onPress: () => store.setState({ last: "from page" }) }, "Touch"),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const panel = await renderForTest(h(Panel));
|
|
61
|
+
const page = await renderForTest(h(Page), { context: { environment: { route: mockPageRoute("/") } } });
|
|
62
|
+
expect(page.find("Text")!.text).toBe("saved 0, last none");
|
|
63
|
+
|
|
64
|
+
await panel.find("Button")!.trigger("onPress");
|
|
65
|
+
await page.settle();
|
|
66
|
+
expect(panel.find("Text")!.text).toBe("saved 1");
|
|
67
|
+
expect(page.find("Text")!.text).toBe("saved 1, last from panel");
|
|
68
|
+
|
|
69
|
+
await page.find("Button")!.trigger("onPress");
|
|
70
|
+
await panel.settle();
|
|
71
|
+
expect(page.find("Text")!.text).toBe("saved 1, last from page");
|
|
72
|
+
// The panel selects `saved` only, which did not change.
|
|
73
|
+
expect(panel.find("Text")!.text).toBe("saved 1");
|
|
74
|
+
expect([...panel.errors, ...page.errors]).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a view's subscription ends when it unmounts", async () => {
|
|
78
|
+
const store = createAppStore({ n: 0 });
|
|
79
|
+
let active = 0;
|
|
80
|
+
const subscribe = store.subscribe;
|
|
81
|
+
store.subscribe = (listener) => {
|
|
82
|
+
active += 1;
|
|
83
|
+
const unsubscribe = subscribe(listener);
|
|
84
|
+
return () => {
|
|
85
|
+
active -= 1;
|
|
86
|
+
unsubscribe();
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
let renders = 0;
|
|
90
|
+
function Counter() {
|
|
91
|
+
renders += 1;
|
|
92
|
+
return h(ContextView, { title: "Counter" }, h(Text, null, String(store.useStore((s) => s.n))));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const a = await renderForTest(h(Counter));
|
|
96
|
+
const b = await renderForTest(h(Counter));
|
|
97
|
+
expect(active).toBe(2);
|
|
98
|
+
|
|
99
|
+
await a.unmount();
|
|
100
|
+
expect(active).toBe(1);
|
|
101
|
+
|
|
102
|
+
const before = renders;
|
|
103
|
+
store.setState({ n: 1 });
|
|
104
|
+
await b.settle();
|
|
105
|
+
expect(b.find("Text")!.text).toBe("1");
|
|
106
|
+
expect(renders).toBe(before + 1); // only the mounted view rendered
|
|
107
|
+
|
|
108
|
+
await b.unmount();
|
|
109
|
+
expect(active).toBe(0);
|
|
110
|
+
store.setState({ n: 2 });
|
|
111
|
+
expect(renders).toBe(before + 1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a selector that builds a new object from an unchanged state does not loop", async () => {
|
|
115
|
+
const store = createAppStore({ a: 1, b: 2 });
|
|
116
|
+
function Sum() {
|
|
117
|
+
const pair = store.useStore((s) => ({ sum: s.a + s.b }));
|
|
118
|
+
return h(ContextView, { title: "Sum" }, h(Text, null, String(pair.sum)));
|
|
119
|
+
}
|
|
120
|
+
const view = await renderForTest(h(Sum));
|
|
121
|
+
expect(view.find("Text")!.text).toBe("3");
|
|
122
|
+
store.setState({ a: 5 });
|
|
123
|
+
await view.settle();
|
|
124
|
+
expect(view.find("Text")!.text).toBe("7");
|
|
125
|
+
expect(view.errors).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("useNavParams", () => {
|
|
130
|
+
function Target() {
|
|
131
|
+
const params = useNavParams();
|
|
132
|
+
return h(ContextView, { title: "Target" }, h(Text, null, JSON.stringify(params)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("is empty unless the view opened from a navigation", async () => {
|
|
136
|
+
const view = await renderForTest(h(Target));
|
|
137
|
+
expect(view.find("Text")!.text).toBe("{}");
|
|
138
|
+
expect(getMockContext().environment.navParams).toEqual({});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("returns the navigation's envParams, separate from the URL query, and follows updateContext", async () => {
|
|
142
|
+
const view = await renderForTest(h(Target), {
|
|
143
|
+
context: { environment: { navParams: { vendorId: "42" }, queryParams: { vendorId: "666" } } },
|
|
144
|
+
});
|
|
145
|
+
expect(view.find("Text")!.text).toBe('{"vendorId":"42"}');
|
|
146
|
+
await view.updateContext({ environment: { ...view.context.environment, navParams: {} } });
|
|
147
|
+
expect(view.find("Text")!.text).toBe("{}");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Hand-written. `post_install_action: { type: "app_page", path }`: installing opens one of the
|
|
3
|
+
* app's own pages. The cases come from `fixtures/post-install-page.json`, which the server's catalog
|
|
4
|
+
* spec reads too; see docs/third-party-apps/fullpageview-plan.md.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
CONCRETE_ROUTE_PATH_PATTERN,
|
|
12
|
+
effectivePostInstallAction,
|
|
13
|
+
manifestJsonSchema,
|
|
14
|
+
validateManifest,
|
|
15
|
+
type AppManifestV2,
|
|
16
|
+
} from "../../apps/index.js";
|
|
17
|
+
|
|
18
|
+
const FIXTURES = resolve(process.cwd(), "src", "__tests__", "apps", "fixtures", "post-install-page.json");
|
|
19
|
+
|
|
20
|
+
interface Case { name: string; action: Record<string, unknown>; routes?: unknown[]; issue?: string }
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadFixtures(): { routes: unknown[]; accepted: Case[]; rejected: Case[] } {
|
|
27
|
+
const parsed: unknown = JSON.parse(readFileSync(FIXTURES, "utf8"));
|
|
28
|
+
if (!isRecord(parsed) || !Array.isArray(parsed["routes"]) || !Array.isArray(parsed["accepted"]) || !Array.isArray(parsed["rejected"])) {
|
|
29
|
+
throw new Error("fixtures/post-install-page.json needs routes, accepted and rejected arrays");
|
|
30
|
+
}
|
|
31
|
+
return { routes: parsed["routes"], accepted: parsed["accepted"] as Case[], rejected: parsed["rejected"] as Case[] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fixtures = loadFixtures();
|
|
35
|
+
|
|
36
|
+
function manifestFor(testCase: Case): Record<string, unknown> {
|
|
37
|
+
return {
|
|
38
|
+
id: 42,
|
|
39
|
+
name: "Acme Reports",
|
|
40
|
+
icon: "https://cdn.acme.com/icon.png",
|
|
41
|
+
short_description: "Reports about your vendors.",
|
|
42
|
+
version: "1.0.0",
|
|
43
|
+
configuration_instructions: "Install and sign in.",
|
|
44
|
+
support_instructions: "support@acme.com",
|
|
45
|
+
ui_extensions: {
|
|
46
|
+
bundle: {
|
|
47
|
+
url: "https://cdn.acme.com/gs-app/1.0.0/app.js",
|
|
48
|
+
integrity: "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC",
|
|
49
|
+
},
|
|
50
|
+
routes: testCase.routes ?? fixtures.routes,
|
|
51
|
+
},
|
|
52
|
+
post_install_action: testCase.action,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function issuePaths(testCase: Case): string[] {
|
|
57
|
+
const result = validateManifest(manifestFor(testCase));
|
|
58
|
+
return result.ok ? [] : result.issues.map((issue) => issue.path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("post_install_action app_page", () => {
|
|
62
|
+
test.each(fixtures.accepted.map((c) => [c.name, c] as const))("accepts %s", (_name, testCase) => {
|
|
63
|
+
expect(issuePaths(testCase)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test.each(fixtures.rejected.map((c) => [c.name, c] as const))("rejects %s", (_name, testCase) => {
|
|
67
|
+
const paths = issuePaths(testCase);
|
|
68
|
+
expect(paths.length).toBeGreaterThan(0);
|
|
69
|
+
for (const path of paths) expect(path).toBe(testCase.issue);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("an explicit app_page action is returned unchanged", () => {
|
|
73
|
+
const result = validateManifest(manifestFor({ name: "welcome", action: { type: "app_page", path: "/welcome" } }));
|
|
74
|
+
if (!result.ok) throw new Error("fixture invalid");
|
|
75
|
+
const manifest: AppManifestV2 = result.manifest;
|
|
76
|
+
|
|
77
|
+
expect(effectivePostInstallAction(manifest)).toEqual({ type: "app_page", path: "/welcome" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("the JSON schema carries the app_page type and the concrete path pattern", () => {
|
|
81
|
+
const schema: unknown = manifestJsonSchema();
|
|
82
|
+
if (!isRecord(schema) || !isRecord(schema["properties"])) throw new Error("schema has no properties");
|
|
83
|
+
const action = schema["properties"]["post_install_action"];
|
|
84
|
+
if (!isRecord(action) || !isRecord(action["properties"])) throw new Error("schema has no post_install_action");
|
|
85
|
+
const { type, path } = action["properties"];
|
|
86
|
+
|
|
87
|
+
expect(isRecord(type) && type["enum"]).toEqual(["url", "app_settings_view", "app_main_view", "app_page"]);
|
|
88
|
+
expect(isRecord(path) && path["maxLength"]).toBe(200);
|
|
89
|
+
// The generator escapes `/` as `\/`; the pattern is the same.
|
|
90
|
+
expect(isRecord(path) && typeof path["pattern"] === "string" && path["pattern"].replaceAll("\\/", "/"))
|
|
91
|
+
.toBe(CONCRETE_ROUTE_PATH_PATTERN);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("a failing ui_extensions field", () => {
|
|
96
|
+
test("is reported alone, without phantom post-install or view issues", () => {
|
|
97
|
+
const manifest = {
|
|
98
|
+
id: 1,
|
|
99
|
+
name: "A",
|
|
100
|
+
icon: "https://a.example.com/i.png",
|
|
101
|
+
short_description: "x",
|
|
102
|
+
configuration_instructions: "x",
|
|
103
|
+
support_instructions: "x",
|
|
104
|
+
version: "1.0.0",
|
|
105
|
+
ui_extensions: {
|
|
106
|
+
bundle: { url: "https://a.example.com/a.js", integrity: `sha384-${"A".repeat(64)}` },
|
|
107
|
+
views: [{ viewport: "settings", component: "S" }],
|
|
108
|
+
routes: [{ path: "/../settings", component: "P", title: "P" }],
|
|
109
|
+
},
|
|
110
|
+
post_install_action: { type: "app_settings_view" },
|
|
111
|
+
};
|
|
112
|
+
const result = validateManifest(manifest);
|
|
113
|
+
expect(result.ok).toBe(false);
|
|
114
|
+
if (result.ok) return;
|
|
115
|
+
expect(result.issues.map((issue) => issue.path)).toEqual(["ui_extensions.routes.0.path"]);
|
|
116
|
+
|
|
117
|
+
const pageAction = validateManifest({ ...manifest, post_install_action: { type: "app_page", path: "/reports" } });
|
|
118
|
+
expect(pageAction.ok ? [] : pageAction.issues.map((issue) => issue.path)).toEqual(["ui_extensions.routes.0.path"]);
|
|
119
|
+
|
|
120
|
+
const strayPath = validateManifest({ ...manifest, post_install_action: { type: "url", url: "https://a.example.com/", path: "/x" } });
|
|
121
|
+
expect(strayPath.ok ? [] : strayPath.issues.map((issue) => issue.path)).toEqual([
|
|
122
|
+
"ui_extensions.routes.0.path",
|
|
123
|
+
"post_install_action.path",
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
});
|