@deveye/types 0.15.0 → 0.15.2
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/README.md +6 -6
- package/package.json +6 -3
- package/src/domain/audience.ts +1 -1
- package/src/domain/sharing.ts +2 -2
- package/src/features/workspace.ts +0 -29
- package/src/index.ts +1 -3
- package/src/sdk/client-ambient.d.ts +18 -3
- package/src/sdk/index.ts +2 -2
- package/src/sdk/manifest.test.ts +97 -0
- package/src/sdk/manifest.ts +67 -1
- package/src/sdk/testing.test.ts +62 -0
- package/src/sdk/testing.ts +127 -27
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# deveye
|
|
1
|
+
# @deveye/types
|
|
2
2
|
|
|
3
3
|
Les contrats partagés de [DevEye](https://github.com/Gerem66/DevEye) : types
|
|
4
4
|
TypeScript et schémas zod, consommés par le serveur, le client et les modules
|
|
@@ -6,14 +6,14 @@ de features.
|
|
|
6
6
|
|
|
7
7
|
Le package sert ses sources directement (`src/*.ts`, aucun build) :
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- **`@deveye/types`** — le domaine transverse (espaces, rôles, live, partage…),
|
|
10
10
|
les protocoles (enveloppe WS, agent), le registre d'identité des features.
|
|
11
|
-
-
|
|
11
|
+
- **`@deveye/types/sdk`** — le contrat des modules de features : `FeatureManifest`,
|
|
12
12
|
`validateManifest`, ids `x-<slug>`.
|
|
13
|
-
-
|
|
13
|
+
- **`@deveye/types/sdk/server`** — le contexte serveur d'un module (handlers,
|
|
14
14
|
store, façade, service).
|
|
15
|
-
-
|
|
16
|
-
-
|
|
15
|
+
- **`@deveye/types/sdk/client`** — les contrats de l'entrée client d'un module.
|
|
16
|
+
- **`@deveye/types/sdk/testing`** — le harnais de test en mémoire des handlers.
|
|
17
17
|
|
|
18
18
|
Le portrait typé du barrel `deveye-sdk-client` (le runtime que l'app fournit
|
|
19
19
|
aux modules) est publié ici aussi (`src/sdk/client-ambient.d.ts`) ; la CI de
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deveye/types",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Shared contracts (types + zod schemas) for DevEye apps",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -41,9 +41,10 @@
|
|
|
41
41
|
"lint": "eslint .",
|
|
42
42
|
"lint:fix": "eslint . --fix",
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
|
-
"ci": "npm run lint && npm run typecheck",
|
|
44
|
+
"ci": "npm run lint && npm run typecheck && npm test",
|
|
45
45
|
"prettier": "prettier --check .",
|
|
46
|
-
"prettier:fix": "prettier --write ."
|
|
46
|
+
"prettier:fix": "prettier --write .",
|
|
47
|
+
"test": "tsx --test \"src/**/*.test.ts\""
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"zod": "^4.4.3"
|
|
@@ -54,11 +55,13 @@
|
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@eslint/js": "^8.0.0",
|
|
58
|
+
"@types/node": "^26.3.0",
|
|
57
59
|
"@types/react": "^19.0.0",
|
|
58
60
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
|
59
61
|
"@typescript-eslint/parser": "^6.0.0",
|
|
60
62
|
"eslint": "^8.0.0",
|
|
61
63
|
"eslint-plugin-prettier": "^5.5.4",
|
|
64
|
+
"tsx": "^4.23.12",
|
|
62
65
|
"typescript": "^5.0.0",
|
|
63
66
|
"typescript-eslint": "^8.39.1"
|
|
64
67
|
},
|
package/src/domain/audience.ts
CHANGED
|
@@ -283,7 +283,7 @@ export const audienceOverviewSchema = z.object({
|
|
|
283
283
|
export type AudienceOverview = z.infer<typeof audienceOverviewSchema>;
|
|
284
284
|
|
|
285
285
|
export const audienceBreakdownItemSchema = z.object({
|
|
286
|
-
/** Le libellé déchiffr
|
|
286
|
+
/** Le libellé déchiffré, vide si le blob est illisible. */
|
|
287
287
|
label: z.string(),
|
|
288
288
|
views: z.number().int().nonnegative(),
|
|
289
289
|
visitors: z.number().int().nonnegative()
|
package/src/domain/sharing.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { featureAccessSchema, workspaceFeatureIdSchema } from './workspaceRole';
|
|
|
10
10
|
* Un élément partagé **ne change jamais de clé**. Il reste chiffré sous celle de
|
|
11
11
|
* son espace d'origine ; servi ailleurs, il est déchiffré avec le codec ouvert
|
|
12
12
|
* de cet espace-là. C'est le prolongement direct du levier L3 de
|
|
13
|
-
* `WORKSPACES.md
|
|
14
|
-
* raison pour laquelle ce chantier ne re-chiffre rien.
|
|
13
|
+
* `WORKSPACES.md`, « chaque espace a sa clé, et un blob n'en change jamais »,
|
|
14
|
+
* et la raison pour laquelle ce chantier ne re-chiffre rien.
|
|
15
15
|
*
|
|
16
16
|
* `WORKSPACES.md` §10 range **déplacer** un élément hors périmètre, précisément
|
|
17
17
|
* parce que ce serait la seule opération à exiger un déchiffrement clé A puis un
|
|
@@ -89,33 +89,6 @@ export const workspaceAddMember = {
|
|
|
89
89
|
output: z.object({ workspace: workspaceSchema })
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
* État de la clé d'espace : déjà active, applicable, et ce qui l'empêcherait.
|
|
94
|
-
*/
|
|
95
|
-
export const workspaceSharedKeyStatus = {
|
|
96
|
-
command: 'workspace.sharedKeyStatus' as const,
|
|
97
|
-
input: z.object({}),
|
|
98
|
-
output: z.object({
|
|
99
|
-
/** L'espace utilise déjà sa propre clé. */
|
|
100
|
-
enabled: z.boolean(),
|
|
101
|
-
/** Faux pour un espace personnel, qui garde la clé de son propriétaire. */
|
|
102
|
-
applicable: z.boolean(),
|
|
103
|
-
/** Obstacles à lever avant conversion, rédigés pour l'utilisateur. */
|
|
104
|
-
blockers: z.array(z.string())
|
|
105
|
-
})
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Convertit l'espace vers sa propre clé. Exige le propriétaire, session
|
|
110
|
-
* déverrouillée : le contenu existant doit être relu avec sa clé avant d'être
|
|
111
|
-
* réécrit sous celle de l'espace.
|
|
112
|
-
*/
|
|
113
|
-
export const workspaceEnableSharedKey = {
|
|
114
|
-
command: 'workspace.enableSharedKey' as const,
|
|
115
|
-
input: z.object({}),
|
|
116
|
-
output: z.object({ converted: z.number().int().nonnegative() })
|
|
117
|
-
};
|
|
118
|
-
|
|
119
92
|
/** Le contenu modifiable d'un rôle. */
|
|
120
93
|
const roleDraftSchema = z.object({
|
|
121
94
|
name: z.string().min(1).max(WORKSPACE_ROLE_NAME_MAX),
|
|
@@ -187,8 +160,6 @@ export const workspaceCommands = [
|
|
|
187
160
|
workspaceRoleDelete,
|
|
188
161
|
workspaceRoleSetDefault,
|
|
189
162
|
workspaceAssignRole,
|
|
190
|
-
workspaceSharedKeyStatus,
|
|
191
|
-
workspaceEnableSharedKey,
|
|
192
163
|
workspaceRename,
|
|
193
164
|
workspaceLeave,
|
|
194
165
|
workspaceRemoveMember,
|
package/src/index.ts
CHANGED
|
@@ -1449,7 +1449,6 @@ export {
|
|
|
1449
1449
|
workspaceAdd,
|
|
1450
1450
|
workspaceCommands,
|
|
1451
1451
|
workspaceDelete,
|
|
1452
|
-
workspaceEnableSharedKey,
|
|
1453
1452
|
workspaceLeave,
|
|
1454
1453
|
workspaceRemoveMember,
|
|
1455
1454
|
workspaceAddMember,
|
|
@@ -1460,8 +1459,7 @@ export {
|
|
|
1460
1459
|
workspaceRoleList,
|
|
1461
1460
|
workspaceRoleSetDefault,
|
|
1462
1461
|
workspaceRoleUpdate,
|
|
1463
|
-
workspaceSetFavorite
|
|
1464
|
-
workspaceSharedKeyStatus
|
|
1462
|
+
workspaceSetFavorite
|
|
1465
1463
|
} from './features/workspace';
|
|
1466
1464
|
export {
|
|
1467
1465
|
AVATAR_MAX_LENGTH,
|
|
@@ -25,8 +25,8 @@ declare module 'deveye-sdk-client' {
|
|
|
25
25
|
SelectHTMLAttributes
|
|
26
26
|
} from 'react';
|
|
27
27
|
import type { z, ZodType } from 'zod';
|
|
28
|
-
import type { FeatureAccess, FeatureId, WorkspaceCapability } from 'deveye
|
|
29
|
-
import type { FeatureManifest } from 'deveye
|
|
28
|
+
import type { FeatureAccess, FeatureId, WorkspaceCapability } from '@deveye/types';
|
|
29
|
+
import type { FeatureManifest } from '@deveye/types/sdk';
|
|
30
30
|
|
|
31
31
|
// ── UI kit ─────────────────────────────────────────────────────────────
|
|
32
32
|
export const Button: ComponentType<
|
|
@@ -78,6 +78,15 @@ declare module 'deveye-sdk-client' {
|
|
|
78
78
|
fill?: boolean;
|
|
79
79
|
holdSecrecy?: boolean;
|
|
80
80
|
}>;
|
|
81
|
+
/** Request the enclosing Dialog's guarded close (the unsaved-changes prompt included). */
|
|
82
|
+
export function useDialogClose(): () => void;
|
|
83
|
+
/** Register `fn` as the enclosing Dialog's primary action (Enter triggers it); `null` clears it. */
|
|
84
|
+
export function useDialogSubmit(fn: (() => void) | null): void;
|
|
85
|
+
/**
|
|
86
|
+
* Register `onEscape` as the topmost dismissible layer while `open`, so
|
|
87
|
+
* Escape closes overlays innermost first. `null` absorbs Escape without closing.
|
|
88
|
+
*/
|
|
89
|
+
export function useDismissLayer(open: boolean, onEscape: (() => void) | null): void;
|
|
81
90
|
export const StatusBadge: ComponentType<{
|
|
82
91
|
tone?: 'online' | 'offline' | 'success' | 'warning' | 'danger' | 'accent' | 'neutral';
|
|
83
92
|
/** Show the leading status dot (default true). */
|
|
@@ -158,8 +167,14 @@ declare module 'deveye-sdk-client' {
|
|
|
158
167
|
export interface SecrecyState {
|
|
159
168
|
/** True when password-based encryption is enabled for the account. */
|
|
160
169
|
enabled: boolean;
|
|
161
|
-
/** True while the session holds the unlocked key. */
|
|
170
|
+
/** True while the session holds the unlocked key (no prompt needed). */
|
|
162
171
|
unlocked: boolean;
|
|
172
|
+
/** True while the unlock dialog is open. */
|
|
173
|
+
prompting: boolean;
|
|
174
|
+
/** Epoch ms at which the grace window expires, or null when nothing counts down. */
|
|
175
|
+
unlockedUntil: number | null;
|
|
176
|
+
/** "Validate on every action": the key is never cached, unlocking ahead of time is pointless. */
|
|
177
|
+
alwaysPrompt: boolean;
|
|
163
178
|
}
|
|
164
179
|
/** Live lock state of the session (shared with the topbar widget and the global prompt). */
|
|
165
180
|
export function useSecrecy(): SecrecyState;
|
package/src/sdk/index.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* The DevEye feature SDK, shared surface: ids and the manifest contract.
|
|
3
3
|
*
|
|
4
4
|
* Side-specific surfaces live in their own entries so neither bundle drags the
|
|
5
|
-
* other's world in:
|
|
6
|
-
*
|
|
5
|
+
* other's world in: `@deveye/types/sdk/server` (handlers, storage, facade),
|
|
6
|
+
* `@deveye/types/sdk/client` (component contracts), `@deveye/types/sdk/testing`
|
|
7
7
|
* (handler test harness).
|
|
8
8
|
*/
|
|
9
9
|
export * from './ids';
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
externalDescriptorOf,
|
|
7
|
+
resolveExtras,
|
|
8
|
+
validateManifest,
|
|
9
|
+
type FeatureManifest
|
|
10
|
+
} from './manifest';
|
|
11
|
+
|
|
12
|
+
const base: FeatureManifest = {
|
|
13
|
+
id: 'x-demo',
|
|
14
|
+
label: 'Demo',
|
|
15
|
+
description: 'A demo module.',
|
|
16
|
+
icon: 'x-demo-icon',
|
|
17
|
+
category: 'daily',
|
|
18
|
+
notifies: false,
|
|
19
|
+
hasItems: false,
|
|
20
|
+
shareTier: 'never',
|
|
21
|
+
resources: ['x-demo.state'],
|
|
22
|
+
commands: [{ command: 'x-demo.get', input: z.object({}), output: z.object({}) }]
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
test('validateManifest accepts a minimal external manifest', () => {
|
|
26
|
+
assert.doesNotThrow(() => validateManifest(base));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('validateManifest rejects the classic mistakes', () => {
|
|
30
|
+
const rejects = (patch: Partial<FeatureManifest>, fragment: string) =>
|
|
31
|
+
assert.throws(() => validateManifest({ ...base, ...patch }), new RegExp(fragment));
|
|
32
|
+
rejects({ label: ' ' }, 'empty label');
|
|
33
|
+
rejects({ hasItems: true }, 'itemNoun');
|
|
34
|
+
rejects({ itemSegment: (id) => `item:${id}` }, 'itemSegment');
|
|
35
|
+
rejects({ shareTier: 'open' }, "shareTier 'never'");
|
|
36
|
+
rejects({ commandPrefix: 'x-demo.' }, 'commandPrefix');
|
|
37
|
+
rejects(
|
|
38
|
+
{ commands: [{ command: 'other.get', input: z.object({}), output: z.object({}) }] },
|
|
39
|
+
'x-demo'
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('externalDescriptorOf projects the identity fields only, and refuses a native id', () => {
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
externalDescriptorOf({ ...base, itemNoun: 'thing', sources: { hint: 'keys' } }),
|
|
46
|
+
{
|
|
47
|
+
id: 'x-demo',
|
|
48
|
+
label: 'Demo',
|
|
49
|
+
description: 'A demo module.',
|
|
50
|
+
icon: 'x-demo-icon',
|
|
51
|
+
notifies: false,
|
|
52
|
+
hasItems: false,
|
|
53
|
+
itemNoun: 'thing',
|
|
54
|
+
sources: { hint: 'keys' },
|
|
55
|
+
shareTier: 'never'
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
assert.throws(() => externalDescriptorOf({ ...base, id: 'weather' }), /not an external id/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('resolveExtras: the owner holds everything, a member what the grant says, unknown keys nothing', () => {
|
|
62
|
+
const specs: FeatureManifest['extraPermissions'] = [
|
|
63
|
+
{ key: 'reset', type: 'toggle', label: 'Reset', description: '' },
|
|
64
|
+
{
|
|
65
|
+
key: 'limit',
|
|
66
|
+
type: 'choice',
|
|
67
|
+
label: 'Limit',
|
|
68
|
+
description: '',
|
|
69
|
+
options: [
|
|
70
|
+
{ value: 'low', label: 'Low' },
|
|
71
|
+
{ value: 'high', label: 'High' }
|
|
72
|
+
],
|
|
73
|
+
default: 'low',
|
|
74
|
+
ownerValue: 'high'
|
|
75
|
+
}
|
|
76
|
+
];
|
|
77
|
+
const owner = resolveExtras(specs, true, {});
|
|
78
|
+
assert.equal(owner.canExtra('reset'), true);
|
|
79
|
+
assert.equal(owner.extraValue('limit'), 'high');
|
|
80
|
+
|
|
81
|
+
const member = resolveExtras(specs, false, { reset: true, limit: 'high' });
|
|
82
|
+
assert.equal(member.canExtra('reset'), true);
|
|
83
|
+
assert.equal(member.extraValue('limit'), 'high');
|
|
84
|
+
|
|
85
|
+
const restricted = resolveExtras(specs, false, { reset: false, limit: 'bogus' });
|
|
86
|
+
assert.equal(restricted.canExtra('reset'), false);
|
|
87
|
+
assert.equal(
|
|
88
|
+
restricted.extraValue('limit'),
|
|
89
|
+
'low',
|
|
90
|
+
'a value outside the options falls back to the default'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Wrong kind or undeclared: nothing, owner or not.
|
|
94
|
+
assert.equal(owner.canExtra('limit'), false);
|
|
95
|
+
assert.equal(owner.extraValue('reset'), '');
|
|
96
|
+
assert.equal(resolveExtras(undefined, true, { reset: true }).canExtra('reset'), false);
|
|
97
|
+
});
|
package/src/sdk/manifest.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ZodType } from 'zod';
|
|
2
|
+
import type { FeatureDescriptor } from '../domain/featureRegistry';
|
|
2
3
|
import {
|
|
3
4
|
EXTERNAL_FEATURE_ID_PATTERN,
|
|
4
5
|
isExternalFeatureId,
|
|
6
|
+
type ExternalFeatureId,
|
|
5
7
|
type FeatureId
|
|
6
8
|
} from '../domain/workspaceRole';
|
|
7
9
|
|
|
@@ -18,7 +20,7 @@ import {
|
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* One WebSocket command contract. Identical in shape to the descriptors native
|
|
21
|
-
* features declare in
|
|
23
|
+
* features declare in `@deveye/types/src/features/*`: the server validates the
|
|
22
24
|
* input before your handler runs and the output after it returns, both ways,
|
|
23
25
|
* so a payload that does not match a schema never crosses the wire.
|
|
24
26
|
*/
|
|
@@ -324,3 +326,67 @@ export function validateManifest(m: FeatureManifest): void {
|
|
|
324
326
|
}
|
|
325
327
|
}
|
|
326
328
|
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The registry descriptor of an EXTERNAL module, read off its manifest: what
|
|
332
|
+
* the roles screen, the settings shell and the catalog need to know without
|
|
333
|
+
* opening the module. Natives keep their published descriptor; the app's
|
|
334
|
+
* server and client registries both project through here.
|
|
335
|
+
*/
|
|
336
|
+
export function externalDescriptorOf(
|
|
337
|
+
m: FeatureManifest
|
|
338
|
+
): FeatureDescriptor & { id: ExternalFeatureId } {
|
|
339
|
+
if (!isExternalFeatureId(m.id)) {
|
|
340
|
+
throw new Error(`externalDescriptorOf: « ${m.id} » is not an external id`);
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
id: m.id,
|
|
344
|
+
label: m.label,
|
|
345
|
+
description: m.description,
|
|
346
|
+
icon: m.icon,
|
|
347
|
+
notifies: m.notifies,
|
|
348
|
+
hasItems: m.hasItems,
|
|
349
|
+
itemNoun: m.itemNoun,
|
|
350
|
+
sources: m.sources,
|
|
351
|
+
shareTier: m.shareTier
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface ExtrasResolver {
|
|
356
|
+
canExtra(key: string): boolean;
|
|
357
|
+
extraValue(key: string): string;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The runtime rules of extra permissions, shared by the app's request context
|
|
362
|
+
* and the test harness so the two can never drift:
|
|
363
|
+
* - a key the manifest does not declare, or of the other kind, yields
|
|
364
|
+
* `false` / `''`;
|
|
365
|
+
* - the workspace owner holds every toggle and gets `ownerValue` of every
|
|
366
|
+
* choice;
|
|
367
|
+
* - a member holds a toggle when the grant says `true`, and gets a choice's
|
|
368
|
+
* granted value when it is one of the options, `default` otherwise.
|
|
369
|
+
*/
|
|
370
|
+
export function resolveExtras(
|
|
371
|
+
specs: readonly ExtraPermissionSpec[] | undefined,
|
|
372
|
+
isOwner: boolean,
|
|
373
|
+
granted: Readonly<Record<string, boolean | string>>
|
|
374
|
+
): ExtrasResolver {
|
|
375
|
+
const byKey = new Map((specs ?? []).map((spec) => [spec.key, spec]));
|
|
376
|
+
return {
|
|
377
|
+
canExtra(key) {
|
|
378
|
+
const spec = byKey.get(key);
|
|
379
|
+
if (!spec || spec.type !== 'toggle') return false;
|
|
380
|
+
return isOwner || granted[key] === true;
|
|
381
|
+
},
|
|
382
|
+
extraValue(key) {
|
|
383
|
+
const spec = byKey.get(key);
|
|
384
|
+
if (!spec || spec.type !== 'choice') return '';
|
|
385
|
+
if (isOwner) return spec.ownerValue;
|
|
386
|
+
const value = granted[key];
|
|
387
|
+
return typeof value === 'string' && spec.options.some((o) => o.value === value)
|
|
388
|
+
? value
|
|
389
|
+
: spec.default;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import { createTestContext, createTestServiceDeps } from './testing';
|
|
6
|
+
|
|
7
|
+
test('createTestContext follows the manifest for extras, and records what handlers do', async () => {
|
|
8
|
+
const manifest = {
|
|
9
|
+
extraPermissions: [
|
|
10
|
+
{ key: 'reset', type: 'toggle' as const, label: 'Reset', description: '' }
|
|
11
|
+
]
|
|
12
|
+
};
|
|
13
|
+
const member = createTestContext({ manifest, isOwner: false, extras: { reset: true } });
|
|
14
|
+
assert.equal(member.canExtra('reset'), true);
|
|
15
|
+
assert.equal(createTestContext({ manifest, isOwner: false }).canExtra('reset'), false);
|
|
16
|
+
assert.equal(
|
|
17
|
+
createTestContext({ isOwner: true }).canExtra('reset'),
|
|
18
|
+
false,
|
|
19
|
+
'no manifest, no extra'
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
await member.deveye.notify.send({ subject: 's', body: 'b' }, { itemId: 3 });
|
|
23
|
+
member.audit({ action: 'x.did', description: 'did' });
|
|
24
|
+
assert.deepEqual(member.recorded.notifications, [{ subject: 's', body: 'b', itemId: 3 }]);
|
|
25
|
+
assert.deepEqual(member.recorded.audits, [{ action: 'x.did', description: 'did' }]);
|
|
26
|
+
|
|
27
|
+
await member.store.putJson(
|
|
28
|
+
'k',
|
|
29
|
+
z.object({ n: z.number() }),
|
|
30
|
+
{ n: 1 },
|
|
31
|
+
{ encryption: 'private' }
|
|
32
|
+
);
|
|
33
|
+
assert.equal(member.store.rows.get('k')?.mode, 'private');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('createTestServiceDeps: one store per workspace, hand-driven tickers, a wrapper that round-trips', async () => {
|
|
37
|
+
const deps = createTestServiceDeps({
|
|
38
|
+
workspaceIds: [1, 2],
|
|
39
|
+
devices: [{ id: 'd1', name: 'One', online: false }]
|
|
40
|
+
});
|
|
41
|
+
assert.deepEqual(await deps.listWorkspaceIds(), [1, 2]);
|
|
42
|
+
await deps.storeFor(1).put('a', '1');
|
|
43
|
+
assert.equal(await deps.storeFor(2).get('a'), null);
|
|
44
|
+
assert.equal(deps.stores.size, 2);
|
|
45
|
+
|
|
46
|
+
let beats = 0;
|
|
47
|
+
const service = deps.createTicker({
|
|
48
|
+
intervalMs: 1000,
|
|
49
|
+
tick: () => Promise.resolve(void beats++)
|
|
50
|
+
});
|
|
51
|
+
await service.start();
|
|
52
|
+
assert.equal(beats, 0, 'a ticker never starts on its own');
|
|
53
|
+
await deps.recorded.tickers[0].tick();
|
|
54
|
+
assert.equal(beats, 1);
|
|
55
|
+
|
|
56
|
+
const sealed = deps.keys.sealBytes(new Uint8Array([1, 2, 3]));
|
|
57
|
+
assert.deepEqual(deps.keys.openBytes(sealed), new Uint8Array([1, 2, 3]));
|
|
58
|
+
assert.equal(deps.keys.openBytes('nope'), null);
|
|
59
|
+
|
|
60
|
+
assert.equal(deps.devicesFor(1).isOnline('d1'), false);
|
|
61
|
+
assert.equal((await deps.devicesFor(1).list()).length, 1);
|
|
62
|
+
});
|
package/src/sdk/testing.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import type { ZodType } from 'zod';
|
|
2
|
+
import { resolveExtras, type FeatureManifest } from './manifest';
|
|
2
3
|
import type {
|
|
3
4
|
DevEyeFacade,
|
|
5
|
+
FeatureServiceDeps,
|
|
4
6
|
FeatureStore,
|
|
5
7
|
SdkCipher,
|
|
8
|
+
SdkDevice,
|
|
6
9
|
SdkFeatureContext,
|
|
7
10
|
SdkLogger,
|
|
8
11
|
StorageEncryption
|
|
9
12
|
} from './server';
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
|
-
* Test
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* Test harnesses: a fully in-memory {@link SdkFeatureContext} for handlers and
|
|
16
|
+
* a matching {@link FeatureServiceDeps} for background services, with
|
|
17
|
+
* identity ciphers, a recording facade, and a silent logger. Call your code
|
|
18
|
+
* directly from `node:test` files; no app, no database, no socket.
|
|
15
19
|
*
|
|
16
20
|
* ```ts
|
|
17
|
-
* const ctx = createTestContext({ repo: fakeRepo() });
|
|
21
|
+
* const ctx = createTestContext({ repo: fakeRepo(), manifest });
|
|
18
22
|
* const out = await myFeature.features[0].handler(ctx, { name: 'x' });
|
|
19
23
|
* assert.equal(ctx.recorded.notifications.length, 1);
|
|
24
|
+
*
|
|
25
|
+
* const deps = createTestServiceDeps({ repo: fakeRepo() });
|
|
26
|
+
* const service = myServer.createService(deps);
|
|
27
|
+
* await deps.recorded.tickers[0].tick();
|
|
20
28
|
* ```
|
|
21
29
|
*/
|
|
22
30
|
|
|
@@ -76,6 +84,31 @@ export interface RecordedCalls {
|
|
|
76
84
|
agentRequests: { method: string; deviceId: string }[];
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
function recordingNotify(recorded: RecordedCalls, hasRoute: boolean): DevEyeFacade['notify'] {
|
|
88
|
+
return {
|
|
89
|
+
hasRoute: () => Promise.resolve(hasRoute),
|
|
90
|
+
send(alert, opts) {
|
|
91
|
+
recorded.notifications.push({
|
|
92
|
+
subject: alert.subject,
|
|
93
|
+
body: alert.body,
|
|
94
|
+
itemId: opts?.itemId
|
|
95
|
+
});
|
|
96
|
+
return Promise.resolve(true);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function recordingDevices(devices: readonly SdkDevice[]): DevEyeFacade['devices'] {
|
|
102
|
+
return {
|
|
103
|
+
authorize: (id) =>
|
|
104
|
+
Promise.resolve(
|
|
105
|
+
devices.find((d) => d.id === id) ?? { id, name: 'Test device', online: true }
|
|
106
|
+
),
|
|
107
|
+
list: () => Promise.resolve([...devices]),
|
|
108
|
+
isOnline: (id) => devices.find((d) => d.id === id)?.online ?? true
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
79
112
|
function recordingAgents(recorded: RecordedCalls): DevEyeFacade['agents'] {
|
|
80
113
|
const req = (method: string) => (deviceId: string) => {
|
|
81
114
|
recorded.agentRequests.push({ method, deviceId });
|
|
@@ -111,8 +144,16 @@ export interface TestContextOverrides<Repo> {
|
|
|
111
144
|
canWrite?: boolean;
|
|
112
145
|
/** Extra permissions the caller holds, as the grant would carry them. */
|
|
113
146
|
extras?: Record<string, boolean | string>;
|
|
147
|
+
/**
|
|
148
|
+
* Your manifest: `canExtra` / `extraValue` then follow the exact runtime
|
|
149
|
+
* rules ({@link resolveExtras}). Without it no extra is declared, so
|
|
150
|
+
* every key answers `false` / `''`, owner or not.
|
|
151
|
+
*/
|
|
152
|
+
manifest?: Pick<FeatureManifest, 'extraPermissions'>;
|
|
114
153
|
/** What `deveye.notify.hasRoute` answers. Default true. */
|
|
115
154
|
hasRoute?: boolean;
|
|
155
|
+
/** Devices `deveye.devices` reveals. Default none listed, any id authorized. */
|
|
156
|
+
devices?: readonly SdkDevice[];
|
|
116
157
|
/** Override facade members entirely when the defaults are not enough. */
|
|
117
158
|
deveye?: Partial<DevEyeFacade>;
|
|
118
159
|
}
|
|
@@ -121,30 +162,16 @@ export function createTestContext<Repo = undefined>(
|
|
|
121
162
|
overrides: TestContextOverrides<Repo> = {}
|
|
122
163
|
): TestContext<Repo> {
|
|
123
164
|
const recorded: RecordedCalls = { notifications: [], audits: [], agentRequests: [] };
|
|
124
|
-
const
|
|
165
|
+
const isOwner = overrides.isOwner ?? true;
|
|
125
166
|
const workspaceId = overrides.workspaceId ?? 1;
|
|
126
167
|
const deveye: DevEyeFacade = {
|
|
127
|
-
notify:
|
|
128
|
-
hasRoute: () => Promise.resolve(overrides.hasRoute ?? true),
|
|
129
|
-
send(alert, opts) {
|
|
130
|
-
recorded.notifications.push({
|
|
131
|
-
subject: alert.subject,
|
|
132
|
-
body: alert.body,
|
|
133
|
-
itemId: opts?.itemId
|
|
134
|
-
});
|
|
135
|
-
return Promise.resolve(true);
|
|
136
|
-
}
|
|
137
|
-
},
|
|
168
|
+
notify: recordingNotify(recorded, overrides.hasRoute ?? true),
|
|
138
169
|
mail: { listAccounts: () => Promise.resolve([]) },
|
|
139
170
|
members: {
|
|
140
171
|
list: () =>
|
|
141
172
|
Promise.resolve([{ userId: overrides.userId ?? 1, name: 'Test', isOwner: true }])
|
|
142
173
|
},
|
|
143
|
-
devices:
|
|
144
|
-
authorize: (id) => Promise.resolve({ id, name: 'Test device', online: true }),
|
|
145
|
-
list: () => Promise.resolve([]),
|
|
146
|
-
isOnline: () => true
|
|
147
|
-
},
|
|
174
|
+
devices: recordingDevices(overrides.devices ?? []),
|
|
148
175
|
agents: recordingAgents(recorded),
|
|
149
176
|
...overrides.deveye
|
|
150
177
|
};
|
|
@@ -153,13 +180,9 @@ export function createTestContext<Repo = undefined>(
|
|
|
153
180
|
userId: overrides.userId ?? 1,
|
|
154
181
|
workspaceId,
|
|
155
182
|
workspace: { id: workspaceId, kind: overrides.kind ?? 'personal', name: 'Test' },
|
|
156
|
-
isOwner
|
|
183
|
+
isOwner,
|
|
157
184
|
canWrite: overrides.canWrite ?? true,
|
|
158
|
-
|
|
159
|
-
extraValue: (key) => {
|
|
160
|
-
const value = extras[key];
|
|
161
|
-
return typeof value === 'string' ? value : '';
|
|
162
|
-
},
|
|
185
|
+
...resolveExtras(overrides.manifest?.extraPermissions, isOwner, overrides.extras ?? {}),
|
|
163
186
|
repo: overrides.repo as Repo,
|
|
164
187
|
store: memoryStore(),
|
|
165
188
|
cipher: () => identityCipher,
|
|
@@ -177,3 +200,80 @@ export function createTestContext<Repo = undefined>(
|
|
|
177
200
|
requestId: 'test'
|
|
178
201
|
};
|
|
179
202
|
}
|
|
203
|
+
|
|
204
|
+
export interface RecordedServiceCalls extends RecordedCalls {
|
|
205
|
+
/** Every `createTicker` call, so a test drives ticks by hand: `await tickers[0].tick()`. */
|
|
206
|
+
tickers: { intervalMs: number; tick(): Promise<void> }[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface TestServiceDeps<Repo> extends FeatureServiceDeps<Repo> {
|
|
210
|
+
recorded: RecordedServiceCalls;
|
|
211
|
+
/** One in-memory store per workspace touched, keyed by workspace id. */
|
|
212
|
+
stores: Map<number, TestFeatureStore>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface TestServiceOverrides<Repo> {
|
|
216
|
+
repo?: Repo;
|
|
217
|
+
/** What `listWorkspaceIds` answers. Default `[1]`. */
|
|
218
|
+
workspaceIds?: readonly number[];
|
|
219
|
+
/** Devices every workspace reveals. Default none. */
|
|
220
|
+
devices?: readonly SdkDevice[];
|
|
221
|
+
/** What `deveyeFor(...).notify.hasRoute` answers. Default true. */
|
|
222
|
+
hasRoute?: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The service twin of {@link createTestContext}: sessionless, so no guarded
|
|
227
|
+
* cipher and no `'private'` rows, exactly like the app. Tickers never start on
|
|
228
|
+
* their own; the test calls `recorded.tickers[i].tick()` when it wants a beat.
|
|
229
|
+
*/
|
|
230
|
+
export function createTestServiceDeps<Repo = undefined>(
|
|
231
|
+
overrides: TestServiceOverrides<Repo> = {}
|
|
232
|
+
): TestServiceDeps<Repo> {
|
|
233
|
+
const recorded: RecordedServiceCalls = {
|
|
234
|
+
notifications: [],
|
|
235
|
+
audits: [],
|
|
236
|
+
agentRequests: [],
|
|
237
|
+
tickers: []
|
|
238
|
+
};
|
|
239
|
+
const stores = new Map<number, TestFeatureStore>();
|
|
240
|
+
const sealedBytes = new Map<string, Uint8Array>();
|
|
241
|
+
const notify = recordingNotify(recorded, overrides.hasRoute ?? true);
|
|
242
|
+
const devices = recordingDevices(overrides.devices ?? []);
|
|
243
|
+
return {
|
|
244
|
+
recorded,
|
|
245
|
+
stores,
|
|
246
|
+
repo: overrides.repo as Repo,
|
|
247
|
+
listWorkspaceIds: () => Promise.resolve([...(overrides.workspaceIds ?? [1])]),
|
|
248
|
+
storeFor(workspaceId) {
|
|
249
|
+
let store = stores.get(workspaceId);
|
|
250
|
+
if (!store) {
|
|
251
|
+
store = memoryStore();
|
|
252
|
+
stores.set(workspaceId, store);
|
|
253
|
+
}
|
|
254
|
+
return store;
|
|
255
|
+
},
|
|
256
|
+
cipherFor: () => identityCipher,
|
|
257
|
+
deveyeFor: () => ({ notify }),
|
|
258
|
+
devicesFor: () => ({ list: devices.list, isOnline: devices.isOnline }),
|
|
259
|
+
audit: (entry) => {
|
|
260
|
+
recorded.audits.push({ action: entry.action, description: entry.description });
|
|
261
|
+
},
|
|
262
|
+
agents: recordingAgents(recorded),
|
|
263
|
+
// A fake wrapper: the sealed string is a handle to the bytes, and an
|
|
264
|
+
// unknown handle opens to `null` exactly like a tampered blob would.
|
|
265
|
+
keys: {
|
|
266
|
+
sealBytes(plain) {
|
|
267
|
+
const handle = `sealed:${sealedBytes.size}`;
|
|
268
|
+
sealedBytes.set(handle, Uint8Array.from(plain));
|
|
269
|
+
return handle;
|
|
270
|
+
},
|
|
271
|
+
openBytes: (sealed) => sealedBytes.get(sealed) ?? null
|
|
272
|
+
},
|
|
273
|
+
createTicker({ intervalMs, tick }) {
|
|
274
|
+
recorded.tickers.push({ intervalMs, tick });
|
|
275
|
+
return { start: () => undefined, stop: () => undefined };
|
|
276
|
+
},
|
|
277
|
+
logger: silentLogger
|
|
278
|
+
};
|
|
279
|
+
}
|