@trypattern/sdk 0.0.0-stage → 0.1.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 (273) hide show
  1. package/README.md +127 -2
  2. package/dist/access.d.ts +31 -0
  3. package/dist/access.js +72 -0
  4. package/dist/actions.d.ts +57 -0
  5. package/dist/actions.js +198 -0
  6. package/dist/api.d.ts +30 -0
  7. package/dist/api.js +60 -0
  8. package/dist/app.d.ts +115 -0
  9. package/dist/app.js +266 -0
  10. package/dist/appInfo.d.ts +35 -0
  11. package/dist/appInfo.js +67 -0
  12. package/dist/appTitle.d.ts +6 -0
  13. package/dist/appTitle.js +3 -0
  14. package/dist/appearance.d.ts +7 -0
  15. package/dist/appearance.js +2 -0
  16. package/dist/auth/sessionApi.d.ts +41 -0
  17. package/dist/auth/sessionApi.js +158 -0
  18. package/dist/auth/sessionStore.d.ts +20 -0
  19. package/dist/auth/sessionStore.js +40 -0
  20. package/dist/auth/user.d.ts +11 -0
  21. package/dist/auth/user.js +9 -0
  22. package/dist/builtins/Account.d.ts +25 -0
  23. package/dist/builtins/Account.js +15 -0
  24. package/dist/builtins/AppSettings.d.ts +12 -0
  25. package/dist/builtins/AppSettings.js +31 -0
  26. package/dist/builtins/Palette.d.ts +29 -0
  27. package/dist/builtins/Palette.js +63 -0
  28. package/dist/builtins/SignIn.d.ts +28 -0
  29. package/dist/builtins/SignIn.js +60 -0
  30. package/dist/builtins/Toasts.d.ts +32 -0
  31. package/dist/builtins/Toasts.js +25 -0
  32. package/dist/builtins/actions.d.ts +2 -0
  33. package/dist/builtins/actions.js +45 -0
  34. package/dist/cache.d.ts +24 -0
  35. package/dist/cache.js +112 -0
  36. package/dist/cli/app.d.ts +2 -0
  37. package/dist/cli/app.js +1406 -0
  38. package/dist/cli/publishing.d.ts +21 -0
  39. package/dist/cli/publishing.js +493 -0
  40. package/dist/cli/screenshot.d.ts +1 -0
  41. package/dist/cli/screenshot.js +96 -0
  42. package/dist/cli/tools.d.ts +12 -0
  43. package/dist/cli/tools.js +35 -0
  44. package/dist/clock.d.ts +6 -0
  45. package/dist/clock.js +31 -0
  46. package/dist/components/AppFrame.d.ts +14 -0
  47. package/dist/components/AppFrame.js +64 -0
  48. package/dist/components/AppearancePicker.d.ts +1 -0
  49. package/dist/components/AppearancePicker.js +12 -0
  50. package/dist/components/Avatar.d.ts +9 -0
  51. package/dist/components/Avatar.js +63 -0
  52. package/dist/components/Badge.d.ts +11 -0
  53. package/dist/components/Badge.js +87 -0
  54. package/dist/components/Button.d.ts +24 -0
  55. package/dist/components/Button.js +252 -0
  56. package/dist/components/Card.d.ts +12 -0
  57. package/dist/components/Card.js +38 -0
  58. package/dist/components/CommandMenu.d.ts +23 -0
  59. package/dist/components/CommandMenu.js +214 -0
  60. package/dist/components/CommandPalette.d.ts +1 -0
  61. package/dist/components/CommandPalette.js +7 -0
  62. package/dist/components/Content.d.ts +7 -0
  63. package/dist/components/Content.js +24 -0
  64. package/dist/components/ContextMenu.d.ts +8 -0
  65. package/dist/components/ContextMenu.js +23 -0
  66. package/dist/components/CopyButton.d.ts +11 -0
  67. package/dist/components/CopyButton.js +16 -0
  68. package/dist/components/Dialog.d.ts +11 -0
  69. package/dist/components/Dialog.js +81 -0
  70. package/dist/components/Divider.d.ts +7 -0
  71. package/dist/components/Divider.js +30 -0
  72. package/dist/components/EmptyState.d.ts +8 -0
  73. package/dist/components/EmptyState.js +24 -0
  74. package/dist/components/Form.d.ts +9 -0
  75. package/dist/components/Form.js +23 -0
  76. package/dist/components/Icon.d.ts +10 -0
  77. package/dist/components/Icon.js +22 -0
  78. package/dist/components/IconButton.d.ts +14 -0
  79. package/dist/components/IconButton.js +53 -0
  80. package/dist/components/Kbd.d.ts +6 -0
  81. package/dist/components/Kbd.js +67 -0
  82. package/dist/components/Link.d.ts +14 -0
  83. package/dist/components/Link.js +44 -0
  84. package/dist/components/ListRow.d.ts +12 -0
  85. package/dist/components/ListRow.js +62 -0
  86. package/dist/components/LoadingRows.d.ts +5 -0
  87. package/dist/components/LoadingRows.js +6 -0
  88. package/dist/components/Menu.d.ts +12 -0
  89. package/dist/components/Menu.js +33 -0
  90. package/dist/components/MenuItems.d.ts +21 -0
  91. package/dist/components/MenuItems.js +32 -0
  92. package/dist/components/Placement.d.ts +21 -0
  93. package/dist/components/Placement.js +1 -0
  94. package/dist/components/Popup.d.ts +8 -0
  95. package/dist/components/Popup.js +43 -0
  96. package/dist/components/PopupItem.d.ts +8 -0
  97. package/dist/components/PopupItem.js +51 -0
  98. package/dist/components/PropertyRow.d.ts +7 -0
  99. package/dist/components/PropertyRow.js +23 -0
  100. package/dist/components/RadioGroup.d.ts +18 -0
  101. package/dist/components/RadioGroup.js +85 -0
  102. package/dist/components/Row.d.ts +16 -0
  103. package/dist/components/Row.js +27 -0
  104. package/dist/components/Screen.d.ts +13 -0
  105. package/dist/components/Screen.js +56 -0
  106. package/dist/components/Scrim.d.ts +8 -0
  107. package/dist/components/Scrim.js +40 -0
  108. package/dist/components/ScrollArea.d.ts +15 -0
  109. package/dist/components/ScrollArea.js +13 -0
  110. package/dist/components/SegmentedControl.d.ts +17 -0
  111. package/dist/components/SegmentedControl.js +68 -0
  112. package/dist/components/Select.d.ts +17 -0
  113. package/dist/components/Select.js +107 -0
  114. package/dist/components/SettingsRow.d.ts +8 -0
  115. package/dist/components/SettingsRow.js +32 -0
  116. package/dist/components/Sidebar.d.ts +20 -0
  117. package/dist/components/Sidebar.js +137 -0
  118. package/dist/components/SignInScreen.d.ts +1 -0
  119. package/dist/components/SignInScreen.js +40 -0
  120. package/dist/components/Size.d.ts +1 -0
  121. package/dist/components/Size.js +1 -0
  122. package/dist/components/Skeleton.d.ts +8 -0
  123. package/dist/components/Skeleton.js +48 -0
  124. package/dist/components/Spinner.d.ts +6 -0
  125. package/dist/components/Spinner.js +54 -0
  126. package/dist/components/Stack.d.ts +14 -0
  127. package/dist/components/Stack.js +19 -0
  128. package/dist/components/Switch.d.ts +10 -0
  129. package/dist/components/Switch.js +72 -0
  130. package/dist/components/Text.d.ts +21 -0
  131. package/dist/components/Text.js +77 -0
  132. package/dist/components/TextField.d.ts +25 -0
  133. package/dist/components/TextField.js +68 -0
  134. package/dist/components/Toaster.d.ts +1 -0
  135. package/dist/components/Toaster.js +83 -0
  136. package/dist/components/Tooltip.d.ts +8 -0
  137. package/dist/components/Tooltip.js +43 -0
  138. package/dist/components/controlLook.d.ts +70 -0
  139. package/dist/components/controlLook.js +80 -0
  140. package/dist/components/frame.d.ts +4 -0
  141. package/dist/components/frame.js +2 -0
  142. package/dist/components/icons.d.ts +59 -0
  143. package/dist/components/icons.js +111 -0
  144. package/dist/components/interaction.d.ts +12 -0
  145. package/dist/components/interaction.js +22 -0
  146. package/dist/components/spacing.d.ts +118 -0
  147. package/dist/components/spacing.js +46 -0
  148. package/dist/components/tones.d.ts +28 -0
  149. package/dist/components/tones.js +12 -0
  150. package/dist/entityStore.d.ts +46 -0
  151. package/dist/entityStore.js +160 -0
  152. package/dist/flows/accessFlows.d.ts +9 -0
  153. package/dist/flows/accessFlows.js +167 -0
  154. package/dist/flows/browser.d.ts +34 -0
  155. package/dist/flows/browser.js +167 -0
  156. package/dist/flows/contactSheet.d.ts +1 -0
  157. package/dist/flows/contactSheet.js +63 -0
  158. package/dist/flows/flowBrowser.d.ts +25 -0
  159. package/dist/flows/flowBrowser.js +23 -0
  160. package/dist/flows/flowSetup.d.ts +3 -0
  161. package/dist/flows/flowSetup.js +35 -0
  162. package/dist/flows/flows.d.ts +47 -0
  163. package/dist/flows/flows.js +175 -0
  164. package/dist/flows/screenshotName.d.ts +1 -0
  165. package/dist/flows/screenshotName.js +6 -0
  166. package/dist/gate.d.ts +8 -0
  167. package/dist/gate.js +52 -0
  168. package/dist/hooks/appContext.d.ts +15 -0
  169. package/dist/hooks/appContext.js +5 -0
  170. package/dist/hooks/appHooks.d.ts +21 -0
  171. package/dist/hooks/appHooks.js +24 -0
  172. package/dist/hooks/useAnimatesOnChange.d.ts +1 -0
  173. package/dist/hooks/useAnimatesOnChange.js +14 -0
  174. package/dist/hooks/useStoreState.d.ts +6 -0
  175. package/dist/hooks/useStoreState.js +5 -0
  176. package/dist/index.d.ts +44 -0
  177. package/dist/index.js +21 -0
  178. package/dist/memoryApi.d.ts +19 -0
  179. package/dist/memoryApi.js +42 -0
  180. package/dist/model.d.ts +94 -0
  181. package/dist/model.js +243 -0
  182. package/dist/page.d.ts +11 -0
  183. package/dist/page.js +9 -0
  184. package/dist/platform.d.ts +1 -0
  185. package/dist/platform.js +5 -0
  186. package/dist/publishWire.d.ts +271 -0
  187. package/dist/publishWire.js +168 -0
  188. package/dist/react.d.ts +49 -0
  189. package/dist/react.js +40 -0
  190. package/dist/registered.d.ts +60 -0
  191. package/dist/registered.js +19 -0
  192. package/dist/routes.d.ts +38 -0
  193. package/dist/routes.js +21 -0
  194. package/dist/rules/checkAllFiles.d.ts +1 -0
  195. package/dist/rules/checkAllFiles.js +749 -0
  196. package/dist/rules/checkApiRouting.d.ts +1 -0
  197. package/dist/rules/checkApiRouting.js +112 -0
  198. package/dist/rules/lintPlugin.d.ts +252 -0
  199. package/dist/rules/lintPlugin.js +1159 -0
  200. package/dist/rules/messages.d.ts +48 -0
  201. package/dist/rules/messages.js +48 -0
  202. package/dist/rules/themeTokens.d.ts +6 -0
  203. package/dist/rules/themeTokens.js +64 -0
  204. package/dist/schema.d.ts +4 -0
  205. package/dist/schema.js +2 -0
  206. package/dist/server/auth.d.ts +115 -0
  207. package/dist/server/auth.js +134 -0
  208. package/dist/server/authService.d.ts +9 -0
  209. package/dist/server/authService.js +50 -0
  210. package/dist/server/createServer.d.ts +402 -0
  211. package/dist/server/createServer.js +82 -0
  212. package/dist/server/env.d.ts +15 -0
  213. package/dist/server/env.js +1 -0
  214. package/dist/server/index.d.ts +13 -0
  215. package/dist/server/index.js +9 -0
  216. package/dist/server/services.d.ts +9 -0
  217. package/dist/server/services.js +47 -0
  218. package/dist/server/session.d.ts +29 -0
  219. package/dist/server/session.js +65 -0
  220. package/dist/server/sessions.d.ts +92 -0
  221. package/dist/server/sessions.js +47 -0
  222. package/dist/server/settings.d.ts +49 -0
  223. package/dist/server/settings.js +25 -0
  224. package/dist/server/signInWire.d.ts +53 -0
  225. package/dist/server/signInWire.js +23 -0
  226. package/dist/server/tables.d.ts +242 -0
  227. package/dist/server/tables.js +246 -0
  228. package/dist/server/users.d.ts +189 -0
  229. package/dist/server/users.js +49 -0
  230. package/dist/serviceClients.d.ts +5 -0
  231. package/dist/serviceClients.js +70 -0
  232. package/dist/services.d.ts +53 -0
  233. package/dist/services.js +30 -0
  234. package/dist/shortcuts.d.ts +3 -0
  235. package/dist/shortcuts.js +40 -0
  236. package/dist/start.d.ts +20 -0
  237. package/dist/start.js +145 -0
  238. package/dist/state.d.ts +14 -0
  239. package/dist/state.js +17 -0
  240. package/dist/store.d.ts +45 -0
  241. package/dist/store.js +208 -0
  242. package/dist/subscribers.d.ts +5 -0
  243. package/dist/subscribers.js +13 -0
  244. package/dist/table.d.ts +143 -0
  245. package/dist/table.js +91 -0
  246. package/dist/tables.d.ts +77 -0
  247. package/dist/tables.js +216 -0
  248. package/dist/theme/reset.css +84 -0
  249. package/dist/theme/scales.d.ts +76 -0
  250. package/dist/theme/scales.js +82 -0
  251. package/dist/theme/tokens.stylex.d.ts +218 -0
  252. package/dist/theme/tokens.stylex.js +233 -0
  253. package/dist/vite/commandBridge.d.ts +2 -0
  254. package/dist/vite/commandBridge.js +46 -0
  255. package/dist/vite/designManifest.d.ts +25 -0
  256. package/dist/vite/designManifest.js +191 -0
  257. package/dist/vite/localWorker.d.ts +4 -0
  258. package/dist/vite/localWorker.js +60 -0
  259. package/dist/vite/projectFile.d.ts +9 -0
  260. package/dist/vite/projectFile.js +17 -0
  261. package/dist/vite/rootAlias.d.ts +2 -0
  262. package/dist/vite/rootAlias.js +15 -0
  263. package/dist/vite/vite.d.ts +8 -0
  264. package/dist/vite/vite.js +95 -0
  265. package/dist/wire.d.ts +7 -0
  266. package/dist/wire.js +1 -0
  267. package/package.json +103 -3
  268. package/presets/lintPlugin.js +7 -0
  269. package/presets/oxlint.json +167 -0
  270. package/presets/register.d.ts +21 -0
  271. package/presets/server.d.ts +2 -0
  272. package/presets/tsconfig.app.json +29 -0
  273. package/presets/tsconfig.server.json +27 -0
@@ -0,0 +1,167 @@
1
+ import { createMemoryRouter } from 'react-router';
2
+ import { expect, inject, test } from 'vitest';
3
+ import { memoryApi } from "../api.js";
4
+ import { Cache } from "../cache.js";
5
+ import { Gate } from "../gate.js";
6
+ import { routeObjects } from "../routes.js";
7
+ import { ownerIdOf, seededRows } from "../table.js";
8
+ import { publicStoreName } from "../tables.js";
9
+ const STRANGER = 'stranger@access.test';
10
+ function seesAll(level) {
11
+ return level === 'anyone' || level === 'signedIn';
12
+ }
13
+ function strangerOutcome(table, operation) {
14
+ const level = table.access[operation];
15
+ if (level === 'admin' || level === 'server')
16
+ return 'forbidden';
17
+ if (!seesAll(table.access.read))
18
+ return 'missing';
19
+ return level === 'owner' ? 'forbidden' : 'saved';
20
+ }
21
+ function creatorOf(table, admin) {
22
+ const level = table.access.create;
23
+ if (level === 'anyone' || level === 'signedIn')
24
+ return STRANGER;
25
+ return level === 'admin' ? admin : undefined;
26
+ }
27
+ function fieldsOf(table, row) {
28
+ return Object.fromEntries(Object.keys(table.columns).map((key) => [key, row[key]]));
29
+ }
30
+ function idsOf(rows) {
31
+ return rows.map((row) => row.id);
32
+ }
33
+ export function accessFlows({ App }) {
34
+ if (inject('patternVisualFlows') !== undefined) {
35
+ test.skip('Access flows run headless, in app test', () => undefined);
36
+ return;
37
+ }
38
+ const { tables, routes, services, memory } = App.definition;
39
+ const seed = (inject('patternSeed') ?? {});
40
+ const admins = inject('patternAdmins') ?? [];
41
+ const title = inject('patternTitle') ?? '';
42
+ const [admin] = admins;
43
+ async function started(signedInAs) {
44
+ const gate = new Gate();
45
+ const app = new App(createMemoryRouter(routeObjects(routes), { initialEntries: ['/'] }), {
46
+ api: memoryApi(tables, seed, {
47
+ gate,
48
+ signedInAs,
49
+ admins,
50
+ title,
51
+ world: structuredClone(inject('patternWorld') ?? {}),
52
+ ...(services === undefined ? {} : { services }),
53
+ ...(memory === undefined ? {} : { memory })
54
+ }),
55
+ cache: Cache.memory(),
56
+ gate
57
+ });
58
+ await app.whenIdle();
59
+ return app;
60
+ }
61
+ function storeOf(app, name) {
62
+ const stores = Reflect.get(app, 'stores');
63
+ return Reflect.get(Object(stores), name);
64
+ }
65
+ async function visibleIds(app, name) {
66
+ await app.whenIdle();
67
+ const snapshot = Reflect.get(Object(app.getSnapshot()), 'stores');
68
+ const state = Reflect.get(Object(snapshot), name);
69
+ const items = Reflect.get(Object(state), 'items');
70
+ return {
71
+ status: Reflect.get(Object(state), 'status'),
72
+ ids: Array.isArray(items)
73
+ ? items.map((item) => Reflect.get(Object(item), 'id'))
74
+ : []
75
+ };
76
+ }
77
+ if (Object.keys(tables).length === 0) {
78
+ test('Access: schema.ts has no tables yet, so there are no rules to check', () => {
79
+ expect(Object.keys(tables)).toEqual([]);
80
+ });
81
+ }
82
+ for (const table of Object.values(tables)) {
83
+ const name = table.name;
84
+ const rows = seededRows(seed, name);
85
+ const read = table.access.read;
86
+ const owners = [
87
+ ...new Set(rows.flatMap((row) => (row.ownerId ? [row.ownerId] : [])))
88
+ ];
89
+ const ownerEmails = new Map((Reflect.get(seed, name) ?? [])
90
+ .flatMap((row) => typeof row['owner'] === 'string' ? [row['owner'].toLowerCase()] : [])
91
+ .map((email) => [ownerIdOf(email), email]));
92
+ if (read !== 'server') {
93
+ test(`Access: ${name}, signed out, ${read === 'anyone' ? 'every row is visible' : 'nothing loads'}`, async () => {
94
+ const seen = await visibleIds(await started(null), name);
95
+ expect(seen).toEqual(read === 'anyone'
96
+ ? { status: 'ready', ids: idsOf(rows) }
97
+ : { status: 'idle', ids: [] });
98
+ });
99
+ test(`Access: ${name}, a signed-in stranger ${seesAll(read) ? 'sees every row' : "sees none of other people's rows"}`, async () => {
100
+ const seen = await visibleIds(await started(STRANGER), name);
101
+ expect(seen.ids).toEqual(seesAll(read) ? idsOf(rows) : []);
102
+ });
103
+ if (read === 'owner') {
104
+ for (const ownerId of owners) {
105
+ const email = ownerEmails.get(ownerId) ?? ownerId;
106
+ test(`Access: ${name}, ${email} sees only their own rows`, async () => {
107
+ const seen = await visibleIds(await started(email), name);
108
+ expect(seen.ids).toEqual(idsOf(rows.filter((row) => row.ownerId === ownerId)));
109
+ });
110
+ }
111
+ }
112
+ if (admin !== undefined) {
113
+ test(`Access: ${name}, admin ${admin} sees every row`, async () => {
114
+ const seen = await visibleIds(await started(admin), name);
115
+ expect(seen.ids).toEqual(idsOf(rows));
116
+ });
117
+ }
118
+ const [first] = rows;
119
+ if (first !== undefined) {
120
+ for (const operation of ['update', 'delete']) {
121
+ const expected = strangerOutcome(table, operation);
122
+ const verb = operation === 'update' ? 'change' : 'delete';
123
+ test(`Access: ${name}, a signed-in stranger ${expected === 'saved' ? 'can' : 'cannot'} ${verb} someone else's row`, async () => {
124
+ const app = await started(STRANGER);
125
+ const store = storeOf(app, name);
126
+ const before = await visibleIds(app, name);
127
+ const outcome = operation === 'update'
128
+ ? await store.update(first)
129
+ : await store.remove(first.id);
130
+ expect(outcome).toBe(expected);
131
+ if (expected !== 'saved') {
132
+ expect(await visibleIds(app, name)).toEqual(before);
133
+ }
134
+ });
135
+ }
136
+ }
137
+ }
138
+ if (table.public.length > 0) {
139
+ const view = publicStoreName(name);
140
+ test(`Access: ${name}, signed out, ${view} shows only ${table.public.join(', ')}`, async () => {
141
+ const app = await started(null);
142
+ const snapshot = Reflect.get(Object(app.getSnapshot()), 'stores');
143
+ const state = Reflect.get(Object(snapshot), view);
144
+ expect(state).toEqual({
145
+ status: 'ready',
146
+ items: rows.map((row) => Object.fromEntries(['id', ...table.public].map((key) => [key, row[key] ?? null])))
147
+ });
148
+ });
149
+ }
150
+ const [duplicated] = rows;
151
+ const creator = creatorOf(table, admin);
152
+ if (table.unique.length > 0 &&
153
+ duplicated !== undefined &&
154
+ creator !== undefined &&
155
+ read !== 'server') {
156
+ for (const columns of table.unique) {
157
+ test(`Access: ${name}, a second row with the same ${columns.join(', ')} is refused`, async () => {
158
+ const app = await started(creator);
159
+ const before = await visibleIds(app, name);
160
+ const outcome = await storeOf(app, name).create(fieldsOf(table, duplicated));
161
+ expect(outcome).toBe('conflict');
162
+ expect(await visibleIds(app, name)).toEqual(before);
163
+ });
164
+ }
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,34 @@
1
+ import type { Browser, BrowserServer, Page } from 'playwright-core';
2
+ export declare const viewports: {
3
+ readonly desktop: {
4
+ readonly width: 1440;
5
+ readonly height: 900;
6
+ readonly deviceScaleFactor: 1;
7
+ };
8
+ readonly compact: {
9
+ readonly width: 390;
10
+ readonly height: 844;
11
+ readonly deviceScaleFactor: 2;
12
+ };
13
+ };
14
+ export type ViewportName = keyof typeof viewports;
15
+ export declare const viewportNames: readonly ViewportName[];
16
+ export declare function launchChrome(): Promise<Browser>;
17
+ export declare function launchChromeServer(): Promise<BrowserServer>;
18
+ export declare function connectChrome(wsEndpoint: string): Promise<Browser>;
19
+ export type AppPage = {
20
+ page: Page;
21
+ problems: string[];
22
+ send: (command: unknown) => Promise<unknown>;
23
+ dispatch: (command: unknown) => Promise<void>;
24
+ snapshot: () => Promise<unknown>;
25
+ hold: (call: string) => Promise<void>;
26
+ release: (call: string) => Promise<void>;
27
+ tick: () => Promise<void>;
28
+ world: (patch: object) => Promise<void>;
29
+ reload: (path: string) => Promise<void>;
30
+ screenshot: (file: string) => Promise<void>;
31
+ captureAt: (viewport: ViewportName) => Promise<string>;
32
+ close: () => Promise<void>;
33
+ };
34
+ export declare function openApp(browser: Browser, baseUrl: string, path: string, viewport: ViewportName, signedInAs: string | null, seed?: object, admins?: readonly string[], world?: unknown): Promise<AppPage>;
@@ -0,0 +1,167 @@
1
+ import { chromium } from 'playwright-core';
2
+ export const viewports = {
3
+ desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
4
+ compact: { width: 390, height: 844, deviceScaleFactor: 2 }
5
+ };
6
+ export const viewportNames = ['desktop', 'compact'];
7
+ const QUIET_NETWORK_MS = 250;
8
+ const SETTLE_TIMEOUT_MS = 10_000;
9
+ const HOOK_TIMEOUT_MS = 10_000;
10
+ function chromeProblem(error) {
11
+ const reason = (error instanceof Error ? error.message : String(error))
12
+ .split('\n')
13
+ .find((line) => line.trim() !== '');
14
+ return new Error(`Could not launch Google Chrome (${reason ?? 'unknown error'}). Screenshots drive your installed Google Chrome; no browser is bundled. Install it from https://www.google.com/chrome/ (or update it), then rerun.`);
15
+ }
16
+ export async function launchChrome() {
17
+ try {
18
+ return await chromium.launch({ channel: 'chrome' });
19
+ }
20
+ catch (error) {
21
+ throw chromeProblem(error);
22
+ }
23
+ }
24
+ export async function launchChromeServer() {
25
+ try {
26
+ return await chromium.launchServer({ channel: 'chrome' });
27
+ }
28
+ catch (error) {
29
+ throw chromeProblem(error);
30
+ }
31
+ }
32
+ export function connectChrome(wsEndpoint) {
33
+ return chromium.connect(wsEndpoint);
34
+ }
35
+ function sleep(ms) {
36
+ return new Promise((done) => setTimeout(done, ms));
37
+ }
38
+ function watchNetwork(page) {
39
+ let inFlight = 0;
40
+ let lastActivity = Date.now();
41
+ const started = () => {
42
+ inFlight += 1;
43
+ lastActivity = Date.now();
44
+ };
45
+ const ended = () => {
46
+ inFlight = Math.max(0, inFlight - 1);
47
+ lastActivity = Date.now();
48
+ };
49
+ page.on('request', started);
50
+ page.on('requestfinished', ended);
51
+ page.on('requestfailed', ended);
52
+ return async () => {
53
+ const deadline = Date.now() + SETTLE_TIMEOUT_MS;
54
+ while (Date.now() < deadline) {
55
+ if (inFlight === 0 && Date.now() - lastActivity >= QUIET_NETWORK_MS) {
56
+ return;
57
+ }
58
+ await sleep(25);
59
+ }
60
+ };
61
+ }
62
+ function recordProblems(page, problems) {
63
+ const record = (problem) => {
64
+ if (!problems.includes(problem))
65
+ problems.push(problem);
66
+ };
67
+ page.on('console', (message) => {
68
+ if (message.type() === 'error') {
69
+ record(`console error: ${message.text()}`);
70
+ }
71
+ });
72
+ page.on('pageerror', (error) => {
73
+ record(`uncaught page error: ${error.message}`);
74
+ });
75
+ page.on('requestfailed', (request) => {
76
+ record(`request failed: ${request.method()} ${request.url()} (${request.failure()?.errorText ?? 'unknown'})`);
77
+ });
78
+ page.on('response', (response) => {
79
+ if (response.status() >= 400) {
80
+ record(`request failed: ${response.request().method()} ${response.url()} (HTTP ${response.status()})`);
81
+ }
82
+ });
83
+ }
84
+ export async function openApp(browser, baseUrl, path, viewport, signedInAs, seed, admins = [], world = {}) {
85
+ const { width, height, deviceScaleFactor } = viewports[viewport];
86
+ const context = await browser.newContext({
87
+ viewport: { width, height },
88
+ deviceScaleFactor,
89
+ reducedMotion: 'reduce',
90
+ colorScheme: 'light'
91
+ });
92
+ if (signedInAs !== null) {
93
+ const signIn = await context.request.post(new URL('/api/auth/dev-sign-in', baseUrl).href, { data: { email: signedInAs } });
94
+ if (!signIn.ok()) {
95
+ throw new Error(`Dev sign-in as ${signedInAs} failed (HTTP ${signIn.status()}): ${await signIn.text()}`);
96
+ }
97
+ }
98
+ if (seed !== undefined) {
99
+ await context.addInitScript((value) => {
100
+ Object.assign(globalThis, {
101
+ patternSeed: value.seed,
102
+ patternAdmins: value.admins,
103
+ patternWorld: value.world
104
+ });
105
+ }, { seed, admins, world });
106
+ }
107
+ const page = await context.newPage();
108
+ const problems = [];
109
+ recordProblems(page, problems);
110
+ const networkIdle = watchNetwork(page);
111
+ const settle = async () => {
112
+ await networkIdle();
113
+ await page.evaluate(async () => {
114
+ const globals = globalThis;
115
+ await globals.pattern.snapshot();
116
+ await globals.document.fonts.ready;
117
+ await new Promise((done) => globals.requestAnimationFrame(() => globals.requestAnimationFrame(done)));
118
+ });
119
+ };
120
+ const load = async (target) => {
121
+ await page.goto(new URL(target, baseUrl).href);
122
+ await page.waitForFunction(() => 'pattern' in globalThis, undefined, {
123
+ timeout: HOOK_TIMEOUT_MS
124
+ });
125
+ await settle();
126
+ };
127
+ const snapshot = () => page.evaluate(() => globalThis.pattern.snapshot());
128
+ const dispatch = async (command) => {
129
+ const reply = await page.evaluate((sent) => globalThis.pattern.send(sent), command);
130
+ if ('error' in reply)
131
+ throw new Error(reply.error);
132
+ };
133
+ await load(path);
134
+ let devTools;
135
+ return {
136
+ page,
137
+ problems,
138
+ send: async (command) => {
139
+ await dispatch(command);
140
+ return snapshot();
141
+ },
142
+ dispatch,
143
+ snapshot,
144
+ hold: (call) => page.evaluate((held) => globalThis.pattern.hold(held), call),
145
+ release: (call) => page.evaluate((released) => globalThis.pattern.release(released), call),
146
+ tick: () => page.evaluate(() => globalThis.pattern.tick()),
147
+ world: (patch) => page.evaluate((changed) => globalThis.pattern.world(changed), patch),
148
+ reload: load,
149
+ screenshot: async (file) => {
150
+ await settle();
151
+ await page.screenshot({ path: file, animations: 'disabled' });
152
+ },
153
+ captureAt: async (next) => {
154
+ devTools ??= await context.newCDPSession(page);
155
+ await devTools.send('Emulation.setDeviceMetricsOverride', {
156
+ ...viewports[next],
157
+ mobile: false
158
+ });
159
+ await settle();
160
+ const { data } = await devTools.send('Page.captureScreenshot', {
161
+ format: 'png'
162
+ });
163
+ return data;
164
+ },
165
+ close: () => context.close()
166
+ };
167
+ }
@@ -0,0 +1 @@
1
+ export declare function writeContactSheet(wsEndpoint: string, directory: string, file: string): Promise<void>;
@@ -0,0 +1,63 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { connectChrome, viewportNames, viewports } from "./browser.js";
4
+ const COLUMNS = 2;
5
+ const IMAGE_HEIGHT_PX = 400;
6
+ function sheetHtml(directory, names) {
7
+ const sizes = viewportNames
8
+ .map((v) => `${v} ${viewports[v].width}×${viewports[v].height}`)
9
+ .join(' · ');
10
+ const figures = names.map((name) => {
11
+ const shots = viewportNames.flatMap((viewport) => {
12
+ const path = join(directory, `${name}-${viewport}.png`);
13
+ if (!existsSync(path))
14
+ return [];
15
+ const png = readFileSync(path);
16
+ return `<div class="shot"><img src="data:image/png;base64,${png.toString('base64')}"><span>${viewport}</span></div>`;
17
+ });
18
+ return `<figure><figcaption>${name}</figcaption><div class="shots">${shots.join('')}</div></figure>`;
19
+ });
20
+ return `<!doctype html>
21
+ <html><head><style>
22
+ * { box-sizing: border-box; margin: 0; }
23
+ body { background: #e8e8ec; font: 500 22px/1.3 -apple-system, system-ui, sans-serif; color: #202020; }
24
+ main { display: inline-grid; grid-template-columns: repeat(${COLUMNS}, auto); gap: 40px 48px; padding: 40px; }
25
+ header { grid-column: 1 / -1; display: flex; gap: 24px; align-items: baseline; }
26
+ header h1 { font-size: 30px; font-weight: 650; }
27
+ header p { color: #646464; }
28
+ figure { display: flex; flex-direction: column; gap: 12px; }
29
+ figcaption { font-size: 26px; font-weight: 650; }
30
+ .shots { display: flex; gap: 16px; align-items: flex-start; }
31
+ .shot { display: flex; flex-direction: column; gap: 6px; color: #646464; font-size: 18px; }
32
+ img { height: ${IMAGE_HEIGHT_PX}px; border-radius: 6px; box-shadow: 0 0 0 1px rgb(0 0 0 / 0.12), 0 4px 16px rgb(0 0 0 / 0.08); }
33
+ </style></head><body><main>
34
+ <header><h1>Flow screenshots</h1><p>${names.length} captures · ${sizes}</p></header>
35
+ ${figures.join('\n')}
36
+ </main></body></html>`;
37
+ }
38
+ export async function writeContactSheet(wsEndpoint, directory, file) {
39
+ const suffix = new RegExp(`-(${viewportNames.join('|')})\\.png$`);
40
+ const names = [
41
+ ...new Set(readdirSync(directory)
42
+ .filter((entry) => suffix.test(entry))
43
+ .map((entry) => entry.replace(suffix, '')))
44
+ ].sort();
45
+ if (names.length === 0)
46
+ return;
47
+ const browser = await connectChrome(wsEndpoint);
48
+ try {
49
+ const page = await browser.newPage({ deviceScaleFactor: 1 });
50
+ await page.setContent(sheetHtml(directory, names));
51
+ const box = await page.locator('main').boundingBox();
52
+ if (box !== null) {
53
+ await page.setViewportSize({
54
+ width: Math.ceil(box.width),
55
+ height: Math.ceil(box.height)
56
+ });
57
+ }
58
+ await page.screenshot({ path: file, fullPage: true });
59
+ }
60
+ finally {
61
+ await browser.close();
62
+ }
63
+ }
@@ -0,0 +1,25 @@
1
+ import type { ViewportName } from './browser.ts';
2
+ export type VisualFlowSettings = {
3
+ url: string;
4
+ wsEndpoint: string;
5
+ directory: string;
6
+ viewports: readonly ViewportName[];
7
+ };
8
+ declare module 'vitest' {
9
+ interface ProvidedContext {
10
+ patternVisualFlows?: VisualFlowSettings;
11
+ }
12
+ }
13
+ export declare function closeFlowBrowser(): Promise<void>;
14
+ export declare function openFlowPage(settings: VisualFlowSettings, viewport: ViewportName, startAt: string, signedInAs: string | null, seed: object, admins: readonly string[], world: unknown): Promise<{
15
+ send: (command: unknown) => Promise<void>;
16
+ snapshot: () => Promise<unknown>;
17
+ hold: (call: string) => Promise<void>;
18
+ release: (call: string) => Promise<void>;
19
+ tick: () => Promise<void>;
20
+ world: (patch: object) => Promise<void>;
21
+ reload: (path: string) => Promise<void>;
22
+ screenshot: (name: string) => Promise<void>;
23
+ problems: string[];
24
+ close: () => Promise<void>;
25
+ }>;
@@ -0,0 +1,23 @@
1
+ import { connectChrome, openApp } from "./browser.js";
2
+ let browser;
3
+ export async function closeFlowBrowser() {
4
+ const connected = browser;
5
+ browser = undefined;
6
+ await (await connected)?.close();
7
+ }
8
+ export async function openFlowPage(settings, viewport, startAt, signedInAs, seed, admins, world) {
9
+ browser ??= connectChrome(settings.wsEndpoint);
10
+ const app = await openApp(await browser, settings.url, startAt, viewport, signedInAs, seed, admins, world);
11
+ return {
12
+ send: app.dispatch,
13
+ snapshot: app.snapshot,
14
+ hold: app.hold,
15
+ release: app.release,
16
+ tick: app.tick,
17
+ world: app.world,
18
+ reload: app.reload,
19
+ screenshot: (name) => app.screenshot(`${settings.directory}/${name}-${viewport}.png`),
20
+ problems: app.problems,
21
+ close: app.close
22
+ };
23
+ }
@@ -0,0 +1,3 @@
1
+ import type { TestProject } from 'vitest/node';
2
+ export declare function setup(project: TestProject): Promise<void>;
3
+ export declare function teardown(): Promise<void>;
@@ -0,0 +1,35 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { launchChromeServer, viewportNames } from "./browser.js";
4
+ import { writeContactSheet } from "./contactSheet.js";
5
+ const ROOT = process.cwd();
6
+ const SCREENSHOTS_DIRECTORY = join(ROOT, '.dev', 'screenshots');
7
+ const FLOW_SCREENSHOTS_DIRECTORY = join(SCREENSHOTS_DIRECTORY, 'flows');
8
+ const CONTACT_SHEET = join(SCREENSHOTS_DIRECTORY, 'contact-sheet.png');
9
+ let server;
10
+ export async function setup(project) {
11
+ const url = process.env['PATTERN_VISUAL_URL'];
12
+ if (url === undefined) {
13
+ throw new Error('Visual flows need the dev server: run `app test --visual`');
14
+ }
15
+ rmSync(FLOW_SCREENSHOTS_DIRECTORY, { recursive: true, force: true });
16
+ rmSync(CONTACT_SHEET, { force: true });
17
+ mkdirSync(FLOW_SCREENSHOTS_DIRECTORY, { recursive: true });
18
+ server = await launchChromeServer();
19
+ project.provide('patternVisualFlows', {
20
+ url,
21
+ wsEndpoint: server.wsEndpoint(),
22
+ directory: FLOW_SCREENSHOTS_DIRECTORY,
23
+ viewports: viewportNames
24
+ });
25
+ }
26
+ export async function teardown() {
27
+ if (server === undefined)
28
+ return;
29
+ try {
30
+ await writeContactSheet(server.wsEndpoint(), FLOW_SCREENSHOTS_DIRECTORY, CONTACT_SHEET);
31
+ }
32
+ finally {
33
+ await server.close();
34
+ }
35
+ }
@@ -0,0 +1,47 @@
1
+ import { expect } from 'vitest';
2
+ import type { ApiCall } from '../api.ts';
3
+ import type { AppClass, RunningApp } from '../app.ts';
4
+ import type { Seed, Tables } from '../table.ts';
5
+ export { expect };
6
+ export { accessFlows } from './accessFlows.ts';
7
+ declare module 'vitest' {
8
+ interface ProvidedContext {
9
+ patternSeed?: Seed<Tables>;
10
+ patternAdmins?: readonly string[];
11
+ patternTitle?: string;
12
+ patternWorld?: unknown;
13
+ }
14
+ }
15
+ type Expected<Value> = Value extends readonly (infer Item)[] ? readonly Expected<Item>[] : Value extends object ? {
16
+ [Key in keyof Value]?: Expected<Value[Key]>;
17
+ } : Value;
18
+ export type FlowStep<Command, Snapshot, Call extends string> = {
19
+ send: Command;
20
+ } | {
21
+ expect: Expected<Snapshot> & object;
22
+ } | {
23
+ screenshot: string;
24
+ } | {
25
+ hold: Call;
26
+ } | {
27
+ release: Call;
28
+ } | {
29
+ restart: true;
30
+ } | {
31
+ tick: true;
32
+ } | {
33
+ world: object;
34
+ };
35
+ export type FlowOptions<Call extends string> = {
36
+ startAt?: string;
37
+ failing?: readonly Call[];
38
+ signedInAs?: string | null;
39
+ };
40
+ export type FlowBinding<Group extends Tables, Command, Snapshot extends {
41
+ readonly path: string;
42
+ }> = {
43
+ App: AppClass<Group, RunningApp<Command, Snapshot>>;
44
+ };
45
+ export declare function defineFlow<Group extends Tables, Command, Snapshot extends {
46
+ readonly path: string;
47
+ }>({ App }: FlowBinding<Group, Command, Snapshot>): (name: string, steps: readonly FlowStep<Command, Snapshot, ApiCall<Group>>[], { startAt, failing, signedInAs }?: FlowOptions<ApiCall<Group>>) => void;