@antelopejs/dms 0.9.0 → 0.9.1

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/data/module-store/catalog.json +71 -0
  3. package/dist/config.d.ts +7 -0
  4. package/dist/config.js +8 -0
  5. package/dist/config.js.map +1 -1
  6. package/dist/implementations/dms/frontend-modules.d.ts +2 -0
  7. package/dist/implementations/dms/frontend-modules.js +29 -1
  8. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  9. package/dist/implementations/dms/frontend-ready.d.ts +8 -0
  10. package/dist/implementations/dms/frontend-ready.js +34 -0
  11. package/dist/implementations/dms/frontend-ready.js.map +1 -0
  12. package/dist/implementations/dms/manifest.d.ts +1 -0
  13. package/dist/implementations/dms/manifest.js.map +1 -1
  14. package/dist/implementations/dms/page.d.ts +2 -0
  15. package/dist/implementations/dms/page.js +16 -0
  16. package/dist/implementations/dms/page.js.map +1 -1
  17. package/dist/routes/index.d.ts +1 -0
  18. package/dist/routes/index.js +1 -0
  19. package/dist/routes/index.js.map +1 -1
  20. package/dist/routes/store.d.ts +21 -0
  21. package/dist/routes/store.js +130 -0
  22. package/dist/routes/store.js.map +1 -0
  23. package/dist/store/activity.d.ts +2 -0
  24. package/dist/store/activity.js +12 -0
  25. package/dist/store/activity.js.map +1 -0
  26. package/dist/store/cache.d.ts +8 -0
  27. package/dist/store/cache.js +29 -0
  28. package/dist/store/cache.js.map +1 -0
  29. package/dist/store/catalog.d.ts +4 -0
  30. package/dist/store/catalog.js +69 -0
  31. package/dist/store/catalog.js.map +1 -0
  32. package/dist/store/compatibility.d.ts +3 -0
  33. package/dist/store/compatibility.js +45 -0
  34. package/dist/store/compatibility.js.map +1 -0
  35. package/dist/store/declarations.d.ts +18 -0
  36. package/dist/store/declarations.js +116 -0
  37. package/dist/store/declarations.js.map +1 -0
  38. package/dist/store/frontend-compatibility.d.ts +18 -0
  39. package/dist/store/frontend-compatibility.js +93 -0
  40. package/dist/store/frontend-compatibility.js.map +1 -0
  41. package/dist/store/interfaces.d.ts +6 -0
  42. package/dist/store/interfaces.js +126 -0
  43. package/dist/store/interfaces.js.map +1 -0
  44. package/dist/store/listing.d.ts +2 -0
  45. package/dist/store/listing.js +180 -0
  46. package/dist/store/listing.js.map +1 -0
  47. package/dist/store/operations.d.ts +8 -0
  48. package/dist/store/operations.js +254 -0
  49. package/dist/store/operations.js.map +1 -0
  50. package/dist/store/registry.d.ts +4 -0
  51. package/dist/store/registry.js +61 -0
  52. package/dist/store/registry.js.map +1 -0
  53. package/dist/store/requirements.d.ts +14 -0
  54. package/dist/store/requirements.js +81 -0
  55. package/dist/store/requirements.js.map +1 -0
  56. package/dist/store/runtime.d.ts +11 -0
  57. package/dist/store/runtime.js +78 -0
  58. package/dist/store/runtime.js.map +1 -0
  59. package/dist/store/tarball.d.ts +2 -0
  60. package/dist/store/tarball.js +80 -0
  61. package/dist/store/tarball.js.map +1 -0
  62. package/dist/store/types.d.ts +98 -0
  63. package/dist/store/types.js +3 -0
  64. package/dist/store/types.js.map +1 -0
  65. package/dist/test/unit/store/store.test.d.ts +1 -0
  66. package/dist/test/unit/store/store.test.js +127 -0
  67. package/dist/test/unit/store/store.test.js.map +1 -0
  68. package/dist/validation/store.schema.d.ts +39 -0
  69. package/dist/validation/store.schema.js +27 -0
  70. package/dist/validation/store.schema.js.map +1 -0
  71. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreDependencyPicker.vue +136 -0
  72. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreRequirements.vue +98 -0
  73. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleStoreTile.vue +247 -29
  74. package/frontend-vue/layers/dms-layout/app/build/components/pages/modules/ModuleTile.vue +19 -2
  75. package/frontend-vue/layers/dms-layout/app/build/utils/module-store-catalog.ts +186 -120
  76. package/frontend-vue/layers/dms-layout/app/build/utils/module-store-install.ts +146 -0
  77. package/frontend-vue/layers/dms-layout/app/composables/page/useModuleStore.ts +109 -0
  78. package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +335 -34
  79. package/frontend-vue/layers/dms-layout/app/types/module-store.ts +134 -0
  80. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +107 -9
  81. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +107 -9
  82. package/frontend-vue/package.json +3 -3
  83. package/frontend-vue/pnpm-lock.yaml +16 -22
  84. package/frontend-vue/tests/module-store-catalog.test.ts +270 -51
  85. package/frontend-vue/tests/top-list-card-period-badge-ssr.test.ts +212 -0
  86. package/package.json +1 -1
@@ -5,11 +5,30 @@ import {
5
5
  moduleCategoryOptions,
6
6
  } from "../layers/dms-layout/app/build/utils/modules-catalog";
7
7
  import {
8
- MODULE_STORE_CATALOG,
9
- availableStoreModules,
10
- moduleInstallCommand,
11
8
  type ModuleStoreEntry,
9
+ filterByScope,
10
+ isAwaitingFrontend,
11
+ needsDependencyChoice,
12
+ sortStoreModules,
13
+ storeActionBlock,
14
+ storeActions,
15
+ storeTileStatus,
16
+ toModuleStoreEntry,
12
17
  } from "../layers/dms-layout/app/build/utils/module-store-catalog";
18
+ import {
19
+ OTHER_PACKAGE_CHOICE,
20
+ areDependencyDraftsValid,
21
+ buildInstallBody,
22
+ createDependencyDrafts,
23
+ isNpmPackageName,
24
+ parseDependencyConfig,
25
+ validateDependencyDraft,
26
+ } from "../layers/dms-layout/app/build/utils/module-store-install";
27
+ import type {
28
+ MissingInterface,
29
+ StoreListingEntry,
30
+ } from "../layers/dms-layout/app/types/module-store";
31
+ import catalogDocument from "../../data/module-store/catalog.json";
13
32
  import en from "../layers/dms-layout/i18n/locales/layout-en-GB.json";
14
33
  import fr from "../layers/dms-layout/i18n/locales/layout-fr-FR.json";
15
34
 
@@ -33,16 +52,33 @@ const resolve = (value: string): string => {
33
52
  return typeof text === "string" ? text : value;
34
53
  };
35
54
 
36
- function storeEntry(
55
+ function listingEntry(
37
56
  id: string,
38
- overrides: Partial<ModuleStoreEntry> = {},
39
- ): ModuleStoreEntry {
57
+ overrides: Partial<StoreListingEntry> = {},
58
+ ): StoreListingEntry {
40
59
  return {
41
60
  id,
42
- packageName: `@antelopejs/dms-${id}`,
61
+ package: `@antelopejs/dms-${id}`,
62
+ icon: "i-ph-cube",
63
+ category: "data",
43
64
  title: id[0].toUpperCase() + id.slice(1),
44
65
  description: `${id} description`,
45
- icon: "i-ph-cube",
66
+ state: "available",
67
+ updateAvailable: false,
68
+ compatible: true,
69
+ frontend: "none",
70
+ missingInterfaces: [],
71
+ dependents: [],
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ function storeEntry(
77
+ id: string,
78
+ overrides: Partial<ModuleStoreEntry> = {},
79
+ ): ModuleStoreEntry {
80
+ return {
81
+ ...toModuleStoreEntry(listingEntry(id)),
46
82
  catalogCategory: "Data",
47
83
  ...overrides,
48
84
  };
@@ -53,32 +89,95 @@ const catalog = [
53
89
  catalogCategory: "Content",
54
90
  description: "Asset library",
55
91
  }),
56
- storeEntry("database"),
92
+ storeEntry("database", { state: "active", installedVersion: "0.1.2" }),
57
93
  storeEntry("lang", { title: "Translations", catalogCategory: "Content" }),
58
- storeEntry("ai", { catalogCategory: "AI" }),
94
+ storeEntry("ai", {
95
+ catalogCategory: "AI",
96
+ state: "disabled",
97
+ installedVersion: "0.2.1",
98
+ }),
59
99
  ];
60
100
 
101
+ const actionKinds = (entry: StoreListingEntry) =>
102
+ storeActions(entry).map((action) => action.kind);
103
+
61
104
  describe("module store catalog", () => {
62
- it("hides the modules already installed", () => {
63
- const available = availableStoreModules(
64
- catalog,
65
- new Set(["database", "demo"]),
66
- resolve,
67
- );
68
- expect(available.map((entry) => entry.id)).toEqual(["ai", "media", "lang"]);
105
+ it("sorts the modules by their shown name", () => {
106
+ expect(
107
+ sortStoreModules(catalog, resolve).map((entry) => entry.title),
108
+ ).toEqual(["Ai", "Database", "Media", "Translations"]);
69
109
  });
70
110
 
71
- it("lists every module, by name, when none is installed", () => {
111
+ it("splits installed modules, paused ones included, from available ones", () => {
72
112
  expect(
73
- availableStoreModules(catalog, new Set(), resolve).map(
74
- (entry) => entry.title,
113
+ filterByScope(catalog, "installed").map((entry) => entry.id),
114
+ ).toEqual(["database", "ai"]);
115
+ expect(
116
+ filterByScope(catalog, "available").map((entry) => entry.id),
117
+ ).toEqual(["media", "lang"]);
118
+ expect(filterByScope(catalog, "all")).toHaveLength(4);
119
+ });
120
+
121
+ it("counts a failed install as available, a failed update as installed", () => {
122
+ const failedInstall = listingEntry("api", { state: "error" });
123
+ const failedUpdate = listingEntry("api", {
124
+ state: "error",
125
+ installedVersion: "0.3.0",
126
+ });
127
+ expect(filterByScope([failedInstall], "available")).toHaveLength(1);
128
+ expect(filterByScope([failedUpdate], "installed")).toHaveLength(1);
129
+ expect(actionKinds(failedInstall)).toEqual(["install"]);
130
+ expect(actionKinds(failedUpdate)).toEqual(["apply", "uninstall"]);
131
+ });
132
+
133
+ it("offers each state its actions, the main one first", () => {
134
+ expect(actionKinds(listingEntry("a"))).toEqual(["install"]);
135
+ expect(actionKinds(listingEntry("a", { state: "active" }))).toEqual([
136
+ "disable",
137
+ "uninstall",
138
+ ]);
139
+ expect(
140
+ actionKinds(
141
+ listingEntry("a", { state: "active", updateAvailable: true }),
75
142
  ),
76
- ).toEqual(["Ai", "Database", "Media", "Translations"]);
143
+ ).toEqual(["update", "disable", "uninstall"]);
144
+ expect(actionKinds(listingEntry("a", { state: "disabled" }))).toEqual([
145
+ "enable",
146
+ "uninstall",
147
+ ]);
148
+ expect(actionKinds(listingEntry("a", { state: "busy" }))).toEqual([]);
77
149
  });
78
150
 
79
- it("is empty once every module is installed", () => {
80
- const installed = new Set(catalog.map((entry) => entry.id));
81
- expect(availableStoreModules(catalog, installed, resolve)).toEqual([]);
151
+ it("asks for confirmation before uninstalling only", () => {
152
+ const confirmed = storeActions(listingEntry("a", { state: "disabled" }))
153
+ .filter((action) => action.confirm)
154
+ .map((action) => action.kind);
155
+ expect(confirmed).toEqual(["uninstall"]);
156
+ });
157
+
158
+ it("flags an update on an active module's pill", () => {
159
+ expect(
160
+ storeTileStatus(
161
+ listingEntry("a", { state: "active", updateAvailable: true }),
162
+ ).labelKey,
163
+ ).toBe("update_available");
164
+ expect(storeTileStatus(listingEntry("a", { state: "busy" })).dot).toBe(
165
+ "live",
166
+ );
167
+ });
168
+
169
+ it("waits on the frontend only once the operation is over", () => {
170
+ expect(isAwaitingFrontend(listingEntry("a", { frontend: "pending" }))).toBe(
171
+ true,
172
+ );
173
+ expect(
174
+ isAwaitingFrontend(
175
+ listingEntry("a", { state: "busy", frontend: "pending" }),
176
+ ),
177
+ ).toBe(false);
178
+ expect(isAwaitingFrontend(listingEntry("a", { frontend: "unknown" }))).toBe(
179
+ false,
180
+ );
82
181
  });
83
182
 
84
183
  it("searches the name, description, category and id", () => {
@@ -95,23 +194,6 @@ describe("module store catalog", () => {
95
194
  expect(search("payments")).toEqual([]);
96
195
  });
97
196
 
98
- it("filters by category, combined with the search", () => {
99
- expect(
100
- filterModules(catalog, {
101
- query: "",
102
- category: "Content",
103
- resolve,
104
- }).map((entry) => entry.id),
105
- ).toEqual(["media", "lang"]);
106
- expect(
107
- filterModules(catalog, {
108
- query: "lang",
109
- category: "Data",
110
- resolve,
111
- }),
112
- ).toEqual([]);
113
- });
114
-
115
197
  it("offers the categories present, with their counts", () => {
116
198
  expect(moduleCategoryOptions(catalog, resolve)).toEqual([
117
199
  { value: "AI", label: "AI", count: 1 },
@@ -119,31 +201,168 @@ describe("module store catalog", () => {
119
201
  { value: "Data", label: "Data", count: 1 },
120
202
  ]);
121
203
  });
204
+ });
205
+
206
+ const REDIS: MissingInterface = {
207
+ interfacePackage: "@antelopejs/interface-redis",
208
+ candidates: [
209
+ { name: "redis", package: "@antelopejs/redis" },
210
+ { name: "valkey", package: "@antelopejs/valkey" },
211
+ ],
212
+ };
213
+ const QUEUE: MissingInterface = {
214
+ interfacePackage: "@antelopejs/interface-queue",
215
+ candidates: [],
216
+ };
217
+
218
+ const FRONTEND_REQUIREMENT = {
219
+ range: ">=0.7.0",
220
+ running: "0.6.0",
221
+ target: "0.7.2",
222
+ command: "pnpm add @antelopejs/dms-frontend@0.7.2",
223
+ breaks: [],
224
+ };
225
+
226
+ describe("module store blocked actions", () => {
227
+ const released = (overrides: Partial<StoreListingEntry> = {}) =>
228
+ listingEntry("cache", { latestVersion: "1.0.0", ...overrides });
229
+
230
+ it("lets a releasable module install and update", () => {
231
+ expect(storeActionBlock(released(), "install")).toBeUndefined();
232
+ expect(storeActionBlock(released(), "update")).toBeUndefined();
233
+ });
122
234
 
123
- it("shows the AntelopeJS CLI command that adds the package", () => {
124
- expect(moduleInstallCommand(storeEntry("media"))).toBe(
125
- "ajs project modules add @antelopejs/dms-media",
235
+ it("blocks a release without a version, on another interface or dms-frontend", () => {
236
+ expect(storeActionBlock(listingEntry("cache"), "install")).toBe(
237
+ "no_release",
238
+ );
239
+ expect(storeActionBlock(released({ compatible: false }), "update")).toBe(
240
+ "incompatible",
126
241
  );
242
+ expect(
243
+ storeActionBlock(
244
+ released({ frontendRequirement: FRONTEND_REQUIREMENT }),
245
+ "install",
246
+ ),
247
+ ).toBe("frontend");
248
+ });
249
+
250
+ it("asks for the missing interfaces on install, refuses them on update", () => {
251
+ const missing = released({ missingInterfaces: [REDIS] });
252
+ expect(storeActionBlock(missing, "install")).toBeUndefined();
253
+ expect(needsDependencyChoice(missing, "install")).toBe(true);
254
+ expect(storeActionBlock(missing, "update")).toBe("interfaces");
255
+ expect(needsDependencyChoice(missing, "update")).toBe(false);
256
+ expect(needsDependencyChoice(released(), "install")).toBe(false);
257
+ });
258
+
259
+ it("keeps a module others need from being paused or removed", () => {
260
+ const needed = released({ state: "active", dependents: ["@acme/jobs"] });
261
+ expect(storeActionBlock(needed, "disable")).toBe("dependents");
262
+ expect(storeActionBlock(needed, "uninstall")).toBe("dependents");
263
+ expect(storeActionBlock(needed, "enable")).toBeUndefined();
264
+ expect(
265
+ storeActionBlock(released({ state: "active" }), "uninstall"),
266
+ ).toBeUndefined();
127
267
  });
128
268
  });
129
269
 
130
- describe("official module list", () => {
270
+ describe("module store install dialog", () => {
271
+ it("accepts npm package names only", () => {
272
+ expect(isNpmPackageName("redis")).toBe(true);
273
+ expect(isNpmPackageName("@antelopejs/redis")).toBe(true);
274
+ expect(isNpmPackageName("Redis")).toBe(false);
275
+ expect(isNpmPackageName("git+https://github.com/a/b.git")).toBe(false);
276
+ expect(isNpmPackageName("./local/module")).toBe(false);
277
+ expect(isNpmPackageName("@scope")).toBe(false);
278
+ expect(isNpmPackageName("a".repeat(215))).toBe(false);
279
+ });
280
+
281
+ it("reads an empty config as none and refuses anything but an object", () => {
282
+ expect(parseDependencyConfig(" ")).toEqual({ isValid: true });
283
+ expect(parseDependencyConfig('{ "url": "redis://cache" }')).toEqual({
284
+ isValid: true,
285
+ config: { url: "redis://cache" },
286
+ });
287
+ expect(parseDependencyConfig("[]").isValid).toBe(false);
288
+ expect(parseDependencyConfig("null").isValid).toBe(false);
289
+ expect(parseDependencyConfig("{ url: }").isValid).toBe(false);
290
+ });
291
+
292
+ it("preselects the first candidate, or the other package without one", () => {
293
+ expect(
294
+ createDependencyDrafts([REDIS, QUEUE]).map((draft) => draft.choice),
295
+ ).toEqual(["@antelopejs/redis", OTHER_PACKAGE_CHOICE]);
296
+ });
297
+
298
+ it("keeps what an earlier attempt picked for the same interface", () => {
299
+ const [earlier] = createDependencyDrafts([REDIS]);
300
+ const edited = { ...earlier!, choice: "@antelopejs/valkey", config: "{}" };
301
+ expect(createDependencyDrafts([REDIS, QUEUE], [edited])[0]).toEqual(edited);
302
+ });
303
+
304
+ it("flags a missing or invalid package and a config that is not an object", () => {
305
+ const [draft] = createDependencyDrafts([QUEUE]);
306
+ expect(validateDependencyDraft(draft!)).toEqual({
307
+ package: "package_required",
308
+ });
309
+ expect(
310
+ validateDependencyDraft({
311
+ ...draft!,
312
+ otherPackage: "github:acme/queue",
313
+ config: "[1]",
314
+ }),
315
+ ).toEqual({ package: "package_invalid", config: "config_invalid" });
316
+ expect(
317
+ areDependencyDraftsValid([{ ...draft!, otherPackage: " @acme/queue " }]),
318
+ ).toBe(true);
319
+ });
320
+
321
+ it("builds the install body, leaving out an empty config", () => {
322
+ const [redis, queue] = createDependencyDrafts([REDIS, QUEUE]);
323
+ expect(
324
+ buildInstallBody("cache", [
325
+ redis!,
326
+ { ...queue!, otherPackage: "@acme/queue", config: '{"size":2}' },
327
+ ]),
328
+ ).toEqual({
329
+ id: "cache",
330
+ dependencies: [
331
+ {
332
+ interfacePackage: "@antelopejs/interface-redis",
333
+ package: "@antelopejs/redis",
334
+ },
335
+ {
336
+ interfacePackage: "@antelopejs/interface-queue",
337
+ package: "@acme/queue",
338
+ config: { size: 2 },
339
+ },
340
+ ],
341
+ });
342
+ expect(buildInstallBody("cache", [])).toEqual({ id: "cache" });
343
+ });
344
+ });
345
+
346
+ describe("bundled module catalog", () => {
347
+ const entries = catalogDocument.modules.map((entry) =>
348
+ toModuleStoreEntry(listingEntry(entry.id, { category: entry.category })),
349
+ );
350
+
131
351
  it("has unique ids and packages", () => {
132
- const ids = MODULE_STORE_CATALOG.map((entry) => entry.id);
133
- const packages = MODULE_STORE_CATALOG.map((entry) => entry.packageName);
352
+ const ids = catalogDocument.modules.map((entry) => entry.id);
353
+ const packages = catalogDocument.modules.map((entry) => entry.package);
134
354
  expect(new Set(ids).size).toBe(ids.length);
135
355
  expect(new Set(packages).size).toBe(packages.length);
136
356
  });
137
357
 
138
358
  it("translates every name, description and category in both locales", () => {
139
359
  for (const messages of [en, fr]) {
140
- for (const entry of MODULE_STORE_CATALOG) {
360
+ for (const entry of entries) {
141
361
  for (const key of [
142
- entry.title,
143
- entry.description,
362
+ `$modules.store.catalog.${entry.id}.title`,
363
+ `$modules.store.catalog.${entry.id}.description`,
144
364
  entry.catalogCategory,
145
365
  ]) {
146
- expect(key.startsWith("$")).toBe(true);
147
366
  expect(typeof lookup(messages, key.slice(1)), key).toBe("string");
148
367
  }
149
368
  }
@@ -0,0 +1,212 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A TopListCard's period badge across a server render and its hydration,
4
+ * through the runtime's real payload: once its selector set up before it, the
5
+ * server prints the preset whatever it knows of the browser's time zone (a
6
+ * preset reads the same in every zone, and a relative one at every instant),
7
+ * and the browser hydrates it without a mismatch. A selector further down the
8
+ * page leaves a skeleton on both sides, which the label replaces once mounted.
9
+ */
10
+ import {
11
+ createSSRApp,
12
+ defineComponent,
13
+ h,
14
+ nextTick,
15
+ onBeforeUnmount,
16
+ ref,
17
+ type App,
18
+ type Component,
19
+ } from "vue";
20
+ import { renderToString } from "vue/server-renderer";
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
+ import type { PeriodPreset } from "../layers/dms-core/app/composables/period/types";
23
+ import { browserTimeZone } from "../layers/dms-core/app/utils/regional";
24
+ import TopListCard from "../layers/dms-ui/app/components/top-list/TopListCard.vue";
25
+
26
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
27
+ useChartFetch: () => ({
28
+ data: ref({ items: [{ id: "pro", title: "Pro", value: 12 }] }),
29
+ isLoading: ref(false),
30
+ }),
31
+ }));
32
+
33
+ const engine = await import("#dms/frontend-module");
34
+ const { usePeriod } = await import(
35
+ "../layers/dms-core/app/composables/period/usePeriod"
36
+ );
37
+ const { registerPeriodScope } = await import(
38
+ "../layers/dms-core/app/composables/period/usePeriodScope"
39
+ );
40
+ const { TIME_ZONE_COOKIE } = await import(
41
+ "../layers/dms-core/app/composables/period/periodTimeZone"
42
+ );
43
+
44
+ const SCOPE = "dashboard";
45
+ const SKELETON = "SKELETON";
46
+
47
+ function selector(preset: PeriodPreset): Component {
48
+ return defineComponent({
49
+ setup() {
50
+ const period = usePeriod({ defaultPreset: preset });
51
+ onBeforeUnmount(registerPeriodScope(SCOPE, period.state));
52
+ return () => h("i");
53
+ },
54
+ });
55
+ }
56
+
57
+ // A skeleton carries an aria-label; the badge renders its label.
58
+ const Slot = defineComponent({
59
+ props: { ariaLabel: String },
60
+ setup:
61
+ (props, { slots }) =>
62
+ () =>
63
+ h("span", props.ariaLabel ? SKELETON : slots.default?.()),
64
+ });
65
+
66
+ const Card = defineComponent({
67
+ setup: () => () =>
68
+ h(TopListCard, {
69
+ title: "Top plans",
70
+ componentId: "plans",
71
+ pageId: "dashboard",
72
+ fetchUrl: "/api/plans/top",
73
+ periodScope: SCOPE,
74
+ }),
75
+ });
76
+
77
+ function page(children: Component[]): App {
78
+ const app = createSSRApp({
79
+ render: () =>
80
+ h(
81
+ "main",
82
+ children.map((child) => h(child)),
83
+ ),
84
+ });
85
+ for (const name of ["DmsCard", "UBadge", "USkeleton", "DmsTopListRow"])
86
+ app.component(name, Slot);
87
+ return app;
88
+ }
89
+
90
+ function zoneCookie(zone: string): string {
91
+ return `${TIME_ZONE_COOKIE}=${encodeURIComponent(JSON.stringify(zone))}`;
92
+ }
93
+
94
+ async function serverRender(children: Component[], cookies?: string) {
95
+ const runtime = engine.createDmsFrontendRuntime(undefined, {}, true, cookies);
96
+ const app = page(children);
97
+ engine.provideDmsFrontendRuntime(app, runtime);
98
+ engine.setDmsServerRuntimeResolver(() => runtime);
99
+ // What the server sees: no `window`.
100
+ const browserWindow = globalThis.window;
101
+ vi.stubGlobal("window", undefined);
102
+ try {
103
+ const html = await renderToString(app);
104
+ return { html, payload: engine.serializeDmsPayload(runtime) };
105
+ } finally {
106
+ vi.stubGlobal("window", browserWindow);
107
+ }
108
+ }
109
+
110
+ const mounted: App[] = [];
111
+
112
+ async function hydrate(
113
+ children: Component[],
114
+ { html, payload }: { html: string; payload: unknown },
115
+ ) {
116
+ const browser = engine.createDmsFrontendRuntime(undefined, {});
117
+ engine.restoreDmsPayload(
118
+ browser,
119
+ structuredClone(payload) as Parameters<typeof engine.restoreDmsPayload>[1],
120
+ );
121
+ const container = document.createElement("div");
122
+ container.innerHTML = html;
123
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
124
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
125
+ try {
126
+ const app = page(children);
127
+ engine.provideDmsFrontendRuntime(app, browser);
128
+ app.mount(container);
129
+ mounted.push(app);
130
+ await nextTick();
131
+ const mismatches = [...warn.mock.calls, ...error.mock.calls].filter(
132
+ (call) => /hydration|mismatch/i.test(String(call[0])),
133
+ );
134
+ return { container, mismatches };
135
+ } finally {
136
+ warn.mockRestore();
137
+ error.mockRestore();
138
+ }
139
+ }
140
+
141
+ /** The card head's badge: its preset label, or its skeleton. */
142
+ function badge(html: string): string {
143
+ const label = /dms\.period\.presets\.[a-z0-9-]+/.exec(html)?.[0];
144
+ if (label) return label;
145
+ return html.includes(SKELETON) ? SKELETON : "none";
146
+ }
147
+
148
+ beforeEach(() => {
149
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en-US"), t: String }));
150
+ vi.stubGlobal("useTranslation", () => ({
151
+ processI18n: (text: string) => text,
152
+ }));
153
+ vi.stubGlobal("useComponentEvent", () => ({}));
154
+ vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
155
+ vi.stubGlobal("useDmsState", engine.useDmsState);
156
+ vi.stubGlobal("useDmsCookie", engine.useDmsCookie);
157
+ });
158
+
159
+ afterEach(() => {
160
+ mounted.splice(0).forEach((app) => app.unmount());
161
+ vi.unstubAllGlobals();
162
+ });
163
+
164
+ describe("a TopListCard's period badge across hydration", () => {
165
+ it("is in the server's HTML, and hydrates without a mismatch", async () => {
166
+ const children = [selector("this-month"), Card];
167
+ const rendered = await serverRender(
168
+ children,
169
+ zoneCookie(browserTimeZone()),
170
+ );
171
+ expect(badge(rendered.html)).toBe("dms.period.presets.this-month");
172
+
173
+ const { container, mismatches } = await hydrate(children, rendered);
174
+ expect(mismatches).toEqual([]);
175
+ expect(badge(container.innerHTML)).toBe("dms.period.presets.this-month");
176
+ });
177
+
178
+ it("is rendered before the browser's time zone is known", async () => {
179
+ const children = [selector("this-month"), Card];
180
+ const rendered = await serverRender(children);
181
+ expect(badge(rendered.html)).toBe("dms.period.presets.this-month");
182
+
183
+ const { mismatches } = await hydrate(children, rendered);
184
+ expect(mismatches).toEqual([]);
185
+ });
186
+
187
+ it("names a relative preset too", async () => {
188
+ const children = [selector("last-24h"), Card];
189
+ const rendered = await serverRender(
190
+ children,
191
+ zoneCookie(browserTimeZone()),
192
+ );
193
+ expect(badge(rendered.html)).toBe("dms.period.presets.last-24h");
194
+
195
+ const { mismatches } = await hydrate(children, rendered);
196
+ expect(mismatches).toEqual([]);
197
+ });
198
+
199
+ it("waits for a selector set up after the card, on both sides", async () => {
200
+ const children = [Card, selector("last-year")];
201
+ const rendered = await serverRender(
202
+ children,
203
+ zoneCookie(browserTimeZone()),
204
+ );
205
+ expect(badge(rendered.html)).toBe(SKELETON);
206
+
207
+ const { container, mismatches } = await hydrate(children, rendered);
208
+ expect(mismatches).toEqual([]);
209
+ await nextTick();
210
+ expect(badge(container.innerHTML)).toBe("dms.period.presets.last-year");
211
+ });
212
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"