@starlove/orbit 1.4.1 → 2.0.0
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/Dockerfile +10 -8
- package/README.md +146 -164
- package/dist/full/csp.txt +1 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
- package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
- package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
- package/dist/full/fonts/Mona_Sans/README.txt +10 -0
- package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
- package/dist/full/fonts/Newsreader/README.txt +8 -0
- package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
- package/dist/full/index.html +36 -653
- package/dist/full/js/app.BmuponPJ.js +32 -0
- package/dist/full/js/app.BmuponPJ.js.map +1 -0
- package/dist/full/js/chunk.1fJGQGbW.js +2 -0
- package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
- package/dist/full/js/chunk.5D7JO2zp.js +2 -0
- package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
- package/dist/full/js/chunk.BrNn_D79.js +2 -0
- package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
- package/dist/full/js/chunk.CokJXIqM.js +2 -0
- package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
- package/dist/full/js/chunk.D_Haqy6R.js +2 -0
- package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
- package/dist/full/js/chunk.RO0jsH6I.js +15 -0
- package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
- package/dist/full/js/chunk.dqWOEtBM.js +2 -0
- package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
- package/dist/full/js/warm.f260bbbc.js +15 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg-wide.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.2b154ffe.css +1 -0
- package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_GUIDE.md +114 -0
- package/docs/UPDATING.md +249 -64
- package/lib/adapters/indexeddb/blobs.d.ts +3 -0
- package/lib/adapters/indexeddb/blobs.js +54 -0
- package/lib/adapters/indexeddb/database.d.ts +60 -0
- package/lib/adapters/indexeddb/database.js +126 -0
- package/lib/adapters/indexeddb/index.d.ts +5 -0
- package/lib/adapters/indexeddb/index.js +3 -0
- package/lib/adapters/indexeddb/locks.d.ts +14 -0
- package/lib/adapters/indexeddb/locks.js +40 -0
- package/lib/adapters/indexeddb/store.d.ts +40 -0
- package/lib/adapters/indexeddb/store.js +424 -0
- package/lib/adapters/indexeddb/tables.d.ts +43 -0
- package/lib/adapters/indexeddb/tables.js +127 -0
- package/lib/adapters/memory/blobs.d.ts +3 -0
- package/lib/adapters/memory/blobs.js +44 -0
- package/lib/adapters/memory/board.d.ts +41 -0
- package/lib/adapters/memory/board.js +521 -0
- package/lib/adapters/memory/index.d.ts +2 -0
- package/lib/adapters/memory/index.js +2 -0
- package/lib/adapters/memory/search.d.ts +6 -0
- package/lib/adapters/memory/search.js +30 -0
- package/lib/adapters/memory/store.d.ts +34 -0
- package/lib/adapters/memory/store.js +266 -0
- package/lib/adapters/sqlite/backups.d.ts +53 -0
- package/lib/adapters/sqlite/backups.js +232 -0
- package/lib/adapters/sqlite/blobs.d.ts +14 -0
- package/lib/adapters/sqlite/blobs.js +109 -0
- package/lib/adapters/sqlite/board.d.ts +77 -0
- package/lib/adapters/sqlite/board.js +328 -0
- package/lib/adapters/sqlite/database.d.ts +67 -0
- package/lib/adapters/sqlite/database.js +285 -0
- package/lib/adapters/sqlite/index.d.ts +3 -0
- package/lib/adapters/sqlite/index.js +2 -0
- package/lib/adapters/sqlite/lock.d.ts +11 -0
- package/lib/adapters/sqlite/lock.js +34 -0
- package/lib/adapters/sqlite/records.d.ts +54 -0
- package/lib/adapters/sqlite/records.js +546 -0
- package/lib/adapters/sqlite/schema.d.ts +54 -0
- package/lib/adapters/sqlite/schema.js +276 -0
- package/lib/adapters/sqlite/store.d.ts +39 -0
- package/lib/adapters/sqlite/store.js +385 -0
- package/lib/browser/in-page.d.ts +31 -0
- package/lib/browser/in-page.js +46 -0
- package/lib/browser/old-preview.d.ts +13 -0
- package/lib/browser/old-preview.js +80 -0
- package/lib/browser/preview-board.d.ts +8 -0
- package/lib/browser/preview-board.js +69 -0
- package/lib/cli/main.d.ts +7 -0
- package/lib/cli/main.js +343 -0
- package/lib/cli/orbit.d.ts +8 -0
- package/lib/cli/orbit.js +25 -0
- package/lib/client/http.d.ts +38 -0
- package/lib/client/http.js +334 -0
- package/lib/client/local.d.ts +26 -0
- package/lib/client/local.js +190 -0
- package/lib/client/object-url.d.ts +8 -0
- package/lib/client/object-url.js +21 -0
- package/lib/client/types.d.ts +268 -0
- package/lib/client/types.js +8 -0
- package/lib/commands/agent-tools.d.ts +31 -0
- package/lib/commands/agent-tools.js +100 -0
- package/lib/commands/index.d.ts +56 -0
- package/lib/commands/index.js +298 -0
- package/lib/commands/parse.d.ts +73 -0
- package/lib/commands/parse.js +231 -0
- package/lib/commands/present.d.ts +134 -0
- package/lib/commands/present.js +198 -0
- package/lib/commands/session.d.ts +85 -0
- package/lib/commands/session.js +138 -0
- package/lib/commands/verbs.d.ts +35 -0
- package/lib/commands/verbs.js +842 -0
- package/lib/core/actors.d.ts +18 -0
- package/lib/core/actors.js +45 -0
- package/lib/core/agent.d.ts +128 -0
- package/lib/core/agent.js +323 -0
- package/lib/core/boards.d.ts +113 -0
- package/lib/core/boards.js +288 -0
- package/lib/core/bootstrap.d.ts +47 -0
- package/lib/core/bootstrap.js +100 -0
- package/lib/core/bytes.d.ts +10 -0
- package/lib/core/bytes.js +52 -0
- package/lib/core/card-paths.d.ts +25 -0
- package/lib/core/card-paths.js +44 -0
- package/lib/core/context.d.ts +60 -0
- package/lib/core/context.js +42 -0
- package/lib/core/cross-board.d.ts +81 -0
- package/lib/core/cross-board.js +228 -0
- package/lib/core/describe.d.ts +21 -0
- package/lib/core/describe.js +81 -0
- package/lib/core/errors.d.ts +159 -0
- package/lib/core/errors.js +160 -0
- package/lib/core/files.d.ts +79 -0
- package/lib/core/files.js +262 -0
- package/lib/core/history.d.ts +20 -0
- package/lib/core/history.js +53 -0
- package/lib/core/image-types.d.ts +16 -0
- package/lib/core/image-types.js +36 -0
- package/lib/core/model.d.ts +263 -0
- package/lib/core/model.js +34 -0
- package/lib/core/nebula-link.d.ts +14 -0
- package/lib/core/nebula-link.js +39 -0
- package/lib/core/relations.d.ts +58 -0
- package/lib/core/relations.js +112 -0
- package/lib/core/search.d.ts +38 -0
- package/lib/core/search.js +92 -0
- package/lib/core/sessions.d.ts +44 -0
- package/lib/core/sessions.js +158 -0
- package/lib/core/snapshot-schema.d.ts +295 -0
- package/lib/core/snapshot-schema.js +82 -0
- package/lib/core/snapshots.d.ts +75 -0
- package/lib/core/snapshots.js +409 -0
- package/lib/core/states.d.ts +25 -0
- package/lib/core/states.js +126 -0
- package/lib/core/text.d.ts +40 -0
- package/lib/core/text.js +113 -0
- package/lib/core/tickets.d.ts +69 -0
- package/lib/core/tickets.js +311 -0
- package/lib/core/trello.d.ts +17 -0
- package/lib/core/trello.js +269 -0
- package/lib/core/verdicts.d.ts +28 -0
- package/lib/core/verdicts.js +92 -0
- package/lib/core/views.d.ts +95 -0
- package/lib/core/views.js +186 -0
- package/lib/http/app.d.ts +46 -0
- package/lib/http/app.js +491 -0
- package/lib/mcp-server.d.ts +10 -0
- package/lib/mcp-server.js +120 -0
- package/lib/node/assets.d.ts +7 -0
- package/lib/node/assets.js +68 -0
- package/lib/node/data-folder.d.ts +21 -0
- package/lib/node/data-folder.js +34 -0
- package/lib/node/env.d.ts +38 -0
- package/lib/node/env.js +49 -0
- package/lib/node/host-check.d.ts +24 -0
- package/lib/node/host-check.js +53 -0
- package/lib/node/host-services.d.ts +9 -0
- package/lib/node/host-services.js +150 -0
- package/lib/node/local.d.ts +22 -0
- package/lib/node/local.js +61 -0
- package/lib/node/migrate.d.ts +53 -0
- package/lib/node/migrate.js +635 -0
- package/lib/node/package.d.ts +7 -0
- package/lib/node/package.js +14 -0
- package/lib/node/repo-board.d.ts +49 -0
- package/lib/node/repo-board.js +245 -0
- package/lib/node/serve.d.ts +8 -0
- package/lib/node/serve.js +106 -0
- package/lib/orbit.d.ts +47 -0
- package/lib/orbit.js +44 -0
- package/lib/quiet-sqlite-warning.d.ts +1 -0
- package/lib/quiet-sqlite-warning.js +18 -0
- package/lib/store/blob-path.d.ts +6 -0
- package/lib/store/blob-path.js +12 -0
- package/lib/store/conformance.d.ts +52 -0
- package/lib/store/conformance.js +1449 -0
- package/lib/store/search-words.d.ts +10 -0
- package/lib/store/search-words.js +19 -0
- package/lib/store/types.d.ts +452 -0
- package/lib/store/types.js +34 -0
- package/llms.txt +63 -0
- package/package.json +109 -25
- package/src/cli/orbit.js +11 -658
- package/src/mcp-server.js +16 -876
- package/SKILL-ORBIT.md +0 -151
- package/build.mjs +0 -210
- package/dist/full/js/app.4e6fbe20.js +0 -952
- package/dist/full/js/app.4e6fbe20.js.map +0 -7
- package/dist/full/styles.4e60d824.css +0 -1
- package/docs/AGENT_PROTOCOL.md +0 -424
- package/docs/MCP_SETUP.md +0 -54
- package/docs/ORBIT_DISPATCH.md +0 -280
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -13
- package/docs/setup/orbit-init-wizard.md +0 -174
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +0 -93
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +0 -93
- package/public/fonts/Inter/README.txt +0 -118
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/README.txt +0 -79
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +0 -100
- package/public/fonts/Orbitron/OFL.txt +0 -93
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +0 -68
- package/public/fonts/Rajdhani/OFL.txt +0 -93
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +0 -769
- package/public/js/actor-labels.js +0 -65
- package/public/js/api.js +0 -39
- package/public/js/app.js +0 -45
- package/public/js/artifact-drawer.js +0 -79
- package/public/js/board-export.js +0 -161
- package/public/js/board-menu.js +0 -374
- package/public/js/card-actions.js +0 -545
- package/public/js/config.js +0 -20
- package/public/js/create-card.js +0 -190
- package/public/js/dom.js +0 -19
- package/public/js/drawer.js +0 -273
- package/public/js/font-preference.js +0 -230
- package/public/js/format.js +0 -433
- package/public/js/help-menu.js +0 -31
- package/public/js/icons.js +0 -16
- package/public/js/kanban.js +0 -694
- package/public/js/local-backend.js +0 -1015
- package/public/js/main.js +0 -161
- package/public/js/modal.js +0 -33
- package/public/js/motion-preference.js +0 -64
- package/public/js/panel.js +0 -58
- package/public/js/router.js +0 -134
- package/public/js/search.js +0 -56
- package/public/js/settings-tabs.js +0 -7
- package/public/js/settings.js +0 -1634
- package/public/js/sse.js +0 -159
- package/public/js/starscape.js +0 -332
- package/public/js/state.js +0 -129
- package/public/js/text-editing.js +0 -128
- package/public/js/theme-preference.js +0 -95
- package/public/js/ticket-detail.js +0 -1516
- package/public/js/ticket-typeahead.js +0 -49
- package/public/js/toast.js +0 -89
- package/public/js/topbar-notes.js +0 -155
- package/public/js/transport.js +0 -22
- package/public/js/typeahead.js +0 -206
- package/public/js/unread.js +0 -121
- package/public/js/update-check.js +0 -58
- package/public/js/url-routes.js +0 -94
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg-wide.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +0 -7709
- package/src/core/agent.js +0 -387
- package/src/core/agents-md.js +0 -109
- package/src/core/artifacts.js +0 -149
- package/src/core/attachments.js +0 -238
- package/src/core/auth.js +0 -85
- package/src/core/backups.js +0 -142
- package/src/core/board.js +0 -148
- package/src/core/boards.js +0 -409
- package/src/core/bootstrap.js +0 -170
- package/src/core/css-bundle.js +0 -30
- package/src/core/db.js +0 -315
- package/src/core/delete-board.js +0 -163
- package/src/core/dispatch.js +0 -1007
- package/src/core/events.js +0 -314
- package/src/core/http.js +0 -115
- package/src/core/migrate.js +0 -198
- package/src/core/paths.js +0 -55
- package/src/core/provision-repo-board.js +0 -119
- package/src/core/queries.js +0 -246
- package/src/core/registry.js +0 -211
- package/src/core/relations.js +0 -163
- package/src/core/review-verdicts.js +0 -138
- package/src/core/router.js +0 -556
- package/src/core/search.js +0 -183
- package/src/core/seed.js +0 -234
- package/src/core/snapshots.js +0 -692
- package/src/core/states.js +0 -167
- package/src/core/system-picker.js +0 -109
- package/src/core/tickets.js +0 -400
- package/src/core/util.js +0 -106
- package/src/core/version-check.js +0 -154
- package/src/mcp/orbit-client.js +0 -287
- package/src/server.js +0 -115
|
@@ -0,0 +1,1449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The behaviour every OrbitStore and BlobStore must show, written once and
|
|
3
|
+
* run against each adapter: memory and SQLite in vitest, IndexedDB in real
|
|
4
|
+
* browsers too. It imports no test framework, so a stranger's adapter proves
|
|
5
|
+
* itself the same way:
|
|
6
|
+
*
|
|
7
|
+
* registerStoreConformance({ describe, it }, "my adapter", () => ({ open: () => openMyStore(dir) }));
|
|
8
|
+
*
|
|
9
|
+
* Each case gets a fresh harness. `open()` gives a store over the harness's
|
|
10
|
+
* data; calling it again gives a second store over the same data, as a second
|
|
11
|
+
* process (or tab) would have, which is how the cases prove that writes are
|
|
12
|
+
* serialised and heard everywhere.
|
|
13
|
+
*/
|
|
14
|
+
import { isOrbitError } from "../core/errors.js";
|
|
15
|
+
// Assertions
|
|
16
|
+
class ConformanceFailure extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ConformanceFailure";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function show(value) {
|
|
23
|
+
try {
|
|
24
|
+
const text = JSON.stringify(value);
|
|
25
|
+
return text.length > 600 ? `${text.slice(0, 600)}…` : text;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return String(value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function assert(condition, message) {
|
|
32
|
+
if (!condition)
|
|
33
|
+
throw new ConformanceFailure(message);
|
|
34
|
+
}
|
|
35
|
+
/** Equal values, and for objects exactly the same keys: an extra field fails too. */
|
|
36
|
+
function same(a, b) {
|
|
37
|
+
if (Object.is(a, b))
|
|
38
|
+
return true;
|
|
39
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
|
|
40
|
+
return false;
|
|
41
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
42
|
+
return false;
|
|
43
|
+
const keysA = Object.keys(a);
|
|
44
|
+
const keysB = Object.keys(b);
|
|
45
|
+
if (keysA.length !== keysB.length)
|
|
46
|
+
return false;
|
|
47
|
+
return keysA.every((key) => Object.hasOwn(b, key) && same(a[key], b[key]));
|
|
48
|
+
}
|
|
49
|
+
function assertEqual(actual, expected, message) {
|
|
50
|
+
if (!same(actual, expected)) {
|
|
51
|
+
throw new ConformanceFailure(`${message}\n expected: ${show(expected)}\n actual: ${show(actual)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function assertRejects(action, code, message) {
|
|
55
|
+
let caught;
|
|
56
|
+
let rejected = false;
|
|
57
|
+
try {
|
|
58
|
+
await action();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
rejected = true;
|
|
62
|
+
caught = error;
|
|
63
|
+
}
|
|
64
|
+
assert(rejected, `${message}: expected OrbitError ${code}, but it resolved`);
|
|
65
|
+
const got = caught instanceof Error ? `${caught.name}: ${caught.message}` : String(caught);
|
|
66
|
+
assert(isOrbitError(caught) && caught.code === code, `${message}: expected OrbitError ${code}, got ${got}`);
|
|
67
|
+
}
|
|
68
|
+
/** Waits for `check` to hold: watchers hear other processes' writes a little later. */
|
|
69
|
+
async function eventually(check, message, timeoutMs = 3000) {
|
|
70
|
+
const started = Date.now();
|
|
71
|
+
while (!check()) {
|
|
72
|
+
if (Date.now() - started > timeoutMs)
|
|
73
|
+
throw new ConformanceFailure(`${message} (waited ${timeoutMs} ms)`);
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
// Records
|
|
79
|
+
const T0 = Date.UTC(2026, 0, 1);
|
|
80
|
+
/** A time `n` seconds into the suite's day, so every case's times are fixed. */
|
|
81
|
+
const at = (n) => new Date(T0 + n * 1000).toISOString();
|
|
82
|
+
function boardRecord(id, slug, over = {}) {
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
slug,
|
|
86
|
+
name: `Board ${slug}`,
|
|
87
|
+
repo_url: "",
|
|
88
|
+
default_branch: "main",
|
|
89
|
+
project_notes: "",
|
|
90
|
+
agent_instructions: "",
|
|
91
|
+
ai_enabled: 1,
|
|
92
|
+
created_at: at(0),
|
|
93
|
+
updated_at: at(0),
|
|
94
|
+
...over
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function stateRecord(id, boardId, name, position, over = {}) {
|
|
98
|
+
return { id, board_id: boardId, name, position, is_default: 0, role: null, created_at: at(1), ...over };
|
|
99
|
+
}
|
|
100
|
+
function newTicket(id, boardId, stateId, over = {}) {
|
|
101
|
+
return {
|
|
102
|
+
id,
|
|
103
|
+
board_id: boardId,
|
|
104
|
+
title: `Card ${id}`,
|
|
105
|
+
description: "",
|
|
106
|
+
type: "task",
|
|
107
|
+
parent_ticket_id: null,
|
|
108
|
+
ai_plan: "",
|
|
109
|
+
plan_artifact_path: "",
|
|
110
|
+
implementation_summary: "",
|
|
111
|
+
implementation_artifact_path: "",
|
|
112
|
+
implementation_updates: "",
|
|
113
|
+
nebula_url: "",
|
|
114
|
+
state_id: stateId,
|
|
115
|
+
priority: 2,
|
|
116
|
+
created_by: "local",
|
|
117
|
+
created_at: at(10),
|
|
118
|
+
updated_at: at(10),
|
|
119
|
+
archived_at: null,
|
|
120
|
+
...over
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function ticketRecord(id, boardId, stateId, number, over = {}) {
|
|
124
|
+
return { ...newTicket(id, boardId, stateId, over), number };
|
|
125
|
+
}
|
|
126
|
+
function commentRecord(id, ticketId, over = {}) {
|
|
127
|
+
return { id, ticket_id: ticketId, author: "local", kind: "human_comment", body: `Comment ${id}`, omitted_for_ai: 0, created_at: at(20), ...over };
|
|
128
|
+
}
|
|
129
|
+
function eventRecord(id, ticketId, over = {}) {
|
|
130
|
+
return { id, ticket_id: ticketId, actor: "local", type: "ticket_updated", body_json: JSON.stringify({ n: id }), created_at: at(30), ...over };
|
|
131
|
+
}
|
|
132
|
+
function labelRecord(id, name, over = {}) {
|
|
133
|
+
return { id, board_id: "b1", name, color: "#000000", created_at: at(1), ...over };
|
|
134
|
+
}
|
|
135
|
+
function relationRecord(id, source, target, type = "relates_to", over = {}) {
|
|
136
|
+
return { id, source_ticket_id: source, target_ticket_id: target, type, created_at: at(2), ...over };
|
|
137
|
+
}
|
|
138
|
+
function crossRecord(id, source, board, target, over = {}) {
|
|
139
|
+
return { id, source_ticket_id: source, target_board_id: board, target_ticket_id: target, type: "blocked_by", created_at: at(2), ...over };
|
|
140
|
+
}
|
|
141
|
+
function entryRecord(id, over = {}) {
|
|
142
|
+
return {
|
|
143
|
+
id,
|
|
144
|
+
board_id: "b1",
|
|
145
|
+
type: "decision",
|
|
146
|
+
title: `Entry ${id}`,
|
|
147
|
+
body: "",
|
|
148
|
+
ticket_id: null,
|
|
149
|
+
created_by: "local",
|
|
150
|
+
created_at: at(40),
|
|
151
|
+
updated_at: at(40),
|
|
152
|
+
struck_at: null,
|
|
153
|
+
...over
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function verdictRecord(id, ticketId, over = {}) {
|
|
157
|
+
return {
|
|
158
|
+
id,
|
|
159
|
+
ticket_id: ticketId,
|
|
160
|
+
verdict: "PASS",
|
|
161
|
+
blocking_findings_json: "[]",
|
|
162
|
+
optional_findings_json: "[]",
|
|
163
|
+
evidence_commands_json: "[]",
|
|
164
|
+
reviewer_profile: "",
|
|
165
|
+
reviewer_session_id: "",
|
|
166
|
+
reviewed_commit_sha: "",
|
|
167
|
+
dispatch_run_id: "",
|
|
168
|
+
supersedes_prior_review_id: null,
|
|
169
|
+
created_by: "local",
|
|
170
|
+
created_at: at(3),
|
|
171
|
+
...over
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function attachmentRecord(id, ticketId, over = {}) {
|
|
175
|
+
return {
|
|
176
|
+
id,
|
|
177
|
+
ticket_id: ticketId,
|
|
178
|
+
board_id: "b1",
|
|
179
|
+
original_name: `${id}.png`,
|
|
180
|
+
stored_path: `tickets/${ticketId}/${id}.png`,
|
|
181
|
+
mime_type: "image/png",
|
|
182
|
+
size_bytes: 1,
|
|
183
|
+
sha256: "0",
|
|
184
|
+
created_by: "local",
|
|
185
|
+
created_at: at(3),
|
|
186
|
+
...over
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function documentFor(title, over = {}) {
|
|
190
|
+
return { title, description: "", type: "task", details: "", comments: "", ...over };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A board with three lanes (Todo, the default; Doing; Done, the done lane),
|
|
194
|
+
* made through the directory as Orbit makes boards.
|
|
195
|
+
*/
|
|
196
|
+
async function makeBoard(store, slug = "harbor", id = `board-${slug}`) {
|
|
197
|
+
const lanes = { todo: `${id}-todo`, doing: `${id}-doing`, done: `${id}-done` };
|
|
198
|
+
await store.boards.create(boardRecord(id, slug), async (w) => {
|
|
199
|
+
await w.states.insert(stateRecord(lanes.todo, id, "Todo", 0, { is_default: 1 }));
|
|
200
|
+
await w.states.insert(stateRecord(lanes.doing, id, "Doing", 1, { role: "in_progress" }));
|
|
201
|
+
await w.states.insert(stateRecord(lanes.done, id, "Done", 2, { role: "done" }));
|
|
202
|
+
});
|
|
203
|
+
return { board: await store.board(id), id, ...lanes };
|
|
204
|
+
}
|
|
205
|
+
/** Opens a store, runs `body`, and closes every store the case opened. */
|
|
206
|
+
async function using(harness, body) {
|
|
207
|
+
const store = await harness.open();
|
|
208
|
+
try {
|
|
209
|
+
await body(store);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await store.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Two stores over the same data, as two processes would have. */
|
|
216
|
+
async function usingTwo(harness, body) {
|
|
217
|
+
const here = await harness.open();
|
|
218
|
+
const there = await harness.open();
|
|
219
|
+
try {
|
|
220
|
+
await body(here, there);
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await here.close();
|
|
224
|
+
await there.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function gate() {
|
|
228
|
+
let open = () => undefined;
|
|
229
|
+
const wait = new Promise((resolve) => {
|
|
230
|
+
open = resolve;
|
|
231
|
+
});
|
|
232
|
+
return { open, wait };
|
|
233
|
+
}
|
|
234
|
+
/** A first write that writes nothing but the board's own record. */
|
|
235
|
+
const seedNothing = () => Promise.resolve();
|
|
236
|
+
const numbersOf = (tickets) => tickets.map((ticket) => ticket.number);
|
|
237
|
+
const idsOf = (records) => records.map((record) => record.id);
|
|
238
|
+
// The cases
|
|
239
|
+
export const storeConformanceCases = [
|
|
240
|
+
// The list of boards
|
|
241
|
+
{
|
|
242
|
+
name: "a new store lists no boards and answers null for any board it doesn't have",
|
|
243
|
+
async run(harness) {
|
|
244
|
+
await using(harness, async (store) => {
|
|
245
|
+
assertEqual(await store.boards.list(), [], "list() of a new store");
|
|
246
|
+
assertEqual(await store.boards.get("nope"), null, "get() of an unknown id");
|
|
247
|
+
assertEqual(await store.boards.bySlug("nope"), null, "bySlug() of an unknown slug");
|
|
248
|
+
assertEqual(await store.boards.latest(), null, "latest() with no boards");
|
|
249
|
+
await assertRejects(() => store.board("nope"), "board_not_found", "board() of an unknown id");
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "makes a board with its first write and lists it",
|
|
255
|
+
async run(harness) {
|
|
256
|
+
await using(harness, async (store) => {
|
|
257
|
+
const fields = { name: "Harbor Lights", repo_url: "https://example.com/h.git", default_branch: "trunk", created_at: at(5), updated_at: at(6) };
|
|
258
|
+
const record = boardRecord("b1", "harbor", fields);
|
|
259
|
+
const listed = await store.boards.create(record, async (w) => {
|
|
260
|
+
await w.states.insert(stateRecord("s1", "b1", "Todo", 0));
|
|
261
|
+
});
|
|
262
|
+
const expected = { id: "b1", slug: "harbor", ...fields, last_active_at: at(5) };
|
|
263
|
+
assertEqual({ ...listed, db_path: undefined }, { ...expected, db_path: undefined }, "the listing create() returns");
|
|
264
|
+
assert(typeof listed.db_path === "string", "db_path is a string");
|
|
265
|
+
assertEqual(await store.boards.get("b1"), listed, "get()");
|
|
266
|
+
assertEqual(await store.boards.bySlug("harbor"), listed, "bySlug()");
|
|
267
|
+
assertEqual(await store.boards.list(), [listed], "list()");
|
|
268
|
+
const board = await store.board("b1");
|
|
269
|
+
assertEqual(await board.read((r) => r.board.get()), record, "the board's own record");
|
|
270
|
+
assertEqual(idsOf(await board.read((r) => r.states.list())), ["s1"], "what the first write made");
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "refuses a slug or an id another board has, and leaves nothing of the attempt",
|
|
276
|
+
async run(harness) {
|
|
277
|
+
await using(harness, async (store) => {
|
|
278
|
+
await makeBoard(store, "harbor", "b1");
|
|
279
|
+
let seeded = false;
|
|
280
|
+
await assertRejects(() => store.boards.create(boardRecord("b2", "harbor"), () => { seeded = true; return Promise.resolve(); }), "board_slug_taken", "a second board named harbor");
|
|
281
|
+
await assertRejects(() => store.boards.create(boardRecord("b1", "other"), () => { seeded = true; return Promise.resolve(); }), "duplicate_id", "a second board with id b1");
|
|
282
|
+
assert(!seeded, "the refused boards' first writes never ran");
|
|
283
|
+
assertEqual(idsOf(await store.boards.list()), ["b1"], "only the first board is listed");
|
|
284
|
+
await assertRejects(() => store.board("b2"), "board_not_found", "the refused board");
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
name: "two boards made at once with one slug: exactly one is made",
|
|
290
|
+
async run(harness) {
|
|
291
|
+
await usingTwo(harness, async (here, there) => {
|
|
292
|
+
const results = await Promise.allSettled([
|
|
293
|
+
here.boards.create(boardRecord("b1", "same"), async () => { await pause(5); }),
|
|
294
|
+
there.boards.create(boardRecord("b2", "same"), async () => { await pause(5); })
|
|
295
|
+
]);
|
|
296
|
+
const made = results.filter((result) => result.status === "fulfilled");
|
|
297
|
+
assertEqual(made.length, 1, "boards made");
|
|
298
|
+
const lost = results.find((result) => result.status === "rejected");
|
|
299
|
+
const refusal = lost?.reason;
|
|
300
|
+
assert(isOrbitError(refusal) && refusal.code === "board_slug_taken", `the other is refused with board_slug_taken, got ${String(refusal)}`);
|
|
301
|
+
assertEqual((await here.boards.list()).length, 1, "boards listed");
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: "a board whose first write throws is not made, and its slug stays free",
|
|
307
|
+
async run(harness) {
|
|
308
|
+
await using(harness, async (store) => {
|
|
309
|
+
const failure = new Error("seed failed");
|
|
310
|
+
let caught;
|
|
311
|
+
try {
|
|
312
|
+
await store.boards.create(boardRecord("b1", "harbor"), async (w) => {
|
|
313
|
+
await w.states.insert(stateRecord("s1", "b1", "Todo", 0));
|
|
314
|
+
throw failure;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
caught = error;
|
|
319
|
+
}
|
|
320
|
+
assert(caught === failure, "the seed's own error is rethrown");
|
|
321
|
+
assertEqual(await store.boards.list(), [], "nothing is listed");
|
|
322
|
+
await assertRejects(() => store.board("b1"), "board_not_found", "the board that wasn't made");
|
|
323
|
+
const { board } = await makeBoard(store, "harbor", "b1");
|
|
324
|
+
assertEqual(await board.read((r) => r.tickets.list()), [], "the same slug and id make a fresh board");
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: "lists boards by name in code-point order, then in the order they were made",
|
|
330
|
+
async run(harness) {
|
|
331
|
+
await using(harness, async (store) => {
|
|
332
|
+
for (const [id, name] of [["b1", "harbor"], ["b2", "Zebra"], ["b3", "Émile"], ["b4", "apple"], ["b5", "harbor"]]) {
|
|
333
|
+
await store.boards.create(boardRecord(id, `slug-${id}`, { name }), seedNothing);
|
|
334
|
+
}
|
|
335
|
+
assertEqual(idsOf(await store.boards.list()), ["b2", "b4", "b1", "b5", "b3"], "list() order");
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: "latest() is the board used last, and among equals the one made last",
|
|
341
|
+
async run(harness) {
|
|
342
|
+
await using(harness, async (store) => {
|
|
343
|
+
await store.boards.create(boardRecord("b1", "one", { created_at: at(1) }), seedNothing);
|
|
344
|
+
await store.boards.create(boardRecord("b2", "two", { created_at: at(1) }), seedNothing);
|
|
345
|
+
assertEqual((await store.boards.latest())?.id, "b2", "two boards made at the same time");
|
|
346
|
+
await store.boards.touch("b1", at(50));
|
|
347
|
+
const one = await store.boards.get("b1");
|
|
348
|
+
assertEqual([one?.last_active_at, one?.updated_at], [at(50), at(50)], "touch() sets last_active_at and updated_at");
|
|
349
|
+
assertEqual((await store.boards.latest())?.id, "b1", "after b1 was used");
|
|
350
|
+
await assertRejects(() => store.boards.touch("nope", at(60)), "board_not_found", "touch() of an unknown board");
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "the list follows the board's own record once its write commits",
|
|
356
|
+
async run(harness) {
|
|
357
|
+
await using(harness, async (store) => {
|
|
358
|
+
const { board } = await makeBoard(store, "harbor", "b1");
|
|
359
|
+
await makeBoard(store, "side", "b2");
|
|
360
|
+
await board.write(async (w) => {
|
|
361
|
+
await w.board.update({ slug: "lights", name: "Lights", repo_url: "https://example.com/l.git", default_branch: "dev", updated_at: at(70) });
|
|
362
|
+
});
|
|
363
|
+
const listed = await store.boards.get("b1");
|
|
364
|
+
assertEqual([listed?.slug, listed?.name, listed?.repo_url, listed?.default_branch, listed?.updated_at], ["lights", "Lights", "https://example.com/l.git", "dev", at(70)], "the listing after the write");
|
|
365
|
+
assertEqual((await store.boards.bySlug("lights"))?.id, "b1", "found by its new slug");
|
|
366
|
+
assertEqual(await store.boards.bySlug("harbor"), null, "not by its old one");
|
|
367
|
+
await assertRejects(() => board.write((w) => w.board.update({ slug: "side", name: "Taken" })), "board_slug_taken", "a slug another board has");
|
|
368
|
+
const after = [(await store.boards.get("b1"))?.slug, (await board.read((r) => r.board.get())).name];
|
|
369
|
+
assertEqual(after, ["lights", "Lights"], "the refused write changed nothing");
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: "a write that leaves the board's own record alone leaves its listing alone",
|
|
375
|
+
async run(harness) {
|
|
376
|
+
await using(harness, async (store) => {
|
|
377
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
378
|
+
await store.boards.touch("b1", at(50));
|
|
379
|
+
const before = await store.boards.get("b1");
|
|
380
|
+
await board.write(async (w) => {
|
|
381
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
382
|
+
await w.events.append(eventRecord("e1", "t1", { created_at: at(60) }));
|
|
383
|
+
});
|
|
384
|
+
assertEqual(await store.boards.get("b1"), before, "the listing after a write to the board's cards");
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "removes a board: off the list, its data gone, its slug free",
|
|
390
|
+
async run(harness) {
|
|
391
|
+
await using(harness, async (store) => {
|
|
392
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
393
|
+
await board.write((w) => w.tickets.create(newTicket("t1", "b1", todo)));
|
|
394
|
+
const { removed } = await store.boards.remove("b1");
|
|
395
|
+
assert(Array.isArray(removed) && removed.every((place) => typeof place === "string"), "remove() says what it deleted");
|
|
396
|
+
assertEqual(await store.boards.list(), [], "the list after remove()");
|
|
397
|
+
await assertRejects(() => store.board("b1"), "board_not_found", "board() of a removed board");
|
|
398
|
+
await assertRejects(() => board.read((r) => r.tickets.list()), "board_not_found", "a read through a store opened before the board was removed");
|
|
399
|
+
await assertRejects(() => store.boards.remove("b1"), "board_not_found", "removing it again");
|
|
400
|
+
const again = await makeBoard(store, "harbor", "b1");
|
|
401
|
+
assertEqual(await again.board.read((r) => r.tickets.list()), [], "a new board with its slug and id starts empty");
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
name: "keeps settings, shared by every store over the same data",
|
|
407
|
+
async run(harness) {
|
|
408
|
+
await usingTwo(harness, async (here, there) => {
|
|
409
|
+
assertEqual(await here.settings.get("default_board"), null, "an unset key");
|
|
410
|
+
await here.settings.set("default_board", "harbor");
|
|
411
|
+
assertEqual(await there.settings.get("default_board"), "harbor", "read through the other store");
|
|
412
|
+
await there.settings.set("default_board", "side");
|
|
413
|
+
assertEqual(await here.settings.get("default_board"), "side", "overwritten");
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: "locates the board holding a lane, card, journal entry or relation, looking at the hint first",
|
|
419
|
+
async run(harness) {
|
|
420
|
+
await using(harness, async (store) => {
|
|
421
|
+
const a = await makeBoard(store, "alpha", "b1");
|
|
422
|
+
const b = await makeBoard(store, "beta", "b2");
|
|
423
|
+
await a.board.write(async (w) => {
|
|
424
|
+
await w.tickets.create(newTicket("t1", "b1", a.todo));
|
|
425
|
+
await w.tickets.create(newTicket("t2", "b1", a.todo));
|
|
426
|
+
await w.relations.insert(relationRecord("r1", "t1", "t2"));
|
|
427
|
+
await w.entries.insert(entryRecord("e1"));
|
|
428
|
+
});
|
|
429
|
+
// An import can bring a card id another board already has.
|
|
430
|
+
await b.board.write((w) => w.tickets.create(newTicket("t1", "b2", b.todo)));
|
|
431
|
+
assertEqual((await store.locate("state", b.doing))?.id, "b2", "a lane");
|
|
432
|
+
assertEqual((await store.locate("ticket", "t2"))?.id, "b1", "a card");
|
|
433
|
+
assertEqual((await store.locate("entry", "e1"))?.id, "b1", "a journal entry");
|
|
434
|
+
assertEqual((await store.locate("relation", "r1"))?.id, "b1", "a relation");
|
|
435
|
+
assertEqual((await store.locate("ticket", "t1"))?.id, "b1", "a card on two boards, without a hint: list() order");
|
|
436
|
+
assertEqual((await store.locate("ticket", "t1", "b2"))?.id, "b2", "with a hint");
|
|
437
|
+
assertEqual(await store.locate("ticket", "nope", "b1"), null, "an id nowhere");
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
// Records
|
|
442
|
+
{
|
|
443
|
+
name: "gives back every kind of record exactly as written, with no field added or lost",
|
|
444
|
+
async run(harness) {
|
|
445
|
+
await usingTwo(harness, async (store, other) => {
|
|
446
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
447
|
+
await makeBoard(store, "side", "b2");
|
|
448
|
+
const text = "Ünïcödé 🌌 “quotes”, 'single', back\\slash,\nnew line\ttab and 漢字";
|
|
449
|
+
const ticket = ticketRecord("t1", "b1", todo, 7, {
|
|
450
|
+
title: text,
|
|
451
|
+
description: `${text}\n\n## Steps`,
|
|
452
|
+
type: "epic",
|
|
453
|
+
ai_plan: "1. plan",
|
|
454
|
+
plan_artifact_path: "artifacts/plan.md",
|
|
455
|
+
implementation_summary: "summary",
|
|
456
|
+
implementation_artifact_path: "artifacts/summary.md",
|
|
457
|
+
implementation_updates: "updates",
|
|
458
|
+
nebula_url: "https://nebula.starscape.app/app/p/x/a.md",
|
|
459
|
+
priority: 4,
|
|
460
|
+
created_by: "crea",
|
|
461
|
+
created_at: at(11),
|
|
462
|
+
updated_at: at(12),
|
|
463
|
+
archived_at: at(13)
|
|
464
|
+
});
|
|
465
|
+
const child = ticketRecord("t2", "b1", todo, 8, { type: "feature", parent_ticket_id: "t1", priority: 0 });
|
|
466
|
+
const label = labelRecord("l1", text, { color: "#123456", created_at: at(14) });
|
|
467
|
+
const comment = commentRecord("c1", "t2", { author: "crea", kind: "agent_note", body: text, omitted_for_ai: 1, created_at: at(15) });
|
|
468
|
+
const relation = relationRecord("r1", "t2", "t1", "loves", { created_at: at(16) });
|
|
469
|
+
const cross = crossRecord("x1", "t2", "b2", "elsewhere", { created_at: at(17) });
|
|
470
|
+
const entry = entryRecord("e1", {
|
|
471
|
+
type: "lesson",
|
|
472
|
+
title: text,
|
|
473
|
+
body: text,
|
|
474
|
+
ticket_id: "t2",
|
|
475
|
+
created_at: at(18),
|
|
476
|
+
updated_at: at(19),
|
|
477
|
+
struck_at: at(20)
|
|
478
|
+
});
|
|
479
|
+
const first = {
|
|
480
|
+
id: "v1",
|
|
481
|
+
ticket_id: "t2",
|
|
482
|
+
verdict: "BLOCK",
|
|
483
|
+
blocking_findings_json: '["Plank 7"]',
|
|
484
|
+
optional_findings_json: "[]",
|
|
485
|
+
evidence_commands_json: '["pier --walk"]',
|
|
486
|
+
reviewer_profile: "secu",
|
|
487
|
+
reviewer_session_id: "s-1",
|
|
488
|
+
reviewed_commit_sha: "abc123",
|
|
489
|
+
dispatch_run_id: "run-1",
|
|
490
|
+
supersedes_prior_review_id: null,
|
|
491
|
+
created_by: "secu",
|
|
492
|
+
created_at: at(21)
|
|
493
|
+
};
|
|
494
|
+
const second = { ...first, id: "v2", verdict: "PASS", supersedes_prior_review_id: "v1", created_at: at(22) };
|
|
495
|
+
const attachment = attachmentRecord("a1", "t2", { original_name: "roof.png", size_bytes: 1234, sha256: "f".repeat(64), created_at: at(23) });
|
|
496
|
+
const event = eventRecord("ev1", "t2", { actor: "crea", type: "agent_claimed", body_json: '{"agent_id":"crea","n":[1,2]}', created_at: at(24) });
|
|
497
|
+
const boardRow = boardRecord("b1", "harbor", { name: text, project_notes: text, agent_instructions: text, ai_enabled: 0, updated_at: at(25) });
|
|
498
|
+
await board.write(async (w) => {
|
|
499
|
+
await w.tickets.insert(ticket);
|
|
500
|
+
await w.tickets.insert(child);
|
|
501
|
+
await w.labels.insert(label);
|
|
502
|
+
await w.ticketLabels.add("t2", "l1");
|
|
503
|
+
await w.comments.insert(comment);
|
|
504
|
+
await w.relations.insert(relation);
|
|
505
|
+
await w.crossBoardRelations.insert(cross);
|
|
506
|
+
await w.entries.insert(entry);
|
|
507
|
+
await w.verdicts.insert(first);
|
|
508
|
+
await w.verdicts.insert(second);
|
|
509
|
+
await w.attachments.insert(attachment);
|
|
510
|
+
await w.events.append(event);
|
|
511
|
+
await w.board.set(boardRow);
|
|
512
|
+
});
|
|
513
|
+
for (const [where, reading] of [["this store", board], ["another store", await other.board("b1")]]) {
|
|
514
|
+
await reading.read(async (r) => {
|
|
515
|
+
assertEqual(await r.board.get(), boardRow, `the board's record, through ${where}`);
|
|
516
|
+
assertEqual(await r.tickets.get("t1"), ticket, `a card with every field set, through ${where}`);
|
|
517
|
+
assertEqual(await r.tickets.get("t2"), child, `a sub-card, through ${where}`);
|
|
518
|
+
assertEqual(await r.labels.list(), [label], `labels, through ${where}`);
|
|
519
|
+
assertEqual(await r.ticketLabels.list(), [{ ticket_id: "t2", label_id: "l1" }], `label links, through ${where}`);
|
|
520
|
+
assertEqual(await r.comments.get("c1"), comment, `a comment, through ${where}`);
|
|
521
|
+
assertEqual(await r.relations.get("r1"), relation, `a relation of a type Orbit doesn't name, through ${where}`);
|
|
522
|
+
assertEqual(await r.crossBoardRelations.list(), [cross], `a cross-board relation, through ${where}`);
|
|
523
|
+
assertEqual(await r.entries.get("e1"), entry, `a journal entry, through ${where}`);
|
|
524
|
+
assertEqual(await r.verdicts.list("t2"), [first, second], `review verdicts, through ${where}`);
|
|
525
|
+
assertEqual(await r.attachments.get("a1"), attachment, `an image's record, through ${where}`);
|
|
526
|
+
assertEqual(await r.events.list(), [event], `an event, through ${where}`);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: "hands out copies: changing a record after writing or reading it changes nothing stored",
|
|
534
|
+
async run(harness) {
|
|
535
|
+
await using(harness, async (store) => {
|
|
536
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
537
|
+
const written = ticketRecord("t1", "b1", todo, 1, { title: "Original" });
|
|
538
|
+
await board.write(async (w) => {
|
|
539
|
+
await w.tickets.insert(written);
|
|
540
|
+
written.title = "Changed after insert";
|
|
541
|
+
const inside = await w.tickets.get("t1");
|
|
542
|
+
if (inside)
|
|
543
|
+
inside.title = "Changed inside the write";
|
|
544
|
+
});
|
|
545
|
+
const read = await board.read((r) => r.tickets.get("t1"));
|
|
546
|
+
assertEqual(read?.title, "Original", "the stored title");
|
|
547
|
+
if (read)
|
|
548
|
+
read.title = "Changed after read";
|
|
549
|
+
const listed = await board.read((r) => r.tickets.list());
|
|
550
|
+
const first = listed[0];
|
|
551
|
+
if (first)
|
|
552
|
+
first.priority = 4;
|
|
553
|
+
const again = await board.read((r) => r.tickets.get("t1"));
|
|
554
|
+
assertEqual([again?.title, again?.priority], ["Original", 2], "after changing what reads handed out");
|
|
555
|
+
const lanes = await board.read((r) => r.states.list());
|
|
556
|
+
lanes.reverse();
|
|
557
|
+
assertEqual(idsOf(await board.read((r) => r.states.list())), [todo, "b1-doing", "b1-done"], "a list handed out is the caller's");
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: "lists every kind of record in its stated order",
|
|
563
|
+
async run(harness) {
|
|
564
|
+
await usingTwo(harness, async (store, other) => {
|
|
565
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
566
|
+
await board.write(async (w) => {
|
|
567
|
+
await w.states.insert(stateRecord("s-mid", "b1", "Middle", 1));
|
|
568
|
+
await w.states.update("b1-doing", { position: 5 });
|
|
569
|
+
for (const [id, name] of [["l1", "tower"], ["l2", "Zebra"], ["l3", "électrique"], ["l4", "apple"]]) {
|
|
570
|
+
await w.labels.insert(labelRecord(id, name));
|
|
571
|
+
}
|
|
572
|
+
for (const [id, number] of [["t3", 3], ["t1", 1], ["t2", 2]])
|
|
573
|
+
await w.tickets.insert(ticketRecord(id, "b1", todo, number));
|
|
574
|
+
await w.ticketLabels.set("t1", ["l1", "l4", "l3"]);
|
|
575
|
+
// Times written out of order, and ties: by time, then the order written.
|
|
576
|
+
await w.comments.insert(commentRecord("c-late", "t1", { created_at: at(30) }));
|
|
577
|
+
await w.comments.insert(commentRecord("c-tie-1", "t1", { created_at: at(20) }));
|
|
578
|
+
await w.comments.insert(commentRecord("c-early", "t1", { created_at: at(10) }));
|
|
579
|
+
await w.comments.insert(commentRecord("c-tie-2", "t1", { created_at: at(20) }));
|
|
580
|
+
await w.comments.insert(commentRecord("c-other", "t2", { created_at: at(15) }));
|
|
581
|
+
await w.events.append(eventRecord("e-late", "t1", { created_at: at(30) }));
|
|
582
|
+
await w.events.append(eventRecord("e-tie-1", "t1", { created_at: at(20) }));
|
|
583
|
+
await w.events.append(eventRecord("e-early", null, { created_at: at(10) }));
|
|
584
|
+
await w.events.append(eventRecord("e-tie-2", "t2", { created_at: at(20) }));
|
|
585
|
+
for (const [id, time] of [["j-late", 30], ["j-tie-1", 20], ["j-tie-2", 20]]) {
|
|
586
|
+
await w.entries.insert(entryRecord(id, { created_at: at(time), updated_at: at(time) }));
|
|
587
|
+
}
|
|
588
|
+
for (const [id, time] of [["r-late", 30], ["r-early", 10]]) {
|
|
589
|
+
await w.relations.insert(relationRecord(id, "t1", id === "r-late" ? "t2" : "t3", "relates_to", { created_at: at(time) }));
|
|
590
|
+
}
|
|
591
|
+
for (const [id, time] of [["a-late", 30], ["a-early", 10]]) {
|
|
592
|
+
await w.attachments.insert(attachmentRecord(id, "t2", { created_at: at(time) }));
|
|
593
|
+
}
|
|
594
|
+
for (const [id, time] of [["v-late", 30], ["v-tie-1", 20], ["v-tie-2", 20]]) {
|
|
595
|
+
await w.verdicts.insert(verdictRecord(id, "t3", { created_at: at(time) }));
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
// A later write links them again in another order: the order is the new one's.
|
|
599
|
+
await board.write((w) => w.ticketLabels.set("t2", ["l2", "l1"]));
|
|
600
|
+
await board.write((w) => w.ticketLabels.set("t2", ["l1", "l2"]));
|
|
601
|
+
for (const [where, reading] of [["this store", board], ["another store", await other.board("b1")]]) {
|
|
602
|
+
await reading.read(async (r) => {
|
|
603
|
+
assertEqual((await r.ticketLabels.list(["t2"])).map((link) => link.label_id), ["l1", "l2"], `links set again, in their new order, through ${where}`);
|
|
604
|
+
assertEqual(idsOf(await r.states.list()), [todo, "s-mid", "b1-done", "b1-doing"], `lanes by position, then the order written, through ${where}`);
|
|
605
|
+
assertEqual(idsOf(await r.labels.list()), ["l2", "l4", "l1", "l3"], `labels by name in code-point order, through ${where}`);
|
|
606
|
+
assertEqual(numbersOf(await r.tickets.list()), [1, 2, 3], `cards by number, through ${where}`);
|
|
607
|
+
assertEqual((await r.ticketLabels.list(["t1"])).map((link) => link.label_id), ["l1", "l4", "l3"], `a card's labels in the order they were linked, through ${where}`);
|
|
608
|
+
assertEqual(idsOf(await r.comments.list("t1")), ["c-early", "c-tie-1", "c-tie-2", "c-late"], `a card's comments, through ${where}`);
|
|
609
|
+
assertEqual(idsOf(await r.comments.list()), ["c-early", "c-other", "c-tie-1", "c-tie-2", "c-late"], `every comment on the board, through ${where}`);
|
|
610
|
+
assertEqual(idsOf(await r.events.list()), ["e-early", "e-tie-1", "e-tie-2", "e-late"], `events, through ${where}`);
|
|
611
|
+
assertEqual(idsOf(await r.entries.list()), ["j-tie-1", "j-tie-2", "j-late"], `journal entries, through ${where}`);
|
|
612
|
+
assertEqual(idsOf(await r.relations.list()), ["r-early", "r-late"], `relations, through ${where}`);
|
|
613
|
+
assertEqual(idsOf(await r.attachments.list()), ["a-early", "a-late"], `images, through ${where}`);
|
|
614
|
+
assertEqual(idsOf(await r.verdicts.list("t3")), ["v-tie-1", "v-tie-2", "v-late"], `verdicts, through ${where}`);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
// Cards
|
|
621
|
+
{
|
|
622
|
+
name: "finds cards by id, number and title, ignoring the case of ASCII letters only",
|
|
623
|
+
async run(harness) {
|
|
624
|
+
await using(harness, async (store) => {
|
|
625
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
626
|
+
await board.write(async (w) => {
|
|
627
|
+
await w.tickets.insert(ticketRecord("t1", "b1", todo, 1, { title: "Lamp Check" }));
|
|
628
|
+
await w.tickets.insert(ticketRecord("t2", "b1", todo, 2, { title: "lamp check" }));
|
|
629
|
+
await w.tickets.insert(ticketRecord("t3", "b1", todo, 3, { title: "LAMP CHECK", archived_at: at(5) }));
|
|
630
|
+
await w.tickets.insert(ticketRecord("t4", "b1", todo, 4, { title: "Émile" }));
|
|
631
|
+
});
|
|
632
|
+
await board.read(async (r) => {
|
|
633
|
+
assertEqual((await r.tickets.get("t4"))?.number, 4, "by id");
|
|
634
|
+
assertEqual((await r.tickets.byNumber(2))?.id, "t2", "by number");
|
|
635
|
+
assertEqual(await r.tickets.byNumber(9), null, "a number no card has");
|
|
636
|
+
assertEqual((await r.tickets.byTitle("lAmP cHeCk"))?.id, "t2", "by title: open before archived, then the highest number");
|
|
637
|
+
assertEqual((await r.tickets.byTitle("Émile"))?.id, "t4", "a title outside ASCII, exactly");
|
|
638
|
+
assertEqual(await r.tickets.byTitle("émile"), null, "letters outside ASCII keep their case");
|
|
639
|
+
assertEqual(await r.tickets.byTitle("Lamp"), null, "only the whole title matches");
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
name: "lists cards by every filter given",
|
|
646
|
+
async run(harness) {
|
|
647
|
+
await using(harness, async (store) => {
|
|
648
|
+
const { board, todo, doing } = await makeBoard(store, "harbor", "b1");
|
|
649
|
+
await board.write(async (w) => {
|
|
650
|
+
await w.tickets.insert(ticketRecord("epic", "b1", todo, 1, { type: "epic" }));
|
|
651
|
+
await w.tickets.insert(ticketRecord("t2", "b1", doing, 2, { parent_ticket_id: "epic" }));
|
|
652
|
+
await w.tickets.insert(ticketRecord("t3", "b1", todo, 3, { parent_ticket_id: "epic", archived_at: at(9) }));
|
|
653
|
+
await w.tickets.insert(ticketRecord("t4", "b1", doing, 4));
|
|
654
|
+
});
|
|
655
|
+
await board.read(async (r) => {
|
|
656
|
+
assertEqual(numbersOf(await r.tickets.list()), [1, 2, 3, 4], "no filter: archived cards too");
|
|
657
|
+
assertEqual(numbersOf(await r.tickets.list({ archived: false })), [1, 2, 4], "open cards");
|
|
658
|
+
assertEqual(numbersOf(await r.tickets.list({ archived: true })), [3], "archived cards");
|
|
659
|
+
assertEqual(numbersOf(await r.tickets.list({ parent: "epic" })), [2, 3], "an epic's sub-cards");
|
|
660
|
+
assertEqual(numbersOf(await r.tickets.list({ parent: "epic", archived: false })), [2], "its open sub-cards");
|
|
661
|
+
assertEqual(numbersOf(await r.tickets.list({ state: doing })), [2, 4], "a lane's cards");
|
|
662
|
+
assertEqual(numbersOf(await r.tickets.list({ ids: ["t4", "nope", "epic"] })), [1, 4], "chosen cards, unknown ids skipped");
|
|
663
|
+
assertEqual(numbersOf(await r.tickets.list({ ids: [] })), [], "no ids, no cards");
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: "numbers a new card one past the highest number on the board, archived cards included",
|
|
670
|
+
async run(harness) {
|
|
671
|
+
await using(harness, async (store) => {
|
|
672
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
673
|
+
assertEqual(await board.read((r) => r.tickets.lastNumber()), 0, "an empty board");
|
|
674
|
+
const first = await board.write((w) => w.tickets.create(newTicket("t1", "b1", todo)));
|
|
675
|
+
assertEqual(first, ticketRecord("t1", "b1", todo, 1), "the first card, as create() returns it");
|
|
676
|
+
await board.write((w) => w.tickets.insert(ticketRecord("t41", "b1", todo, 41, { archived_at: at(3) })));
|
|
677
|
+
assertEqual(await board.read((r) => r.tickets.lastNumber()), 41, "lastNumber() counts archived cards");
|
|
678
|
+
const next = await board.write((w) => w.tickets.create(newTicket("t2", "b1", todo)));
|
|
679
|
+
assertEqual(next.number, 42, "after an archived #41");
|
|
680
|
+
await board.write((w) => w.tickets.remove("t2"));
|
|
681
|
+
const reused = await board.write((w) => w.tickets.create(newTicket("t3", "b1", todo)));
|
|
682
|
+
assertEqual(reused.number, 42, "removing the highest card frees its number, as one past the highest says");
|
|
683
|
+
await assertRejects(() => board.write((w) => w.tickets.insert(ticketRecord("t9", "b1", todo, 42))), "ticket_number_taken", "inserting a number in use");
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
name: "twenty cards made at once, through two stores, get twenty different numbers",
|
|
689
|
+
async run(harness) {
|
|
690
|
+
await usingTwo(harness, async (here, there) => {
|
|
691
|
+
const { todo } = await makeBoard(here, "harbor", "b1");
|
|
692
|
+
const boards = [await here.board("b1"), await there.board("b1")];
|
|
693
|
+
const made = await Promise.all(Array.from({ length: 20 }, (_, i) => boards[i % 2].write(async (w) => {
|
|
694
|
+
const top = await w.tickets.lastNumber();
|
|
695
|
+
await pause(i % 3);
|
|
696
|
+
const ticket = await w.tickets.create(newTicket(`t${i}`, "b1", todo));
|
|
697
|
+
assert(ticket.number === top + 1, `a write's card follows what it read: ${top} then ${ticket.number}`);
|
|
698
|
+
return ticket.number;
|
|
699
|
+
})));
|
|
700
|
+
assertEqual([...made].sort((a, b) => a - b), Array.from({ length: 20 }, (_, i) => i + 1), "the numbers given");
|
|
701
|
+
const stored = await (await here.board("b1")).read((r) => r.tickets.list());
|
|
702
|
+
assertEqual(numbersOf(stored), Array.from({ length: 20 }, (_, i) => i + 1), "the numbers stored");
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
name: "changes only the fields a patch names, and returns the record",
|
|
708
|
+
async run(harness) {
|
|
709
|
+
await using(harness, async (store) => {
|
|
710
|
+
const { board, todo, doing } = await makeBoard(store, "harbor", "b1");
|
|
711
|
+
await board.write((w) => w.tickets.insert(ticketRecord("t1", "b1", todo, 1, { title: "Keep", description: "Old", priority: 3 })));
|
|
712
|
+
const updated = await board.write((w) => w.tickets.update("t1", { description: "New", state_id: doing, title: undefined, updated_at: at(50) }));
|
|
713
|
+
const expected = ticketRecord("t1", "b1", doing, 1, { title: "Keep", description: "New", priority: 3, updated_at: at(50) });
|
|
714
|
+
assertEqual(updated, expected, "what update() returns");
|
|
715
|
+
assertEqual(await board.read((r) => r.tickets.get("t1")), expected, "what is stored");
|
|
716
|
+
const lane = await board.write((w) => w.states.update(todo, { name: "To do", role: "ai_ready" }));
|
|
717
|
+
assertEqual(lane, stateRecord(todo, "b1", "To do", 0, { is_default: 1, role: "ai_ready" }), "a lane's update");
|
|
718
|
+
const comment = await board.write(async (w) => {
|
|
719
|
+
await w.comments.insert(commentRecord("c1", "t1"));
|
|
720
|
+
return w.comments.update("c1", { omitted_for_ai: 1 });
|
|
721
|
+
});
|
|
722
|
+
assertEqual(comment, commentRecord("c1", "t1", { omitted_for_ai: 1 }), "a comment hidden from agents");
|
|
723
|
+
const board2 = await board.write((w) => w.board.update({ agent_instructions: "Be brief." }));
|
|
724
|
+
assertEqual(board2, boardRecord("b1", "harbor", { agent_instructions: "Be brief." }), "the board's update");
|
|
725
|
+
const entry = await board.write(async (w) => {
|
|
726
|
+
await w.entries.insert(entryRecord("e1", { title: "LED", created_at: at(1), updated_at: at(1) }));
|
|
727
|
+
return w.entries.update("e1", { struck_at: at(2), updated_at: at(2) });
|
|
728
|
+
});
|
|
729
|
+
assertEqual([entry.struck_at, entry.updated_at, entry.title], [at(2), at(2), "LED"], "a struck journal entry");
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: "removing a card removes what belongs to it and keeps its history",
|
|
735
|
+
async run(harness) {
|
|
736
|
+
await using(harness, async (store) => {
|
|
737
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
738
|
+
await makeBoard(store, "side", "b2");
|
|
739
|
+
await board.write(async (w) => {
|
|
740
|
+
await w.tickets.insert(ticketRecord("epic", "b1", todo, 1, { type: "epic" }));
|
|
741
|
+
await w.tickets.insert(ticketRecord("t2", "b1", todo, 2, { parent_ticket_id: "epic" }));
|
|
742
|
+
await w.tickets.insert(ticketRecord("t3", "b1", todo, 3));
|
|
743
|
+
await w.labels.insert(labelRecord("l1", "tower"));
|
|
744
|
+
await w.ticketLabels.add("epic", "l1");
|
|
745
|
+
await w.ticketLabels.add("t3", "l1");
|
|
746
|
+
await w.comments.insert(commentRecord("c1", "epic"));
|
|
747
|
+
await w.comments.insert(commentRecord("c3", "t3"));
|
|
748
|
+
await w.relations.insert(relationRecord("r-out", "epic", "t3", "blocks"));
|
|
749
|
+
await w.relations.insert(relationRecord("r-in", "t3", "epic"));
|
|
750
|
+
await w.crossBoardRelations.insert(crossRecord("x1", "epic", "b2", "far"));
|
|
751
|
+
await w.verdicts.insert(verdictRecord("v1", "epic"));
|
|
752
|
+
await w.attachments.insert(attachmentRecord("a1", "epic"));
|
|
753
|
+
await w.events.append(eventRecord("ev1", "epic"));
|
|
754
|
+
await w.events.append(eventRecord("ev3", "t3"));
|
|
755
|
+
await w.entries.insert(entryRecord("j1", { type: "lesson", ticket_id: "epic" }));
|
|
756
|
+
await w.search.index("epic", documentFor("Relight the tower"));
|
|
757
|
+
await w.search.index("t3", documentFor("Paint the tower"));
|
|
758
|
+
});
|
|
759
|
+
await board.write((w) => w.tickets.remove("epic"));
|
|
760
|
+
await board.read(async (r) => {
|
|
761
|
+
assertEqual(await r.tickets.get("epic"), null, "the card");
|
|
762
|
+
assertEqual((await r.tickets.get("t2"))?.parent_ticket_id, "epic", "its sub-card keeps its parent id");
|
|
763
|
+
assertEqual(await r.ticketLabels.list(), [{ ticket_id: "t3", label_id: "l1" }], "label links");
|
|
764
|
+
assertEqual(idsOf(await r.labels.list()), ["l1"], "the label itself stays");
|
|
765
|
+
assertEqual(await r.comments.list("epic"), [], "its comments");
|
|
766
|
+
assertEqual(idsOf(await r.comments.list("t3")), ["c3"], "another card's comments");
|
|
767
|
+
assertEqual(await r.relations.list(), [], "relations either way");
|
|
768
|
+
assertEqual(await r.crossBoardRelations.list(), [], "cross-board relations from it");
|
|
769
|
+
assertEqual(await r.verdicts.list(), [], "its verdicts");
|
|
770
|
+
assertEqual(await r.attachments.list(), [], "its images' records");
|
|
771
|
+
assertEqual((await r.events.list()).map((event) => [event.id, event.ticket_id]), [["ev1", null], ["ev3", "t3"]], "events stay, unlinked");
|
|
772
|
+
assertEqual((await r.entries.get("j1"))?.ticket_id, null, "journal entries stay, unlinked");
|
|
773
|
+
assertEqual((await r.search.query("tower", { limit: 10 })).map((hit) => hit.ticket_id), ["t3"], "its index entry");
|
|
774
|
+
});
|
|
775
|
+
await assertRejects(() => board.write((w) => w.tickets.remove("epic")), "ticket_not_found", "removing it again");
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
name: "refuses duplicates with their codes",
|
|
781
|
+
async run(harness) {
|
|
782
|
+
await using(harness, async (store) => {
|
|
783
|
+
const { board, todo, doing } = await makeBoard(store, "harbor", "b1");
|
|
784
|
+
await board.write(async (w) => {
|
|
785
|
+
await w.tickets.insert(ticketRecord("t1", "b1", todo, 1));
|
|
786
|
+
await w.tickets.insert(ticketRecord("t2", "b1", todo, 2));
|
|
787
|
+
await w.labels.insert(labelRecord("l1", "tower"));
|
|
788
|
+
await w.comments.insert(commentRecord("c1", "t1"));
|
|
789
|
+
await w.events.append(eventRecord("e1", "t1"));
|
|
790
|
+
await w.relations.insert(relationRecord("r1", "t1", "t2", "blocks"));
|
|
791
|
+
await w.crossBoardRelations.insert(crossRecord("x1", "t1", "b9", "far"));
|
|
792
|
+
});
|
|
793
|
+
const refused = async (code, what, body) => {
|
|
794
|
+
await assertRejects(() => board.write(body), code, what);
|
|
795
|
+
};
|
|
796
|
+
await refused("duplicate_id", "a card id", (w) => w.tickets.create(newTicket("t1", "b1", todo)));
|
|
797
|
+
await refused("duplicate_id", "a lane id", (w) => w.states.insert(stateRecord(todo, "b1", "Other", 9)));
|
|
798
|
+
await refused("duplicate_id", "a comment id", (w) => w.comments.insert(commentRecord("c1", "t2")));
|
|
799
|
+
await refused("duplicate_id", "an event id", (w) => w.events.append(eventRecord("e1", null)));
|
|
800
|
+
await refused("duplicate_id", "a label id", (w) => w.labels.insert(labelRecord("l1", "other")));
|
|
801
|
+
await refused("state_name_taken", "a lane named as another", (w) => w.states.insert(stateRecord("s9", "b1", "Doing", 9)));
|
|
802
|
+
await refused("state_name_taken", "renaming a lane to another's name", (w) => w.states.update(todo, { name: "Doing" }));
|
|
803
|
+
await refused("label_name_taken", "a label named as another", (w) => w.labels.insert(labelRecord("l2", "tower")));
|
|
804
|
+
await refused("relation_exists", "the same relation again", (w) => w.relations.insert(relationRecord("r2", "t1", "t2", "blocks")));
|
|
805
|
+
await refused("relation_exists", "the same cross-board relation again", (w) => w.crossBoardRelations.insert(crossRecord("x2", "t1", "b9", "far")));
|
|
806
|
+
await refused("state_has_tickets", "removing a lane with cards", async (w) => {
|
|
807
|
+
await w.tickets.update("t1", { archived_at: at(3) });
|
|
808
|
+
await w.tickets.update("t2", { state_id: doing });
|
|
809
|
+
await w.states.remove(todo);
|
|
810
|
+
});
|
|
811
|
+
await board.write(async (w) => {
|
|
812
|
+
await w.relations.insert(relationRecord("r3", "t2", "t1", "blocks"));
|
|
813
|
+
await w.relations.insert(relationRecord("r4", "t1", "t2"));
|
|
814
|
+
await w.ticketLabels.add("t1", "l1");
|
|
815
|
+
await w.ticketLabels.add("t1", "l1");
|
|
816
|
+
});
|
|
817
|
+
assertEqual((await board.read((r) => r.ticketLabels.list())).length, 1, "adding a label link twice keeps one");
|
|
818
|
+
assertEqual((await board.read((r) => r.relations.list())).length, 3, "the other direction and another type are other relations");
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
name: "refuses records naming what the board doesn't have, and takes what it doesn't check",
|
|
824
|
+
async run(harness) {
|
|
825
|
+
await using(harness, async (store) => {
|
|
826
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
827
|
+
await makeBoard(store, "side", "b2");
|
|
828
|
+
await board.write((w) => w.tickets.insert(ticketRecord("t1", "b1", todo, 1)));
|
|
829
|
+
const refused = (what, body) => assertRejects(() => board.write(body), "invalid_reference", what);
|
|
830
|
+
await refused("a card in a lane the board doesn't have", (w) => w.tickets.create(newTicket("t2", "b1", "b2-todo")));
|
|
831
|
+
await refused("a card moved to such a lane", (w) => w.tickets.update("t1", { state_id: "nope" }));
|
|
832
|
+
await refused("a card of another board", (w) => w.tickets.insert(ticketRecord("t3", "b2", todo, 3)));
|
|
833
|
+
await refused("a lane of another board", (w) => w.states.insert(stateRecord("s9", "b2", "Elsewhere", 9)));
|
|
834
|
+
await refused("a label of another board", (w) => w.labels.insert(labelRecord("l9", "x", { board_id: "b2" })));
|
|
835
|
+
await refused("a comment on a missing card", (w) => w.comments.insert(commentRecord("c1", "nope")));
|
|
836
|
+
await refused("a link to a missing label", (w) => w.ticketLabels.add("t1", "nope"));
|
|
837
|
+
await refused("a link from a missing card", async (w) => {
|
|
838
|
+
await w.labels.insert(labelRecord("l1", "tower"));
|
|
839
|
+
await w.ticketLabels.set("nope", ["l1"]);
|
|
840
|
+
});
|
|
841
|
+
await refused("a relation to a missing card", (w) => w.relations.insert(relationRecord("r1", "t1", "nope", "blocks")));
|
|
842
|
+
await refused("a cross-board relation from a missing card", (w) => w.crossBoardRelations.insert(crossRecord("x1", "nope", "b2", "t9")));
|
|
843
|
+
await refused("an event about a missing card", (w) => w.events.append(eventRecord("e1", "nope")));
|
|
844
|
+
await refused("a journal entry about a missing card", (w) => w.entries.insert(entryRecord("j1", { ticket_id: "nope" })));
|
|
845
|
+
await refused("an image of a missing card", (w) => w.attachments.insert(attachmentRecord("a1", "nope")));
|
|
846
|
+
await refused("a verdict superseding a missing verdict", (w) => w.verdicts.insert(verdictRecord("v1", "t1", { supersedes_prior_review_id: "nope" })));
|
|
847
|
+
await refused("the board's record with another board's id", async (w) => w.board.set(boardRecord("b2", "harbor")));
|
|
848
|
+
await board.write(async (w) => {
|
|
849
|
+
await w.tickets.insert(ticketRecord("t5", "b1", todo, 5, { parent_ticket_id: "gone" }));
|
|
850
|
+
await w.crossBoardRelations.insert(crossRecord("x5", "t5", "b2", "not-checked", { type: "relates_to" }));
|
|
851
|
+
});
|
|
852
|
+
assertEqual((await board.read((r) => r.tickets.get("t5")))?.parent_ticket_id, "gone", "a card's epic isn't checked");
|
|
853
|
+
assertEqual(numbersOf(await board.read((r) => r.tickets.list())), [1, 5], "nothing else was written");
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
name: "updating or removing what isn't there answers its kind's not-found code",
|
|
859
|
+
async run(harness) {
|
|
860
|
+
await using(harness, async (store) => {
|
|
861
|
+
const { board } = await makeBoard(store, "harbor", "b1");
|
|
862
|
+
const missing = async (code, body) => {
|
|
863
|
+
await assertRejects(() => board.write(body), code, code);
|
|
864
|
+
};
|
|
865
|
+
await missing("state_not_found", (w) => w.states.update("nope", { name: "x" }));
|
|
866
|
+
await missing("state_not_found", (w) => w.states.remove("nope"));
|
|
867
|
+
await missing("ticket_not_found", (w) => w.tickets.update("nope", { title: "x" }));
|
|
868
|
+
await missing("ticket_not_found", (w) => w.tickets.remove("nope"));
|
|
869
|
+
await missing("comment_not_found", (w) => w.comments.update("nope", { omitted_for_ai: 1 }));
|
|
870
|
+
await missing("relation_not_found", (w) => w.relations.remove("nope"));
|
|
871
|
+
await missing("relation_not_found", (w) => w.crossBoardRelations.remove("nope"));
|
|
872
|
+
await missing("board_entry_not_found", (w) => w.entries.update("nope", { struck_at: null }));
|
|
873
|
+
await missing("board_entry_not_found", (w) => w.entries.remove("nope"));
|
|
874
|
+
await missing("attachment_not_found", (w) => w.attachments.remove("nope"));
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
name: "removes a lane, an image and relations by id",
|
|
880
|
+
async run(harness) {
|
|
881
|
+
await using(harness, async (store) => {
|
|
882
|
+
const { board, todo, doing } = await makeBoard(store, "harbor", "b1");
|
|
883
|
+
await board.write(async (w) => {
|
|
884
|
+
await w.tickets.insert(ticketRecord("t1", "b1", todo, 1));
|
|
885
|
+
await w.tickets.insert(ticketRecord("t2", "b1", todo, 2));
|
|
886
|
+
await w.relations.insert(relationRecord("r1", "t1", "t2", "blocks"));
|
|
887
|
+
await w.crossBoardRelations.insert(crossRecord("x1", "t1", "b9", "far"));
|
|
888
|
+
await w.attachments.insert(attachmentRecord("a1", "t1"));
|
|
889
|
+
});
|
|
890
|
+
await board.write(async (w) => {
|
|
891
|
+
await w.states.remove(doing);
|
|
892
|
+
await w.relations.remove("r1");
|
|
893
|
+
await w.crossBoardRelations.remove("x1");
|
|
894
|
+
await w.attachments.remove("a1");
|
|
895
|
+
});
|
|
896
|
+
await board.read(async (r) => {
|
|
897
|
+
assertEqual(idsOf(await r.states.list()), [todo, "b1-done"], "lanes");
|
|
898
|
+
assertEqual([await r.relations.list(), await r.crossBoardRelations.list(), await r.attachments.list()], [[], [], []], "relations and images");
|
|
899
|
+
assertEqual(numbersOf(await r.tickets.list()), [1, 2], "the cards stay");
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
name: "removes a journal entry by id, and only that one",
|
|
906
|
+
async run(harness) {
|
|
907
|
+
await using(harness, async (store) => {
|
|
908
|
+
const { board } = await makeBoard(store, "harbor", "b1");
|
|
909
|
+
await board.write(async (w) => {
|
|
910
|
+
await w.entries.insert(entryRecord("e1", { title: "Keep", created_at: at(1), updated_at: at(1) }));
|
|
911
|
+
await w.entries.insert(entryRecord("e2", { title: "Go", created_at: at(2), updated_at: at(2), struck_at: at(3) }));
|
|
912
|
+
});
|
|
913
|
+
await board.write((w) => w.entries.remove("e2"));
|
|
914
|
+
await board.read(async (r) => {
|
|
915
|
+
assertEqual(idsOf(await r.entries.list()), ["e1"], "the journal after the removal");
|
|
916
|
+
assertEqual(await r.entries.get("e2"), null, "the removed entry");
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
name: "counts each card's comments, leaving out hidden ones and one text when asked",
|
|
923
|
+
async run(harness) {
|
|
924
|
+
await using(harness, async (store) => {
|
|
925
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
926
|
+
const claim = "Agent claimed this ticket and is starting work.";
|
|
927
|
+
await board.write(async (w) => {
|
|
928
|
+
for (const id of ["t1", "t2", "t3"])
|
|
929
|
+
await w.tickets.create(newTicket(id, "b1", todo));
|
|
930
|
+
await w.comments.insert(commentRecord("c1", "t1"));
|
|
931
|
+
await w.comments.insert(commentRecord("c2", "t1", { omitted_for_ai: 1 }));
|
|
932
|
+
await w.comments.insert(commentRecord("c3", "t1", { body: claim }));
|
|
933
|
+
await w.comments.insert(commentRecord("c4", "t2", { omitted_for_ai: 1 }));
|
|
934
|
+
});
|
|
935
|
+
const counts = async (options) => Object.fromEntries((await board.read((r) => r.comments.countByTicket(options))).map((row) => [row.ticket_id, row.count]));
|
|
936
|
+
assertEqual(await counts(), { t1: 3, t2: 1 }, "every comment, cards with none left out");
|
|
937
|
+
assertEqual(await counts({ forAgents: true }), { t1: 2 }, "the ones agents may read");
|
|
938
|
+
assertEqual(await counts({ forAgents: true, exceptBody: claim }), { t1: 1 }, "without claim notes");
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
name: "reads events by card, type and time, the newest per card, and those after one",
|
|
944
|
+
async run(harness) {
|
|
945
|
+
await using(harness, async (store) => {
|
|
946
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
947
|
+
const work = ["agent_claimed", "state_changed"];
|
|
948
|
+
await board.write(async (w) => {
|
|
949
|
+
for (const id of ["t1", "t2", "t3"])
|
|
950
|
+
await w.tickets.create(newTicket(id, "b1", todo));
|
|
951
|
+
await w.events.append(eventRecord("e1", "t1", { type: "agent_claimed", created_at: at(10) }));
|
|
952
|
+
await w.events.append(eventRecord("e2", "t1", { type: "comment_created", created_at: at(20) }));
|
|
953
|
+
await w.events.append(eventRecord("e3", "t2", { type: "state_changed", created_at: at(20) }));
|
|
954
|
+
await w.events.append(eventRecord("e4", "t2", { type: "agent_claimed", created_at: at(20) }));
|
|
955
|
+
await w.events.append(eventRecord("e5", null, { type: "board_updated", created_at: at(30) }));
|
|
956
|
+
await w.events.append(eventRecord("e0", "t1", { type: "state_changed", created_at: at(5) }));
|
|
957
|
+
});
|
|
958
|
+
await board.read(async (r) => {
|
|
959
|
+
assertEqual(idsOf(await r.events.list({ ticketId: "t1" })), ["e0", "e1", "e2"], "a card's");
|
|
960
|
+
assertEqual(idsOf(await r.events.list({ types: work })), ["e0", "e1", "e3", "e4"], "of some types");
|
|
961
|
+
assertEqual(idsOf(await r.events.list({ since: at(20) })), ["e2", "e3", "e4", "e5"], "since a time, that time included");
|
|
962
|
+
assertEqual(idsOf(await r.events.list({ ticketId: "t1", types: work, since: at(6) })), ["e1"], "every filter at once");
|
|
963
|
+
const newest = idsOf(await r.events.latest(["t1", "t2", "t3"], work));
|
|
964
|
+
assertEqual(newest, ["e1", "e4"], "the newest work event per card, a tie going to the one appended last");
|
|
965
|
+
assertEqual(idsOf(await r.events.after("e2", 2)), ["e3", "e4"], "after one, in the order appended, up to the limit");
|
|
966
|
+
assertEqual(idsOf(await r.events.after("e5", 10)), ["e0"], "appended order, not time order");
|
|
967
|
+
assertEqual(await r.events.after("nope", 10), [], "after an id the board doesn't have");
|
|
968
|
+
assertEqual((await r.events.last())?.id, "e0", "the last appended");
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
name: "clear() empties the board, and a write can fill it again",
|
|
975
|
+
async run(harness) {
|
|
976
|
+
await usingTwo(harness, async (store, other) => {
|
|
977
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
978
|
+
await board.write(async (w) => {
|
|
979
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
980
|
+
await w.events.append(eventRecord("e1", "t1"));
|
|
981
|
+
await w.search.index("t1", documentFor("Lamp"));
|
|
982
|
+
});
|
|
983
|
+
const replaced = boardRecord("b1", "harbor", { name: "Replaced", updated_at: at(90) });
|
|
984
|
+
await board.write(async (w) => {
|
|
985
|
+
await w.clear();
|
|
986
|
+
await assertRejects(() => w.board.get(), "board_not_found", "the board's record right after clear()");
|
|
987
|
+
assertEqual([await w.states.list(), await w.tickets.list(), await w.events.list()], [[], [], []], "everything else");
|
|
988
|
+
await w.board.set(replaced);
|
|
989
|
+
await w.states.insert(stateRecord("s1", "b1", "Only", 0));
|
|
990
|
+
await w.tickets.insert(ticketRecord("t9", "b1", "s1", 9));
|
|
991
|
+
});
|
|
992
|
+
for (const [where, reading] of [["this store", board], ["another store", await other.board("b1")]]) {
|
|
993
|
+
await reading.read(async (r) => {
|
|
994
|
+
assertEqual(await r.board.get(), replaced, `the board's new record, through ${where}`);
|
|
995
|
+
assertEqual(idsOf(await r.tickets.list()), ["t9"], `its cards, through ${where}`);
|
|
996
|
+
assertEqual(await r.events.list(), [], `the old events, through ${where}`);
|
|
997
|
+
assertEqual(await r.search.query("lamp", { limit: 5 }), [], `the old index, through ${where}`);
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
assertEqual((await store.boards.get("b1"))?.name, "Replaced", "the list follows");
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
// Cross-board relations
|
|
1005
|
+
{
|
|
1006
|
+
name: "keeps a cross-board relation on its source's board and finds it from the target's",
|
|
1007
|
+
async run(harness) {
|
|
1008
|
+
await using(harness, async (store) => {
|
|
1009
|
+
const a = await makeBoard(store, "alpha", "b1");
|
|
1010
|
+
const b = await makeBoard(store, "beta", "b2");
|
|
1011
|
+
const c = await makeBoard(store, "gamma", "b3");
|
|
1012
|
+
await b.board.write((w) => w.tickets.create(newTicket("target", "b2", b.todo)));
|
|
1013
|
+
await a.board.write(async (w) => {
|
|
1014
|
+
await w.tickets.create(newTicket("blocked", "b1", a.todo));
|
|
1015
|
+
await w.crossBoardRelations.insert(crossRecord("x1", "blocked", "b2", "target", { created_at: at(1) }));
|
|
1016
|
+
});
|
|
1017
|
+
await c.board.write(async (w) => {
|
|
1018
|
+
await w.tickets.create(newTicket("related", "b3", c.todo));
|
|
1019
|
+
await w.crossBoardRelations.insert(crossRecord("x2", "related", "b2", "target", { type: "relates_to", created_at: at(0) }));
|
|
1020
|
+
});
|
|
1021
|
+
const expected = [
|
|
1022
|
+
{ board_id: "b1", relation: crossRecord("x1", "blocked", "b2", "target", { created_at: at(1) }) },
|
|
1023
|
+
{ board_id: "b3", relation: crossRecord("x2", "related", "b2", "target", { type: "relates_to", created_at: at(0) }) }
|
|
1024
|
+
];
|
|
1025
|
+
assertEqual(await store.relationsInto("b2"), expected, "the relations into beta, board by board");
|
|
1026
|
+
assertEqual(await store.relationsInto("b1"), [], "none into alpha");
|
|
1027
|
+
assertEqual(idsOf(await a.board.read((r) => r.crossBoardRelations.list("blocked"))), ["x1"], "on the source's board");
|
|
1028
|
+
assertEqual(await b.board.read((r) => r.crossBoardRelations.list()), [], "nothing on the target's board");
|
|
1029
|
+
await b.board.write((w) => w.tickets.remove("target"));
|
|
1030
|
+
assertEqual(idsOf(await a.board.read((r) => r.crossBoardRelations.list())), ["x1"], "removing the target leaves it (it reads as resolved)");
|
|
1031
|
+
await a.board.write((w) => w.tickets.remove("blocked"));
|
|
1032
|
+
assertEqual(idsOf((await store.relationsInto("b2")).map((row) => row.relation)), ["x2"], "removing the source removes it");
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
// Search
|
|
1037
|
+
{
|
|
1038
|
+
name: "finds open cards by the start of every word, in any field and case",
|
|
1039
|
+
async run(harness) {
|
|
1040
|
+
await using(harness, async (store) => {
|
|
1041
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
1042
|
+
await board.write(async (w) => {
|
|
1043
|
+
for (const id of ["t1", "t2", "t3", "t4", "t5"])
|
|
1044
|
+
await w.tickets.create(newTicket(id, "b1", todo));
|
|
1045
|
+
await w.tickets.update("t5", { archived_at: at(5) });
|
|
1046
|
+
await w.search.index("t1", documentFor("Replace the lamp HOUSING", { description: "North tower" }));
|
|
1047
|
+
await w.search.index("t2", documentFor("Paint", { type: "bug", details: "Primer first" }));
|
|
1048
|
+
await w.search.index("t3", documentFor("Wiring", { comments: "Checked the torque table" }));
|
|
1049
|
+
await w.search.index("t4", documentFor("Café lights", { description: "lamp" }));
|
|
1050
|
+
await w.search.index("t5", documentFor("Old lamp housing"));
|
|
1051
|
+
});
|
|
1052
|
+
const found = async (query, limit = 10) => (await board.read((r) => r.search.query(query, { limit }))).map((hit) => hit.ticket_id).sort();
|
|
1053
|
+
assertEqual(await found("lamp"), ["t1", "t4"], "a word in a title or description, archived cards left out");
|
|
1054
|
+
assertEqual(await found("hous"), ["t1"], "the start of a word, in any case");
|
|
1055
|
+
assertEqual(await found("LAMP tow"), ["t1"], "every word must match");
|
|
1056
|
+
assertEqual(await found("prim"), ["t2"], "the details");
|
|
1057
|
+
assertEqual(await found("bug"), ["t2"], "the type");
|
|
1058
|
+
assertEqual(await found("torque"), ["t3"], "the comments given");
|
|
1059
|
+
assertEqual(await found("cafe"), ["t4"], "a word with an accent, by its ASCII letters");
|
|
1060
|
+
assertEqual(await found("amp"), [], "not the middle of a word");
|
|
1061
|
+
assertEqual(await found(" "), [], "a blank query");
|
|
1062
|
+
assertEqual((await found("lamp", 1)).length, 1, "at most the limit");
|
|
1063
|
+
const hits = await board.read((r) => r.search.query("lamp", { limit: 10 }));
|
|
1064
|
+
assert(hits.every((hit) => typeof hit.rank === "number" && Number.isFinite(hit.rank)), `ranks are numbers: ${show(hits)}`);
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
name: "matches a query with no words by its text alone, with % _ and \\ meaning only themselves",
|
|
1070
|
+
async run(harness) {
|
|
1071
|
+
await using(harness, async (store) => {
|
|
1072
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
1073
|
+
await board.write(async (w) => {
|
|
1074
|
+
for (const id of ["t1", "t2", "t3", "t4"])
|
|
1075
|
+
await w.tickets.create(newTicket(id, "b1", todo));
|
|
1076
|
+
await w.search.index("t1", documentFor("Discount", { comments: "40% off" }));
|
|
1077
|
+
await w.search.index("t2", documentFor("Paths", { description: "C:\\orbit" }));
|
|
1078
|
+
await w.search.index("t3", documentFor("Plain words"));
|
|
1079
|
+
await w.search.index("t4", documentFor("Snake", { details: "a__b" }));
|
|
1080
|
+
});
|
|
1081
|
+
const found = async (query) => (await board.read((r) => r.search.query(query, { limit: 10 }))).map((hit) => hit.ticket_id).sort();
|
|
1082
|
+
assertEqual(await found("%"), ["t1"], "a percent sign");
|
|
1083
|
+
assertEqual(await found("\\"), ["t2"], "a backslash");
|
|
1084
|
+
assertEqual(await found("__"), ["t4"], "underscores");
|
|
1085
|
+
assertEqual(await found("?"), [], "a sign no card has");
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
name: "words joined by _ are one word, found by their start and never by a later part",
|
|
1091
|
+
async run(harness) {
|
|
1092
|
+
await using(harness, async (store) => {
|
|
1093
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
1094
|
+
await board.write(async (w) => {
|
|
1095
|
+
for (const id of ["t1", "t2"])
|
|
1096
|
+
await w.tickets.create(newTicket(id, "b1", todo));
|
|
1097
|
+
await w.search.index("t1", documentFor("Rename snake_case keys"));
|
|
1098
|
+
await w.search.index("t2", documentFor("Upper case keys"));
|
|
1099
|
+
});
|
|
1100
|
+
const found = async (query) => (await board.read((r) => r.search.query(query, { limit: 10 }))).map((hit) => hit.ticket_id).sort();
|
|
1101
|
+
assertEqual(await found("snake"), ["t1"], "the start of the joined word");
|
|
1102
|
+
assertEqual(await found("snake_c"), ["t1"], "the start, the _ included");
|
|
1103
|
+
assertEqual(await found("case"), ["t2"], "the part after the _ is no word of its own");
|
|
1104
|
+
assertEqual(await found("keys"), ["t1", "t2"], "the words around it");
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
name: "a card's document is replaced when indexed again, and gone when removed",
|
|
1110
|
+
async run(harness) {
|
|
1111
|
+
await using(harness, async (store) => {
|
|
1112
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
1113
|
+
await board.write(async (w) => {
|
|
1114
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
1115
|
+
await w.search.index("t1", documentFor("Lamp"));
|
|
1116
|
+
await w.search.index("t1", documentFor("Beacon"));
|
|
1117
|
+
});
|
|
1118
|
+
const found = async (query) => (await board.read((r) => r.search.query(query, { limit: 10 }))).map((hit) => hit.ticket_id);
|
|
1119
|
+
assertEqual(await found("lamp"), [], "the old words");
|
|
1120
|
+
assertEqual(await found("beacon"), ["t1"], "the new words");
|
|
1121
|
+
await board.write((w) => w.search.remove("t1"));
|
|
1122
|
+
assertEqual(await found("beacon"), [], "after remove()");
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
// Transactions: the contract owns the races
|
|
1127
|
+
{
|
|
1128
|
+
name: "a write that throws leaves nothing behind: no record, number, event or index entry",
|
|
1129
|
+
async run(harness) {
|
|
1130
|
+
await usingTwo(harness, async (here, there) => {
|
|
1131
|
+
const { board, todo } = await makeBoard(here, "harbor", "b1");
|
|
1132
|
+
const heard = [];
|
|
1133
|
+
const stop = [board.watch((change) => heard.push(change.id)), (await there.board("b1")).watch((change) => heard.push(`there:${change.id}`))];
|
|
1134
|
+
const failure = new Error("the body failed");
|
|
1135
|
+
let caught;
|
|
1136
|
+
try {
|
|
1137
|
+
await board.write(async (w) => {
|
|
1138
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
1139
|
+
await w.comments.insert(commentRecord("c1", "t1"));
|
|
1140
|
+
await w.events.append(eventRecord("e-failed", "t1"));
|
|
1141
|
+
await w.search.index("t1", documentFor("Lamp"));
|
|
1142
|
+
await w.board.update({ name: "Renamed" });
|
|
1143
|
+
throw failure;
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
caught = error;
|
|
1148
|
+
}
|
|
1149
|
+
assert(caught === failure, "write() rethrows the body's own error");
|
|
1150
|
+
await board.read(async (r) => {
|
|
1151
|
+
assertEqual([await r.tickets.list(), await r.comments.list("t1"), await r.events.list()], [[], [], []], "records");
|
|
1152
|
+
assertEqual(await r.search.query("lamp", { limit: 5 }), [], "the index");
|
|
1153
|
+
assertEqual((await r.board.get()).name, "Board harbor", "the board's record");
|
|
1154
|
+
});
|
|
1155
|
+
assertEqual((await here.boards.get("b1"))?.name, "Board harbor", "the list");
|
|
1156
|
+
const next = await board.write(async (w) => {
|
|
1157
|
+
const made = await w.tickets.create(newTicket("t2", "b1", todo));
|
|
1158
|
+
await w.events.append(eventRecord("e-ok", "t2"));
|
|
1159
|
+
return made;
|
|
1160
|
+
});
|
|
1161
|
+
assertEqual(next.number, 1, "the failed write used no number");
|
|
1162
|
+
await eventually(() => heard.includes("e-ok") && heard.includes("there:e-ok"), "both stores hear the write that worked");
|
|
1163
|
+
assertEqual(heard.filter((id) => id.endsWith("e-failed")), [], "no one heard the failed write's event");
|
|
1164
|
+
for (const unsubscribe of stop)
|
|
1165
|
+
unsubscribe();
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
name: "a write's own reads see its changes before it commits",
|
|
1171
|
+
async run(harness) {
|
|
1172
|
+
await using(harness, async (store) => {
|
|
1173
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
1174
|
+
await board.write(async (w) => {
|
|
1175
|
+
const made = await w.tickets.create(newTicket("t1", "b1", todo));
|
|
1176
|
+
assertEqual(await w.tickets.get("t1"), made, "get() inside the write");
|
|
1177
|
+
assertEqual(numbersOf(await w.tickets.list({ archived: false })), [1], "list() inside the write");
|
|
1178
|
+
await w.tickets.update("t1", { title: "Renamed" });
|
|
1179
|
+
assertEqual((await w.tickets.byTitle("renamed"))?.id, "t1", "byTitle() after an update");
|
|
1180
|
+
await w.events.append(eventRecord("e1", "t1"));
|
|
1181
|
+
assertEqual(idsOf(await w.events.list()), ["e1"], "its events");
|
|
1182
|
+
await w.search.index("t1", documentFor("Lamp"));
|
|
1183
|
+
assertEqual((await w.search.query("lamp", { limit: 5 })).map((hit) => hit.ticket_id), ["t1"], "its index");
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
name: "writes to one board run one at a time, across stores",
|
|
1190
|
+
async run(harness) {
|
|
1191
|
+
await usingTwo(harness, async (here, there) => {
|
|
1192
|
+
await makeBoard(here, "harbor", "b1");
|
|
1193
|
+
const steps = [];
|
|
1194
|
+
const body = (name) => async (w) => {
|
|
1195
|
+
steps.push(`${name} starts`);
|
|
1196
|
+
await w.tickets.lastNumber();
|
|
1197
|
+
await pause(5);
|
|
1198
|
+
await w.tickets.lastNumber();
|
|
1199
|
+
steps.push(`${name} ends`);
|
|
1200
|
+
};
|
|
1201
|
+
const boards = [await here.board("b1"), await there.board("b1"), await here.board("b1")];
|
|
1202
|
+
await Promise.all(boards.map((board, i) => board.write(body(`w${i}`))));
|
|
1203
|
+
for (let i = 0; i < steps.length; i += 2) {
|
|
1204
|
+
const start = steps[i] ?? "";
|
|
1205
|
+
assertEqual(steps[i + 1], start.replace("starts", "ends"), `a write ended before another started: ${steps.join(", ")}`);
|
|
1206
|
+
}
|
|
1207
|
+
assertEqual(steps.length, 6, "every write ran");
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
},
|
|
1211
|
+
{
|
|
1212
|
+
name: "a read while another caller's write is open never sees that write's changes",
|
|
1213
|
+
async run(harness) {
|
|
1214
|
+
await usingTwo(harness, async (here, there) => {
|
|
1215
|
+
const { board, todo } = await makeBoard(here, "harbor", "b1");
|
|
1216
|
+
const farBoard = await there.board("b1");
|
|
1217
|
+
const hold = gate();
|
|
1218
|
+
let opened = false;
|
|
1219
|
+
const failing = board.write(async (w) => {
|
|
1220
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
1221
|
+
await w.events.append(eventRecord("e1", "t1"));
|
|
1222
|
+
opened = true;
|
|
1223
|
+
await hold.wait;
|
|
1224
|
+
throw new Error("rolled back");
|
|
1225
|
+
});
|
|
1226
|
+
await eventually(() => opened, "the write is open");
|
|
1227
|
+
const reads = [
|
|
1228
|
+
board.read(async (r) => [await r.tickets.get("t1"), await r.events.list()]),
|
|
1229
|
+
farBoard.read(async (r) => [await r.tickets.get("t1"), await r.events.list()])
|
|
1230
|
+
];
|
|
1231
|
+
// A store may make a read wait for the write; let it, then end the write.
|
|
1232
|
+
await pause(30);
|
|
1233
|
+
hold.open();
|
|
1234
|
+
await failing.then(() => undefined, () => undefined);
|
|
1235
|
+
const [mine, theirs] = await Promise.all(reads);
|
|
1236
|
+
assertEqual(mine, [null, []], "a read through the same store");
|
|
1237
|
+
assertEqual(theirs, [null, []], "a read through another store");
|
|
1238
|
+
const done = gate();
|
|
1239
|
+
let made = false;
|
|
1240
|
+
const working = board.write(async (w) => {
|
|
1241
|
+
await w.tickets.create(newTicket("t2", "b1", todo));
|
|
1242
|
+
made = true;
|
|
1243
|
+
await done.wait;
|
|
1244
|
+
});
|
|
1245
|
+
await eventually(() => made, "the second write is open");
|
|
1246
|
+
const during = farBoard.read((r) => r.tickets.get("t2"));
|
|
1247
|
+
await pause(30);
|
|
1248
|
+
done.open();
|
|
1249
|
+
await working;
|
|
1250
|
+
const seen = await during;
|
|
1251
|
+
assert(seen === null || seen.number === 1, "a read during a write sees the state before it or, if it waited, after it");
|
|
1252
|
+
assertEqual((await farBoard.read((r) => r.tickets.get("t2")))?.number, 1, "after the commit, everyone sees it");
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
name: "everything one read sees is one committed state",
|
|
1258
|
+
async run(harness) {
|
|
1259
|
+
await usingTwo(harness, async (here, there) => {
|
|
1260
|
+
const { board, todo } = await makeBoard(here, "harbor", "b1");
|
|
1261
|
+
const farBoard = await there.board("b1");
|
|
1262
|
+
let writing = Promise.resolve();
|
|
1263
|
+
let finished = false;
|
|
1264
|
+
const seen = await board.read(async (r) => {
|
|
1265
|
+
const tickets = await r.tickets.list();
|
|
1266
|
+
writing = farBoard.write(async (w) => {
|
|
1267
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
1268
|
+
await w.events.append(eventRecord("e1", "t1"));
|
|
1269
|
+
}).then(() => {
|
|
1270
|
+
finished = true;
|
|
1271
|
+
});
|
|
1272
|
+
// A store may hold the write until this read ends; give it the chance to commit first.
|
|
1273
|
+
const started = Date.now();
|
|
1274
|
+
while (!finished && Date.now() - started < 200)
|
|
1275
|
+
await pause(10);
|
|
1276
|
+
const events = await r.events.list();
|
|
1277
|
+
const again = await r.tickets.list();
|
|
1278
|
+
return { tickets: idsOf(tickets), events: idsOf(events), again: idsOf(again) };
|
|
1279
|
+
});
|
|
1280
|
+
await writing;
|
|
1281
|
+
assertEqual(seen, { tickets: [], events: [], again: [] }, "the read saw only the state it began with");
|
|
1282
|
+
assertEqual(idsOf(await board.read((r) => r.events.list())), ["e1"], "a new read sees the write");
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
name: "a writer refuses every call once its write has settled",
|
|
1288
|
+
async run(harness) {
|
|
1289
|
+
await using(harness, async (store) => {
|
|
1290
|
+
const { board, todo } = await makeBoard(store, "harbor", "b1");
|
|
1291
|
+
let kept;
|
|
1292
|
+
await board.write(async (w) => {
|
|
1293
|
+
kept = w;
|
|
1294
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
1295
|
+
});
|
|
1296
|
+
const late = kept;
|
|
1297
|
+
assert(late, "the writer was kept");
|
|
1298
|
+
await assertRejects(() => late.tickets.create(newTicket("t2", "b1", todo)), "write_closed", "a create after the write");
|
|
1299
|
+
await assertRejects(() => late.tickets.list(), "write_closed", "a read through the writer after the write");
|
|
1300
|
+
let floating = Promise.resolve();
|
|
1301
|
+
await board.write(async (w) => {
|
|
1302
|
+
floating = w.tickets.create(newTicket("t3", "b1", todo)).then(() => w.tickets.create(newTicket("t4", "b1", todo)));
|
|
1303
|
+
// Handled now: an adapter whose commit takes a while refuses it before the case looks.
|
|
1304
|
+
floating.catch(() => undefined);
|
|
1305
|
+
await Promise.resolve();
|
|
1306
|
+
});
|
|
1307
|
+
await floating.then(() => undefined, () => undefined);
|
|
1308
|
+
const left = idsOf(await board.read((r) => r.tickets.list()));
|
|
1309
|
+
assert(!left.includes("t4"), `a call the body never awaited can't land after the write: ${left.join(", ")}`);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
},
|
|
1313
|
+
// Watching
|
|
1314
|
+
{
|
|
1315
|
+
name: "watch() hears every committed event once, in order, from this store and others",
|
|
1316
|
+
async run(harness) {
|
|
1317
|
+
await usingTwo(harness, async (here, there) => {
|
|
1318
|
+
const { board, todo } = await makeBoard(here, "harbor", "b1");
|
|
1319
|
+
await makeBoard(here, "side", "b2");
|
|
1320
|
+
const farBoard = await there.board("b1");
|
|
1321
|
+
const otherBoard = await there.board("b2");
|
|
1322
|
+
const mine = [];
|
|
1323
|
+
const theirs = [];
|
|
1324
|
+
const stopMine = board.watch((change) => mine.push(change));
|
|
1325
|
+
const stopTheirs = farBoard.watch((change) => theirs.push(change.id));
|
|
1326
|
+
await board.write(async (w) => {
|
|
1327
|
+
await w.tickets.create(newTicket("t1", "b1", todo));
|
|
1328
|
+
await w.events.append(eventRecord("e1", "t1", { type: "ticket_created", actor: "crea", body_json: '{"title":"One"}', created_at: at(1) }));
|
|
1329
|
+
await w.events.append(eventRecord("e2", null, { type: "board_updated", body_json: "not json", created_at: at(2) }));
|
|
1330
|
+
});
|
|
1331
|
+
await farBoard.write((w) => w.events.append(eventRecord("e3", "t1", { created_at: at(3) })));
|
|
1332
|
+
await otherBoard.write((w) => w.events.append(eventRecord("elsewhere", null)));
|
|
1333
|
+
await eventually(() => mine.length >= 3 && theirs.length >= 3, "both stores hear all three");
|
|
1334
|
+
assertEqual(mine.slice(0, 2), [
|
|
1335
|
+
{ id: "e1", type: "ticket_created", ticket_id: "t1", board_id: "b1", actor: "crea", data: { title: "One" }, timestamp: at(1) },
|
|
1336
|
+
{ id: "e2", type: "board_updated", ticket_id: null, board_id: "b1", actor: "local", data: {}, timestamp: at(2) }
|
|
1337
|
+
], "the changes, data parsed (unreadable data as {})");
|
|
1338
|
+
assertEqual(mine.map((change) => change.id), ["e1", "e2", "e3"], "this store hears its writes and the other store's, in order");
|
|
1339
|
+
assertEqual(theirs, ["e1", "e2", "e3"], "the other store hears them too");
|
|
1340
|
+
stopMine();
|
|
1341
|
+
await board.write((w) => w.events.append(eventRecord("e4", null)));
|
|
1342
|
+
await eventually(() => theirs.length >= 4, "the other store hears the next");
|
|
1343
|
+
await pause(50);
|
|
1344
|
+
assertEqual(mine.length, 3, "nothing after unsubscribing");
|
|
1345
|
+
assertEqual(theirs, ["e1", "e2", "e3", "e4"], "never another board's events, never twice");
|
|
1346
|
+
stopTheirs();
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
];
|
|
1351
|
+
export const blobConformanceCases = [
|
|
1352
|
+
{
|
|
1353
|
+
name: "keeps bytes exactly, by board and path",
|
|
1354
|
+
async run(blobs) {
|
|
1355
|
+
const every = new Uint8Array(256).map((_, i) => i);
|
|
1356
|
+
const big = new Uint8Array(300_000).map((_, i) => (i * 31) % 251);
|
|
1357
|
+
await blobs.put("b1", "attachments/tickets/t1/a1.png", every);
|
|
1358
|
+
await blobs.put("b1", "artifacts/tickets/t1/plan.md", big);
|
|
1359
|
+
await blobs.put("b2", "attachments/tickets/t1/a1.png", new Uint8Array([7]));
|
|
1360
|
+
assertEqual([...((await blobs.get("b1", "attachments/tickets/t1/a1.png")) ?? [])], [...every], "every byte value");
|
|
1361
|
+
const back = await blobs.get("b1", "artifacts/tickets/t1/plan.md");
|
|
1362
|
+
assert(back !== null && back.length === big.length && back.every((byte, i) => byte === big[i]), "300 KB, byte for byte");
|
|
1363
|
+
assertEqual(await blobs.size("b1", "artifacts/tickets/t1/plan.md"), 300_000, "size()");
|
|
1364
|
+
assertEqual([...((await blobs.get("b2", "attachments/tickets/t1/a1.png")) ?? [])], [7], "the same path on another board");
|
|
1365
|
+
assertEqual(await blobs.get("b1", "attachments/none.png"), null, "nothing at a path");
|
|
1366
|
+
assertEqual(await blobs.size("b1", "attachments/none.png"), null, "no size for nothing");
|
|
1367
|
+
const kept = await blobs.get("b1", "attachments/tickets/t1/a1.png");
|
|
1368
|
+
if (kept)
|
|
1369
|
+
kept[0] = 99;
|
|
1370
|
+
assertEqual((await blobs.get("b1", "attachments/tickets/t1/a1.png"))?.[0], 0, "bytes handed out are the caller's");
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
{
|
|
1374
|
+
name: "replaces bytes, refuses an exclusive put over them, and removes them",
|
|
1375
|
+
async run(blobs) {
|
|
1376
|
+
await blobs.put("b1", "attachments/a.png", new Uint8Array([1, 2, 3]));
|
|
1377
|
+
await blobs.put("b1", "attachments/a.png", new Uint8Array([4]));
|
|
1378
|
+
assertEqual([...((await blobs.get("b1", "attachments/a.png")) ?? [])], [4], "a put replaces");
|
|
1379
|
+
await assertRejects(() => blobs.put("b1", "attachments/a.png", new Uint8Array([5]), { exclusive: true }), "blob_exists", "an exclusive put over bytes");
|
|
1380
|
+
assertEqual([...((await blobs.get("b1", "attachments/a.png")) ?? [])], [4], "the refused put changed nothing");
|
|
1381
|
+
await blobs.put("b1", "attachments/b.png", new Uint8Array([6]), { exclusive: true });
|
|
1382
|
+
await blobs.remove("b1", "attachments/a.png");
|
|
1383
|
+
await blobs.remove("b1", "attachments/never.png");
|
|
1384
|
+
assertEqual(await blobs.get("b1", "attachments/a.png"), null, "removed");
|
|
1385
|
+
await blobs.put("b2", "attachments/c.png", new Uint8Array([8]));
|
|
1386
|
+
await blobs.removeBoard("b1");
|
|
1387
|
+
const left = [await blobs.get("b1", "attachments/b.png"), await blobs.size("b2", "attachments/c.png")];
|
|
1388
|
+
assertEqual(left, [null, 1], "removeBoard() removes that board's only");
|
|
1389
|
+
}
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
name: "refuses paths that leave the board",
|
|
1393
|
+
async run(blobs) {
|
|
1394
|
+
for (const path of ["", "/etc/passwd", "../other/board.db", "attachments/../../x", "attachments//x", "attachments/./x", "attachments\\x"]) {
|
|
1395
|
+
await assertRejects(() => blobs.put("b1", path, new Uint8Array([1])), "invalid_blob_path", `put ${JSON.stringify(path)}`);
|
|
1396
|
+
await assertRejects(() => blobs.get("b1", path), "invalid_blob_path", `get ${JSON.stringify(path)}`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
];
|
|
1401
|
+
// Running the cases
|
|
1402
|
+
/** Registers every store case, under one describe block, with a test framework. */
|
|
1403
|
+
export function registerStoreConformance(api, label, factory, options = {}) {
|
|
1404
|
+
api.describe(`${label} (OrbitStore conformance)`, () => {
|
|
1405
|
+
for (const testCase of storeConformanceCases) {
|
|
1406
|
+
if (options.skip?.includes(testCase.name))
|
|
1407
|
+
continue;
|
|
1408
|
+
api.it(testCase.name, async () => {
|
|
1409
|
+
const harness = await factory();
|
|
1410
|
+
try {
|
|
1411
|
+
await testCase.run(harness);
|
|
1412
|
+
}
|
|
1413
|
+
finally {
|
|
1414
|
+
await harness.dispose?.();
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
/** Registers every blob case, each on a fresh BlobStore. */
|
|
1421
|
+
export function registerBlobConformance(api, label, factory) {
|
|
1422
|
+
api.describe(`${label} (BlobStore conformance)`, () => {
|
|
1423
|
+
for (const testCase of blobConformanceCases) {
|
|
1424
|
+
api.it(testCase.name, async () => testCase.run(await factory()));
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
/** Runs every store case in order, each on a fresh harness, and reports instead of throwing. */
|
|
1429
|
+
export async function runStoreConformance(factory, options = {}) {
|
|
1430
|
+
const results = [];
|
|
1431
|
+
for (const testCase of storeConformanceCases) {
|
|
1432
|
+
if (options.skip?.includes(testCase.name))
|
|
1433
|
+
continue;
|
|
1434
|
+
if (options.only && !options.only.includes(testCase.name))
|
|
1435
|
+
continue;
|
|
1436
|
+
const harness = await factory();
|
|
1437
|
+
try {
|
|
1438
|
+
await testCase.run(harness);
|
|
1439
|
+
results.push({ name: testCase.name, ok: true });
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
results.push({ name: testCase.name, ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
|
|
1443
|
+
}
|
|
1444
|
+
finally {
|
|
1445
|
+
await harness.dispose?.();
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return results;
|
|
1449
|
+
}
|