@jskit-ai/shell-web 0.1.4
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/package.descriptor.mjs +165 -0
- package/package.json +23 -0
- package/src/client/components/ShellErrorHost.vue +208 -0
- package/src/client/components/ShellLayout.vue +191 -0
- package/src/client/components/ShellOutlet.vue +95 -0
- package/src/client/components/useShellLayout.js +93 -0
- package/src/client/error/index.js +2 -0
- package/src/client/error/inject.js +142 -0
- package/src/client/error/normalize.js +75 -0
- package/src/client/error/policy.js +50 -0
- package/src/client/error/presenters.js +89 -0
- package/src/client/error/runtime.js +418 -0
- package/src/client/error/store.js +176 -0
- package/src/client/error/tokens.js +14 -0
- package/src/client/index.js +17 -0
- package/src/client/navigation/linkResolver.js +117 -0
- package/src/client/placement/debug.js +52 -0
- package/src/client/placement/index.js +26 -0
- package/src/client/placement/inject.js +104 -0
- package/src/client/placement/pathname.js +14 -0
- package/src/client/placement/registry.js +41 -0
- package/src/client/placement/runtime.js +435 -0
- package/src/client/placement/surfaceContext.js +290 -0
- package/src/client/placement/tokens.js +29 -0
- package/src/client/placement/validators.js +210 -0
- package/src/client/providers/ShellWebClientProvider.js +352 -0
- package/templates/src/App.vue +11 -0
- package/templates/src/components/ShellLayout.vue +247 -0
- package/templates/src/error.js +13 -0
- package/templates/src/pages/console/index.vue +24 -0
- package/templates/src/pages/console.vue +20 -0
- package/templates/src/pages/home/index.vue +54 -0
- package/templates/src/pages/home.vue +20 -0
- package/templates/src/placement.js +12 -0
- package/test/errorRuntime.test.js +191 -0
- package/test/errorStore.test.js +26 -0
- package/test/linkResolver.test.js +112 -0
- package/test/placementRegistry.test.js +45 -0
- package/test/placementRuntime.test.js +374 -0
- package/test/provider.test.js +163 -0
- package/test/surfaceContext.test.js +184 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolveShellLinkPath } from "../src/client/navigation/linkResolver.js";
|
|
4
|
+
|
|
5
|
+
function createPlacementContext() {
|
|
6
|
+
return {
|
|
7
|
+
surfaceConfig: {
|
|
8
|
+
tenancyMode: "workspace",
|
|
9
|
+
defaultSurfaceId: "app",
|
|
10
|
+
enabledSurfaceIds: ["app", "admin", "console"],
|
|
11
|
+
surfacesById: {
|
|
12
|
+
app: {
|
|
13
|
+
id: "app",
|
|
14
|
+
pagesRoot: "w/[workspaceSlug]",
|
|
15
|
+
routeBase: "/w/:workspaceSlug",
|
|
16
|
+
enabled: true,
|
|
17
|
+
requiresWorkspace: true
|
|
18
|
+
},
|
|
19
|
+
admin: {
|
|
20
|
+
id: "admin",
|
|
21
|
+
pagesRoot: "admin",
|
|
22
|
+
routeBase: "/admin",
|
|
23
|
+
enabled: true,
|
|
24
|
+
requiresWorkspace: true
|
|
25
|
+
},
|
|
26
|
+
console: {
|
|
27
|
+
id: "console",
|
|
28
|
+
pagesRoot: "console",
|
|
29
|
+
routeBase: "/console",
|
|
30
|
+
enabled: true,
|
|
31
|
+
requiresWorkspace: false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("resolveShellLinkPath composes surface path for workspace-labeled surfaces", () => {
|
|
39
|
+
const to = resolveShellLinkPath({
|
|
40
|
+
context: createPlacementContext(),
|
|
41
|
+
surface: "admin",
|
|
42
|
+
relativePath: "/contacts/2"
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.equal(to, "/admin/contacts/2");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("resolveShellLinkPath materializes dynamic route params from params map", () => {
|
|
49
|
+
const to = resolveShellLinkPath({
|
|
50
|
+
context: createPlacementContext(),
|
|
51
|
+
surface: "app",
|
|
52
|
+
params: {
|
|
53
|
+
workspaceSlug: "acme"
|
|
54
|
+
},
|
|
55
|
+
relativePath: "/projects/2"
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(to, "/w/acme/projects/2");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("resolveShellLinkPath fails when required route params are missing", () => {
|
|
62
|
+
assert.throws(
|
|
63
|
+
() =>
|
|
64
|
+
resolveShellLinkPath({
|
|
65
|
+
context: createPlacementContext(),
|
|
66
|
+
surface: "app",
|
|
67
|
+
relativePath: "/projects/2"
|
|
68
|
+
}),
|
|
69
|
+
/Missing required surface route params/
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("resolveShellLinkPath composes non-workspace path for non-workspace surface", () => {
|
|
74
|
+
const to = resolveShellLinkPath({
|
|
75
|
+
context: createPlacementContext(),
|
|
76
|
+
surface: "console",
|
|
77
|
+
relativePath: "/contacts"
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
assert.equal(to, "/console/contacts");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("resolveShellLinkPath keeps explicit target unchanged", () => {
|
|
84
|
+
const to = resolveShellLinkPath({
|
|
85
|
+
context: createPlacementContext(),
|
|
86
|
+
surface: "admin",
|
|
87
|
+
explicitTo: "/custom/path"
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.equal(to, "/custom/path");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("resolveShellLinkPath prefers surfaceRelativePath when provided", () => {
|
|
94
|
+
const to = resolveShellLinkPath({
|
|
95
|
+
context: createPlacementContext(),
|
|
96
|
+
surface: "admin",
|
|
97
|
+
relativePath: "/ignored",
|
|
98
|
+
surfaceRelativePath: "/contacts"
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
assert.equal(to, "/admin/contacts");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("resolveShellLinkPath uses deterministic surface fallback when context is missing", () => {
|
|
105
|
+
const to = resolveShellLinkPath({
|
|
106
|
+
context: null,
|
|
107
|
+
surface: "admin",
|
|
108
|
+
relativePath: "/contacts/5"
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.equal(to, "/admin/contacts/5");
|
|
112
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createPlacementRegistry } from "../src/client/placement/registry.js";
|
|
4
|
+
|
|
5
|
+
test("placement registry stores unique entries and builds immutable array", () => {
|
|
6
|
+
const registry = createPlacementRegistry();
|
|
7
|
+
|
|
8
|
+
const firstAdded = registry.addPlacement({
|
|
9
|
+
id: "example.profile",
|
|
10
|
+
host: "shell-layout",
|
|
11
|
+
position: "top-right",
|
|
12
|
+
surfaces: ["*"],
|
|
13
|
+
componentToken: "example.profile.component"
|
|
14
|
+
});
|
|
15
|
+
const duplicateAdded = registry.addPlacement({
|
|
16
|
+
id: "example.profile",
|
|
17
|
+
host: "shell-layout",
|
|
18
|
+
position: "top-right",
|
|
19
|
+
surfaces: ["*"],
|
|
20
|
+
componentToken: "example.profile.component.duplicate"
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
assert.equal(firstAdded, true);
|
|
24
|
+
assert.equal(duplicateAdded, false);
|
|
25
|
+
assert.equal(registry.hasPlacement("example.profile"), true);
|
|
26
|
+
|
|
27
|
+
const placements = registry.build();
|
|
28
|
+
assert.equal(Array.isArray(placements), true);
|
|
29
|
+
assert.equal(placements.length, 1);
|
|
30
|
+
assert.equal(placements[0].componentToken, "example.profile.component");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("placement registry accepts explicit non-global surface ids", () => {
|
|
34
|
+
const registry = createPlacementRegistry();
|
|
35
|
+
|
|
36
|
+
const added = registry.addPlacement({
|
|
37
|
+
id: "example.admin",
|
|
38
|
+
host: "shell-layout",
|
|
39
|
+
position: "top-right",
|
|
40
|
+
surfaces: ["admin"],
|
|
41
|
+
componentToken: "example.admin.component"
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.equal(added, true);
|
|
45
|
+
});
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { definePlacement } from "../src/client/placement/validators.js";
|
|
4
|
+
import { createWebPlacementRuntime } from "../src/client/placement/runtime.js";
|
|
5
|
+
|
|
6
|
+
function createAppStub({ tokens = {}, contextContributors = [] } = {}) {
|
|
7
|
+
return {
|
|
8
|
+
has(token) {
|
|
9
|
+
return Object.prototype.hasOwnProperty.call(tokens, token);
|
|
10
|
+
},
|
|
11
|
+
make(token) {
|
|
12
|
+
if (!this.has(token)) {
|
|
13
|
+
throw new Error(`Unknown token: ${String(token)}`);
|
|
14
|
+
}
|
|
15
|
+
return tokens[token];
|
|
16
|
+
},
|
|
17
|
+
resolveTag(tagName) {
|
|
18
|
+
if (tagName === "web-placement.context.client") {
|
|
19
|
+
return contextContributors;
|
|
20
|
+
}
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createPlacementContext() {
|
|
27
|
+
return {
|
|
28
|
+
surfaceConfig: {
|
|
29
|
+
defaultSurfaceId: "app",
|
|
30
|
+
enabledSurfaceIds: ["app", "admin", "console"]
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("web placement runtime filters by surface/host/position, resolves component tokens, and sorts by order", () => {
|
|
36
|
+
const app = createAppStub({
|
|
37
|
+
tokens: {
|
|
38
|
+
"component.alerts": () => null,
|
|
39
|
+
"component.profile": () => null,
|
|
40
|
+
"component.menu": () => null
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
45
|
+
runtime.replacePlacements([
|
|
46
|
+
definePlacement({
|
|
47
|
+
id: "test.menu",
|
|
48
|
+
host: "shell-layout",
|
|
49
|
+
position: "primary-menu",
|
|
50
|
+
surfaces: ["app"],
|
|
51
|
+
order: 30,
|
|
52
|
+
componentToken: "component.menu"
|
|
53
|
+
}),
|
|
54
|
+
definePlacement({
|
|
55
|
+
id: "test.profile",
|
|
56
|
+
host: "shell-layout",
|
|
57
|
+
position: "top-right",
|
|
58
|
+
surfaces: ["*"],
|
|
59
|
+
order: 20,
|
|
60
|
+
componentToken: "component.profile"
|
|
61
|
+
}),
|
|
62
|
+
definePlacement({
|
|
63
|
+
id: "test.alerts",
|
|
64
|
+
host: "shell-layout",
|
|
65
|
+
position: "top-right",
|
|
66
|
+
surfaces: ["app"],
|
|
67
|
+
order: 10,
|
|
68
|
+
componentToken: "component.alerts"
|
|
69
|
+
})
|
|
70
|
+
]);
|
|
71
|
+
runtime.setContext(createPlacementContext());
|
|
72
|
+
|
|
73
|
+
const topRight = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "top-right" });
|
|
74
|
+
assert.deepEqual(topRight.map((entry) => entry.id), ["test.alerts", "test.profile"]);
|
|
75
|
+
assert.equal(typeof topRight[0].component, "function");
|
|
76
|
+
|
|
77
|
+
const primaryMenu = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "primary-menu" });
|
|
78
|
+
assert.deepEqual(primaryMenu.map((entry) => entry.id), ["test.menu"]);
|
|
79
|
+
|
|
80
|
+
const adminTopRight = runtime.getPlacements({ surface: "admin", host: "shell-layout", position: "top-right" });
|
|
81
|
+
assert.deepEqual(adminTopRight.map((entry) => entry.id), ["test.profile"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("web placement runtime applies context contributors and placement when() predicates", () => {
|
|
85
|
+
const app = createAppStub({
|
|
86
|
+
tokens: {
|
|
87
|
+
"component.guest": () => null,
|
|
88
|
+
"component.authenticated": () => null
|
|
89
|
+
},
|
|
90
|
+
contextContributors: [
|
|
91
|
+
() => ({
|
|
92
|
+
auth: { authenticated: true }
|
|
93
|
+
})
|
|
94
|
+
]
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
98
|
+
runtime.replacePlacements([
|
|
99
|
+
definePlacement({
|
|
100
|
+
id: "guest.item",
|
|
101
|
+
host: "auth-profile-menu",
|
|
102
|
+
position: "primary-menu",
|
|
103
|
+
surfaces: ["*"],
|
|
104
|
+
componentToken: "component.guest",
|
|
105
|
+
when: ({ auth }) => !Boolean(auth?.authenticated)
|
|
106
|
+
}),
|
|
107
|
+
definePlacement({
|
|
108
|
+
id: "auth.item",
|
|
109
|
+
host: "auth-profile-menu",
|
|
110
|
+
position: "primary-menu",
|
|
111
|
+
surfaces: ["*"],
|
|
112
|
+
componentToken: "component.authenticated",
|
|
113
|
+
when: ({ auth }) => Boolean(auth?.authenticated)
|
|
114
|
+
})
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const menu = runtime.getPlacements({ surface: "app", host: "auth-profile-menu", position: "primary-menu" });
|
|
118
|
+
assert.deepEqual(menu.map((entry) => entry.id), ["auth.item"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("web placement runtime uses runtime context and local context overrides contributor values", () => {
|
|
122
|
+
const app = createAppStub({
|
|
123
|
+
tokens: {
|
|
124
|
+
"component.allowed": () => null
|
|
125
|
+
},
|
|
126
|
+
contextContributors: [
|
|
127
|
+
() => ({
|
|
128
|
+
auth: {
|
|
129
|
+
authenticated: false
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
]
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
136
|
+
runtime.replacePlacements([
|
|
137
|
+
definePlacement({
|
|
138
|
+
id: "allowed",
|
|
139
|
+
host: "auth-profile-menu",
|
|
140
|
+
position: "primary-menu",
|
|
141
|
+
surfaces: ["*"],
|
|
142
|
+
componentToken: "component.allowed",
|
|
143
|
+
when: ({ auth }) => Boolean(auth?.authenticated)
|
|
144
|
+
})
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
runtime.setContext({
|
|
148
|
+
auth: {
|
|
149
|
+
authenticated: true
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
const fromRuntime = runtime.getPlacements({ surface: "app", host: "auth-profile-menu", position: "primary-menu" });
|
|
153
|
+
assert.deepEqual(fromRuntime.map((entry) => entry.id), ["allowed"]);
|
|
154
|
+
|
|
155
|
+
const fromLocalOverride = runtime.getPlacements({
|
|
156
|
+
surface: "app",
|
|
157
|
+
host: "auth-profile-menu",
|
|
158
|
+
position: "primary-menu",
|
|
159
|
+
context: {
|
|
160
|
+
auth: {
|
|
161
|
+
authenticated: false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
assert.deepEqual(fromLocalOverride.map((entry) => entry.id), []);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("web placement runtime notifies subscribers on placement and context updates", () => {
|
|
169
|
+
const app = createAppStub();
|
|
170
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
171
|
+
const events = [];
|
|
172
|
+
const unsubscribe = runtime.subscribe((event) => {
|
|
173
|
+
events.push(event.type);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
runtime.replacePlacements([]);
|
|
177
|
+
runtime.setContext({
|
|
178
|
+
auth: {
|
|
179
|
+
authenticated: true
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
unsubscribe();
|
|
184
|
+
runtime.setContext({
|
|
185
|
+
auth: {
|
|
186
|
+
authenticated: false
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(events, ["placements.replaced", "context.updated"]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("web placement runtime rejects duplicate placement ids", () => {
|
|
194
|
+
const app = createAppStub();
|
|
195
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
196
|
+
|
|
197
|
+
assert.throws(() => {
|
|
198
|
+
runtime.replacePlacements([
|
|
199
|
+
definePlacement({
|
|
200
|
+
id: "dup.entry",
|
|
201
|
+
host: "shell-layout",
|
|
202
|
+
position: "top-right",
|
|
203
|
+
surfaces: ["*"],
|
|
204
|
+
componentToken: "component.a"
|
|
205
|
+
}),
|
|
206
|
+
definePlacement({
|
|
207
|
+
id: "dup.entry",
|
|
208
|
+
host: "shell-layout",
|
|
209
|
+
position: "primary-menu",
|
|
210
|
+
surfaces: ["*"],
|
|
211
|
+
componentToken: "component.b"
|
|
212
|
+
})
|
|
213
|
+
]);
|
|
214
|
+
}, /Duplicate placement id/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("web placement runtime skips throwing component tokens and logs resolution errors once", () => {
|
|
218
|
+
const app = {
|
|
219
|
+
has(token) {
|
|
220
|
+
return token === "component.bad" || token === "component.good";
|
|
221
|
+
},
|
|
222
|
+
make(token) {
|
|
223
|
+
if (token === "component.bad") {
|
|
224
|
+
throw new Error("bad component token");
|
|
225
|
+
}
|
|
226
|
+
if (token === "component.good") {
|
|
227
|
+
return () => null;
|
|
228
|
+
}
|
|
229
|
+
throw new Error(`Unknown token: ${String(token)}`);
|
|
230
|
+
},
|
|
231
|
+
resolveTag() {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const errors = [];
|
|
237
|
+
const runtime = createWebPlacementRuntime({
|
|
238
|
+
app,
|
|
239
|
+
logger: {
|
|
240
|
+
warn() {},
|
|
241
|
+
error(payload, message) {
|
|
242
|
+
errors.push({ payload, message });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
runtime.replacePlacements([
|
|
248
|
+
definePlacement({
|
|
249
|
+
id: "bad",
|
|
250
|
+
host: "shell-layout",
|
|
251
|
+
position: "top-right",
|
|
252
|
+
surfaces: ["*"],
|
|
253
|
+
componentToken: "component.bad"
|
|
254
|
+
}),
|
|
255
|
+
definePlacement({
|
|
256
|
+
id: "good",
|
|
257
|
+
host: "shell-layout",
|
|
258
|
+
position: "top-right",
|
|
259
|
+
surfaces: ["*"],
|
|
260
|
+
componentToken: "component.good"
|
|
261
|
+
})
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
const first = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "top-right" });
|
|
265
|
+
assert.deepEqual(first.map((entry) => entry.id), ["good"]);
|
|
266
|
+
assert.equal(errors.length, 1);
|
|
267
|
+
|
|
268
|
+
const second = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "top-right" });
|
|
269
|
+
assert.deepEqual(second.map((entry) => entry.id), ["good"]);
|
|
270
|
+
assert.equal(errors.length, 1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("web placement runtime clears failed token cache when placements are replaced", () => {
|
|
274
|
+
let shouldThrow = true;
|
|
275
|
+
const app = {
|
|
276
|
+
has(token) {
|
|
277
|
+
return token === "component.toggle";
|
|
278
|
+
},
|
|
279
|
+
make(token) {
|
|
280
|
+
if (token !== "component.toggle") {
|
|
281
|
+
throw new Error(`Unknown token: ${String(token)}`);
|
|
282
|
+
}
|
|
283
|
+
if (shouldThrow) {
|
|
284
|
+
throw new Error("toggle failure");
|
|
285
|
+
}
|
|
286
|
+
return () => null;
|
|
287
|
+
},
|
|
288
|
+
resolveTag() {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const runtime = createWebPlacementRuntime({
|
|
294
|
+
app,
|
|
295
|
+
logger: {
|
|
296
|
+
warn() {},
|
|
297
|
+
error() {}
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
runtime.replacePlacements([
|
|
302
|
+
definePlacement({
|
|
303
|
+
id: "toggle",
|
|
304
|
+
host: "shell-layout",
|
|
305
|
+
position: "top-right",
|
|
306
|
+
surfaces: ["*"],
|
|
307
|
+
componentToken: "component.toggle"
|
|
308
|
+
})
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
const initial = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "top-right" });
|
|
312
|
+
assert.equal(initial.length, 0);
|
|
313
|
+
|
|
314
|
+
shouldThrow = false;
|
|
315
|
+
const stillSkipped = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "top-right" });
|
|
316
|
+
assert.equal(stillSkipped.length, 0);
|
|
317
|
+
|
|
318
|
+
runtime.replacePlacements([
|
|
319
|
+
definePlacement({
|
|
320
|
+
id: "toggle",
|
|
321
|
+
host: "shell-layout",
|
|
322
|
+
position: "top-right",
|
|
323
|
+
surfaces: ["*"],
|
|
324
|
+
componentToken: "component.toggle"
|
|
325
|
+
})
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
const recovered = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "top-right" });
|
|
329
|
+
assert.equal(recovered.length, 1);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("web placement runtime follows explicit surface targeting without role indirection", () => {
|
|
333
|
+
const app = createAppStub({
|
|
334
|
+
tokens: {
|
|
335
|
+
"component.global": () => null,
|
|
336
|
+
"component.app": () => null,
|
|
337
|
+
"component.admin": () => null
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
341
|
+
runtime.replacePlacements([
|
|
342
|
+
definePlacement({
|
|
343
|
+
id: "global.banner",
|
|
344
|
+
host: "shell-layout",
|
|
345
|
+
position: "top-right",
|
|
346
|
+
surfaces: ["*"],
|
|
347
|
+
order: 10,
|
|
348
|
+
componentToken: "component.global"
|
|
349
|
+
}),
|
|
350
|
+
definePlacement({
|
|
351
|
+
id: "app.link",
|
|
352
|
+
host: "shell-layout",
|
|
353
|
+
position: "top-right",
|
|
354
|
+
surfaces: ["app"],
|
|
355
|
+
order: 20,
|
|
356
|
+
componentToken: "component.app"
|
|
357
|
+
}),
|
|
358
|
+
definePlacement({
|
|
359
|
+
id: "admin.link",
|
|
360
|
+
host: "shell-layout",
|
|
361
|
+
position: "top-right",
|
|
362
|
+
surfaces: ["admin"],
|
|
363
|
+
order: 30,
|
|
364
|
+
componentToken: "component.admin"
|
|
365
|
+
})
|
|
366
|
+
]);
|
|
367
|
+
runtime.setContext(createPlacementContext());
|
|
368
|
+
|
|
369
|
+
const appEntries = runtime.getPlacements({ surface: "app", host: "shell-layout", position: "top-right" });
|
|
370
|
+
assert.deepEqual(appEntries.map((placement) => placement.id), ["global.banner", "app.link"]);
|
|
371
|
+
|
|
372
|
+
const adminEntries = runtime.getPlacements({ surface: "admin", host: "shell-layout", position: "top-right" });
|
|
373
|
+
assert.deepEqual(adminEntries.map((placement) => placement.id), ["global.banner", "admin.link"]);
|
|
374
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ShellWebClientProvider } from "../src/client/providers/ShellWebClientProvider.js";
|
|
4
|
+
import {
|
|
5
|
+
WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN,
|
|
6
|
+
WEB_PLACEMENT_RUNTIME_INJECTION_KEY
|
|
7
|
+
} from "../src/client/placement/tokens.js";
|
|
8
|
+
import {
|
|
9
|
+
SHELL_WEB_ERROR_PRESENTATION_STORE_CLIENT_TOKEN,
|
|
10
|
+
SHELL_WEB_ERROR_PRESENTATION_STORE_INJECTION_KEY,
|
|
11
|
+
SHELL_WEB_ERROR_RUNTIME_CLIENT_TOKEN,
|
|
12
|
+
SHELL_WEB_ERROR_RUNTIME_INJECTION_KEY
|
|
13
|
+
} from "../src/client/error/tokens.js";
|
|
14
|
+
import {
|
|
15
|
+
CLIENT_MODULE_SURFACE_RUNTIME_TOKEN,
|
|
16
|
+
CLIENT_MODULE_VUE_APP_TOKEN
|
|
17
|
+
} from "@jskit-ai/kernel/client/moduleBootstrap";
|
|
18
|
+
|
|
19
|
+
const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
|
|
20
|
+
|
|
21
|
+
function setClientAppConfig(source = {}) {
|
|
22
|
+
const normalized =
|
|
23
|
+
source && typeof source === "object" && !Array.isArray(source) ? Object.freeze({ ...source }) : Object.freeze({});
|
|
24
|
+
if (typeof globalThis === "object" && globalThis) {
|
|
25
|
+
globalThis[CLIENT_APP_CONFIG_GLOBAL_KEY] = normalized;
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createAppDouble({ surfaceRuntime = null } = {}) {
|
|
31
|
+
const singletons = new Map();
|
|
32
|
+
const singletonInstances = new Map();
|
|
33
|
+
const provided = [];
|
|
34
|
+
const plugins = [];
|
|
35
|
+
|
|
36
|
+
const vueApp = {
|
|
37
|
+
config: {},
|
|
38
|
+
use(plugin, options) {
|
|
39
|
+
plugins.push({ plugin, options });
|
|
40
|
+
return this;
|
|
41
|
+
},
|
|
42
|
+
provide(key, value) {
|
|
43
|
+
provided.push({ key, value });
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
singletons,
|
|
49
|
+
provided,
|
|
50
|
+
plugins,
|
|
51
|
+
vueApp,
|
|
52
|
+
singleton(token, factory) {
|
|
53
|
+
singletons.set(token, factory);
|
|
54
|
+
},
|
|
55
|
+
has(token) {
|
|
56
|
+
if (token === CLIENT_MODULE_VUE_APP_TOKEN) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (token === CLIENT_MODULE_SURFACE_RUNTIME_TOKEN) {
|
|
60
|
+
return Boolean(surfaceRuntime);
|
|
61
|
+
}
|
|
62
|
+
return singletons.has(token) || singletonInstances.has(token);
|
|
63
|
+
},
|
|
64
|
+
make(token) {
|
|
65
|
+
if (token === CLIENT_MODULE_VUE_APP_TOKEN) {
|
|
66
|
+
return vueApp;
|
|
67
|
+
}
|
|
68
|
+
if (token === CLIENT_MODULE_SURFACE_RUNTIME_TOKEN && surfaceRuntime) {
|
|
69
|
+
return surfaceRuntime;
|
|
70
|
+
}
|
|
71
|
+
if (singletonInstances.has(token)) {
|
|
72
|
+
return singletonInstances.get(token);
|
|
73
|
+
}
|
|
74
|
+
const factory = singletons.get(token);
|
|
75
|
+
if (!factory) {
|
|
76
|
+
throw new Error(`Unknown token ${String(token)}`);
|
|
77
|
+
}
|
|
78
|
+
const instance = factory(this);
|
|
79
|
+
singletonInstances.set(token, instance);
|
|
80
|
+
return instance;
|
|
81
|
+
},
|
|
82
|
+
resolveTag() {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test("shell web client provider binds runtime and injects it into Vue app", async () => {
|
|
89
|
+
const app = createAppDouble();
|
|
90
|
+
const provider = new ShellWebClientProvider();
|
|
91
|
+
|
|
92
|
+
provider.register(app);
|
|
93
|
+
assert.equal(app.singletons.has(WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN), true);
|
|
94
|
+
assert.equal(app.singletons.has(SHELL_WEB_ERROR_RUNTIME_CLIENT_TOKEN), true);
|
|
95
|
+
assert.equal(app.singletons.has(SHELL_WEB_ERROR_PRESENTATION_STORE_CLIENT_TOKEN), true);
|
|
96
|
+
|
|
97
|
+
await provider.boot(app);
|
|
98
|
+
assert.equal(app.plugins.length, 1);
|
|
99
|
+
assert.equal(typeof app.plugins[0].plugin.install, "function");
|
|
100
|
+
assert.equal(typeof app.plugins[0].options?.queryClient, "object");
|
|
101
|
+
|
|
102
|
+
const providedByKey = new Map(app.provided.map((entry) => [entry.key, entry.value]));
|
|
103
|
+
|
|
104
|
+
assert.equal(providedByKey.has(WEB_PLACEMENT_RUNTIME_INJECTION_KEY), true);
|
|
105
|
+
assert.equal(providedByKey.has(SHELL_WEB_ERROR_RUNTIME_INJECTION_KEY), true);
|
|
106
|
+
assert.equal(providedByKey.has(SHELL_WEB_ERROR_PRESENTATION_STORE_INJECTION_KEY), true);
|
|
107
|
+
|
|
108
|
+
const placementRuntime = providedByKey.get(WEB_PLACEMENT_RUNTIME_INJECTION_KEY);
|
|
109
|
+
assert.equal(typeof placementRuntime.getPlacements, "function");
|
|
110
|
+
assert.equal(typeof placementRuntime.getContext, "function");
|
|
111
|
+
assert.equal(typeof placementRuntime.setContext, "function");
|
|
112
|
+
assert.equal(typeof placementRuntime.getContext().surfaceConfig, "object");
|
|
113
|
+
|
|
114
|
+
const errorRuntime = providedByKey.get(SHELL_WEB_ERROR_RUNTIME_INJECTION_KEY);
|
|
115
|
+
assert.equal(typeof errorRuntime.report, "function");
|
|
116
|
+
assert.equal(typeof errorRuntime.configure, "function");
|
|
117
|
+
|
|
118
|
+
const errorStore = providedByKey.get(SHELL_WEB_ERROR_PRESENTATION_STORE_INJECTION_KEY);
|
|
119
|
+
assert.equal(typeof errorStore.getState, "function");
|
|
120
|
+
assert.equal(typeof errorStore.present, "function");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("shell web client provider resolves surface config from client app config", async () => {
|
|
124
|
+
setClientAppConfig({
|
|
125
|
+
tenancyMode: "workspace",
|
|
126
|
+
surfaceAccessPolicies: {
|
|
127
|
+
public: {}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const app = createAppDouble({
|
|
133
|
+
surfaceRuntime: {
|
|
134
|
+
DEFAULT_SURFACE_ID: "app",
|
|
135
|
+
listEnabledSurfaceIds() {
|
|
136
|
+
return ["app", "admin", "console"];
|
|
137
|
+
},
|
|
138
|
+
listSurfaceDefinitions() {
|
|
139
|
+
return [
|
|
140
|
+
{ id: "app", pagesRoot: "w/[workspaceSlug]", requiresWorkspace: true, enabled: true },
|
|
141
|
+
{ id: "admin", pagesRoot: "w/[workspaceSlug]/admin", requiresWorkspace: true, enabled: true },
|
|
142
|
+
{ id: "console", pagesRoot: "console", requiresWorkspace: false, enabled: true }
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
const provider = new ShellWebClientProvider();
|
|
148
|
+
provider.register(app);
|
|
149
|
+
|
|
150
|
+
await provider.boot(app);
|
|
151
|
+
|
|
152
|
+
const placementRuntime = app.make(WEB_PLACEMENT_RUNTIME_CLIENT_TOKEN);
|
|
153
|
+
const context = placementRuntime.getContext();
|
|
154
|
+
assert.equal(context.surfaceConfig.tenancyMode, "workspace");
|
|
155
|
+
assert.equal(context.surfaceConfig.defaultSurfaceId, "app");
|
|
156
|
+
assert.deepEqual(context.surfaceConfig.enabledSurfaceIds, ["app", "admin", "console"]);
|
|
157
|
+
assert.deepEqual(context.surfaceAccessPolicies, {
|
|
158
|
+
public: {}
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
setClientAppConfig({});
|
|
162
|
+
}
|
|
163
|
+
});
|