@mh-gg/schema 0.1.1-alpha.20260613T085325975Z
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +39 -0
- package/schemas/app-composition.schema.json +684 -0
- package/schemas/example-app.schema.json +64 -0
- package/schemas/example-demo.schema.json +45 -0
- package/schemas/micro-plugin.schema.json +20 -0
- package/src/actions.cjs +31 -0
- package/src/app.d.ts +119 -0
- package/src/builder/app.cjs +157 -0
- package/src/builder/effects.cjs +116 -0
- package/src/builder/guards.cjs +35 -0
- package/src/builder/index.cjs +11 -0
- package/src/builder/matterhornApp/build.cjs +137 -0
- package/src/builder/matterhornApp/bundle.cjs +89 -0
- package/src/builder/matterhornApp/demo.cjs +161 -0
- package/src/builder/matterhornApp/demoAliases.cjs +50 -0
- package/src/builder/matterhornApp/descriptors.cjs +59 -0
- package/src/builder/matterhornApp/exports.cjs +95 -0
- package/src/builder/matterhornApp/frontend.cjs +80 -0
- package/src/builder/matterhornApp/plugins.cjs +95 -0
- package/src/builder/matterhornApp/shared.cjs +105 -0
- package/src/builder/matterhornApp.cjs +7 -0
- package/src/builder/model.cjs +172 -0
- package/src/builder/notifications.cjs +51 -0
- package/src/builder/refs.cjs +33 -0
- package/src/builder/schema.cjs +101 -0
- package/src/builder/streamKey.cjs +27 -0
- package/src/composition.cjs +157 -0
- package/src/configured.cjs +41 -0
- package/src/configured.d.ts +1 -0
- package/src/imports/loader.cjs +86 -0
- package/src/index.cjs +12 -0
- package/src/index.d.ts +168 -0
- package/src/json.cjs +70 -0
- package/src/jsonSchema/validator.cjs +131 -0
- package/src/microPlugin.cjs +112 -0
- package/src/model/collections.cjs +57 -0
- package/src/model/effects.cjs +149 -0
- package/src/model/expressions.cjs +85 -0
- package/src/model/guards.cjs +139 -0
- package/src/model/index.cjs +9 -0
- package/src/model/partitionOperands.cjs +89 -0
- package/src/model/partitionValidator.cjs +95 -0
- package/src/model/payload.cjs +93 -0
- package/src/model/plugin.cjs +146 -0
- package/src/notifications.cjs +41 -0
- package/src/notifications.d.ts +63 -0
- package/src/registry.cjs +63 -0
- package/src/runtime-exports.d.ts +65 -0
- package/src/streamKey.d.ts +15 -0
- package/src/types/actionTypes.cjs +164 -0
- package/src/types/coreActionPayloadSchemas.cjs +35 -0
- package/src/types/coreFeatures.cjs +29 -0
- package/src/types/entities.cjs +131 -0
- package/src/types/entityAliases.cjs +84 -0
- package/src/types/generator.cjs +145 -0
- package/src/types/index.cjs +5 -0
- package/src/types/interfaceBlock.cjs +41 -0
- package/src/types/pluginEntities.cjs +83 -0
- package/src/types/schema.cjs +148 -0
- package/src/types/standardPluginEntityTypes.cjs +135 -0
- package/test/content-lww-contract.test.cjs +104 -0
- package/test/partitionValidator.test.cjs +92 -0
- package/test/schema-array-effects.test.cjs +116 -0
- package/test/schema-builder-types.test.cjs +383 -0
- package/test/schema-composition.test.cjs +144 -0
- package/test/schema-configured-builders.test.cjs +286 -0
- package/test/schema-imports.test.cjs +66 -0
- package/test/schema-model-interpreter.test.cjs +273 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const test = require('node:test');
|
|
3
|
+
const { applyEffect, applyEffects, SUPPORTED_SCHEMA_EFFECT_KINDS } = require('../src/model/index.cjs');
|
|
4
|
+
|
|
5
|
+
function ctx(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
operation: { id: 'op_1', createdAt: 1000, type: 'card.move', ...(overrides.operation || {}) },
|
|
8
|
+
actor: { memberId: 'alice', deviceId: 'dev_alice', role: 'member', ...(overrides.actor || {}) },
|
|
9
|
+
payload: { ...(overrides.payload || {}) }
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function legacyMoveCard(state, payload, createdAt = 1000) {
|
|
14
|
+
const cards = state.cards || {};
|
|
15
|
+
const card = cards[payload.cardId];
|
|
16
|
+
if (!card) throw new Error(`Card ${payload.cardId} not found`);
|
|
17
|
+
const lists = Array.isArray(state.lists) ? state.lists : [];
|
|
18
|
+
const toList = lists.find((list) => list.id === payload.toListId);
|
|
19
|
+
if (!toList) throw new Error(`List ${payload.toListId} not found`);
|
|
20
|
+
const nextLists = lists.map((list) => {
|
|
21
|
+
const without = (list.cardIds || []).filter((id) => id !== payload.cardId);
|
|
22
|
+
if (list.id !== toList.id) return { ...list, cardIds: without };
|
|
23
|
+
const cardIds = without.slice();
|
|
24
|
+
cardIds.splice(Math.min(payload.position ?? cardIds.length, cardIds.length), 0, payload.cardId);
|
|
25
|
+
return { ...list, cardIds };
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
...state,
|
|
29
|
+
lists: nextLists,
|
|
30
|
+
cards: { ...cards, [card.id]: { ...card, listId: toList.id, updatedAt: createdAt } }
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const moveEffects = [
|
|
35
|
+
{ kind: 'removeIdFromRecordArray', collection: 'lists', storage: 'array', idField: 'fromListId', arrayField: 'cardIds', value: '$payload.cardId', recordLabel: 'List' },
|
|
36
|
+
{ kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'toListId', arrayField: 'cardIds', value: '$payload.cardId', position: '$payload.position', recordLabel: 'List' },
|
|
37
|
+
{ kind: 'updateRecord', collection: 'cards', storage: 'map', idField: 'cardId', fields: { listId: '$payload.toListId', updatedAt: '$createdAt' }, recordLabel: 'Card' }
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
test('schema array primitives expose a domain-neutral allowlist', () => {
|
|
41
|
+
assert.ok(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('insertIdIntoRecordArray'));
|
|
42
|
+
assert.ok(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('removeIdFromRecordArray'));
|
|
43
|
+
assert.equal(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('moveCard'), false);
|
|
44
|
+
assert.equal(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('appendIdToArrayInRecord'), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('insertIdIntoRecordArray appends with de-dupe and repositions when a finite position is supplied', () => {
|
|
48
|
+
const state = { lists: [{ id: 'todo', cardIds: ['a', 'b'] }] };
|
|
49
|
+
const appendDuplicate = applyEffect(state, {
|
|
50
|
+
kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
|
|
51
|
+
}, ctx({ payload: { listId: 'todo', cardId: 'b' } }));
|
|
52
|
+
assert.deepEqual(appendDuplicate.lists[0].cardIds, ['a', 'b']);
|
|
53
|
+
|
|
54
|
+
const appendNew = applyEffect(state, {
|
|
55
|
+
kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
|
|
56
|
+
}, ctx({ payload: { listId: 'todo', cardId: 'c' } }));
|
|
57
|
+
assert.deepEqual(appendNew.lists[0].cardIds, ['a', 'b', 'c']);
|
|
58
|
+
|
|
59
|
+
const repositioned = applyEffect(state, {
|
|
60
|
+
kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId', position: '$payload.position'
|
|
61
|
+
}, ctx({ payload: { listId: 'todo', cardId: 'b', position: 0 } }));
|
|
62
|
+
assert.deepEqual(repositioned.lists[0].cardIds, ['b', 'a']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('insertIdIntoRecordArray clamps positioned inserts and works with map storage', () => {
|
|
66
|
+
const state = { columns: { todo: { id: 'todo', itemIds: ['a'] } } };
|
|
67
|
+
const next = applyEffect(state, {
|
|
68
|
+
kind: 'insertIdIntoRecordArray', collection: 'columns', storage: 'map', idField: 'columnId', arrayField: 'itemIds', value: '$payload.itemId', position: '$payload.position'
|
|
69
|
+
}, ctx({ payload: { columnId: 'todo', itemId: 'b', position: 99 } }));
|
|
70
|
+
assert.deepEqual(next.columns.todo.itemIds, ['a', 'b']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('removeIdFromRecordArray removes all matching ids and throws on missing records', () => {
|
|
74
|
+
const state = { lists: [{ id: 'todo', cardIds: ['a', 'b', 'a'] }] };
|
|
75
|
+
const next = applyEffect(state, {
|
|
76
|
+
kind: 'removeIdFromRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
|
|
77
|
+
}, ctx({ payload: { listId: 'todo', cardId: 'a' } }));
|
|
78
|
+
assert.deepEqual(next.lists[0].cardIds, ['b']);
|
|
79
|
+
|
|
80
|
+
assert.throws(() => applyEffect(state, {
|
|
81
|
+
kind: 'removeIdFromRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
|
|
82
|
+
}, ctx({ payload: { listId: 'missing', cardId: 'a' } })), /Record missing not found/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('composed card.move effects match the legacy moveCard behavior under the single-list invariant', () => {
|
|
86
|
+
const fixtures = [
|
|
87
|
+
{
|
|
88
|
+
name: 'move to empty list',
|
|
89
|
+
state: { lists: [{ id: 'todo', cardIds: ['card_1'] }, { id: 'done', cardIds: [] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' } } },
|
|
90
|
+
payload: { cardId: 'card_1', fromListId: 'todo', toListId: 'done' }
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'move with explicit position',
|
|
94
|
+
state: { lists: [{ id: 'todo', cardIds: ['card_1'] }, { id: 'done', cardIds: ['card_2'] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' }, card_2: { id: 'card_2', listId: 'done', title: 'Two' } } },
|
|
95
|
+
payload: { cardId: 'card_1', fromListId: 'todo', toListId: 'done', position: 0 }
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'position clamps past end',
|
|
99
|
+
state: { lists: [{ id: 'todo', cardIds: ['card_1'] }, { id: 'done', cardIds: ['card_2'] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' }, card_2: { id: 'card_2', listId: 'done', title: 'Two' } } },
|
|
100
|
+
payload: { cardId: 'card_1', fromListId: 'todo', toListId: 'done', position: 99 }
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'same-list reorder',
|
|
104
|
+
state: { lists: [{ id: 'todo', cardIds: ['card_1', 'card_2', 'card_3'] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' }, card_2: { id: 'card_2', listId: 'todo', title: 'Two' }, card_3: { id: 'card_3', listId: 'todo', title: 'Three' } } },
|
|
105
|
+
payload: { cardId: 'card_3', fromListId: 'todo', toListId: 'todo', position: 0 }
|
|
106
|
+
}
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
for (const fixture of fixtures) {
|
|
110
|
+
const context = ctx({ payload: fixture.payload, operation: { createdAt: 2000 } });
|
|
111
|
+
const rewritten = applyEffects(fixture.state, moveEffects, context);
|
|
112
|
+
const legacy = legacyMoveCard(fixture.state, fixture.payload, 2000);
|
|
113
|
+
assert.deepEqual(rewritten, legacy, fixture.name);
|
|
114
|
+
assert.deepEqual(JSON.parse(JSON.stringify(rewritten)), JSON.parse(JSON.stringify(legacy)), `${fixture.name} JSON shape`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const test = require('node:test');
|
|
6
|
+
const {
|
|
7
|
+
manifestHash,
|
|
8
|
+
playerSupportsApp,
|
|
9
|
+
validateAppPackManifest,
|
|
10
|
+
validateHostPackManifest,
|
|
11
|
+
validatePlayerPackManifest
|
|
12
|
+
} = require('@mh-gg/base');
|
|
13
|
+
const { HostPluginRuntime, createMemoryOperationLog, createMemoryRoomStore } = require('@mh-gg/host-runtime');
|
|
14
|
+
const { ensureOperationIdentity } = require('@mh-gg/protocol');
|
|
15
|
+
const { createSignedPartyEvent, nostrEventToPartyEvent, partyEventToNostrEvent } = require('@mh-gg/event');
|
|
16
|
+
const {
|
|
17
|
+
defineApp,
|
|
18
|
+
defineModel,
|
|
19
|
+
fx,
|
|
20
|
+
guard,
|
|
21
|
+
op,
|
|
22
|
+
p,
|
|
23
|
+
q,
|
|
24
|
+
ref,
|
|
25
|
+
createSchemaDefinedHostPlugin,
|
|
26
|
+
genericSummary
|
|
27
|
+
} = require('../src/index.cjs');
|
|
28
|
+
|
|
29
|
+
function inertPlugin(id) {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
operationSchemaDescriptor: { operations: {} }
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dynamicBoardApp() {
|
|
38
|
+
const model = defineModel({
|
|
39
|
+
roles: {
|
|
40
|
+
member: { name: 'Member', rank: 1, system: true },
|
|
41
|
+
admin: { name: 'Admin', rank: 3, system: true }
|
|
42
|
+
},
|
|
43
|
+
state: { lists: {}, cards: {}, settings: {}, activity: [] }
|
|
44
|
+
});
|
|
45
|
+
const { lists, cards } = model.collections;
|
|
46
|
+
const board = model.withOperations({
|
|
47
|
+
'list.create': op({
|
|
48
|
+
authorize: { roles: ['member'] },
|
|
49
|
+
payload: {
|
|
50
|
+
listId: p.string({ max: 80 }),
|
|
51
|
+
title: p.string({ max: 80 }),
|
|
52
|
+
wipLimit: p.number({ min: 0 })
|
|
53
|
+
}
|
|
54
|
+
}, ({ ref, fx }) => ({
|
|
55
|
+
effects: [fx.create(lists, {
|
|
56
|
+
id: ref.payload('listId'),
|
|
57
|
+
fields: { title: ref.payload('title'), cardIds: [], wipLimit: ref.payload('wipLimit'), createdAt: ref.now() }
|
|
58
|
+
})]
|
|
59
|
+
})),
|
|
60
|
+
'card.create': op({
|
|
61
|
+
authorize: { roles: ['member'] },
|
|
62
|
+
payload: { cardId: p.string({ max: 80 }), listId: p.string({ max: 80 }), title: p.string({ max: 160 }) }
|
|
63
|
+
}, ({ ref, fx, guard }) => ({
|
|
64
|
+
guards: [
|
|
65
|
+
guard.lt(
|
|
66
|
+
guard.length('lists[$payload.listId].cardIds'),
|
|
67
|
+
guard.path('lists[$payload.listId].wipLimit'),
|
|
68
|
+
'List is at its WIP limit'
|
|
69
|
+
)
|
|
70
|
+
],
|
|
71
|
+
effects: [
|
|
72
|
+
fx.create(cards, { id: ref.payload('cardId'), fields: { listId: ref.payload('listId'), title: ref.payload('title'), createdAt: ref.now() } }),
|
|
73
|
+
fx.insertIntoArray(lists, { matchId: ref.payload('listId'), field: 'cardIds', value: ref.payload('cardId') })
|
|
74
|
+
]
|
|
75
|
+
})),
|
|
76
|
+
'settings.update': op({
|
|
77
|
+
authorize: { roles: ['member'] },
|
|
78
|
+
payload: {
|
|
79
|
+
media: p.object({
|
|
80
|
+
audio: p.boolean().default(true),
|
|
81
|
+
video: p.boolean().optional(),
|
|
82
|
+
screen: p.boolean().optional()
|
|
83
|
+
}),
|
|
84
|
+
roleAccess: p.record(p.enum(['editor', 'readonly'])).optional()
|
|
85
|
+
}
|
|
86
|
+
}, () => ({ effects: [fx.merge({ path: 'settings', fields: '$payload' })] }))
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return defineApp({
|
|
90
|
+
id: 'com.matterhorn.tests.dynamic-board',
|
|
91
|
+
name: 'Dynamic Board',
|
|
92
|
+
version: '1.0.0',
|
|
93
|
+
model: board,
|
|
94
|
+
views: { board: q.state() },
|
|
95
|
+
routes: [{ path: '/', component: 'BoardPage' }]
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function operation(app, type, payload, overrides = {}) {
|
|
100
|
+
const composition = app.toJSON();
|
|
101
|
+
const actor = overrides.actor || { memberId: 'ada', deviceId: 'dev_ada', role: 'member', displayName: 'Ada' };
|
|
102
|
+
const createdAt = overrides.createdAt || 1000;
|
|
103
|
+
return ensureOperationIdentity({
|
|
104
|
+
clientOperationId: overrides.id || `op_${type.replace(/[^a-z0-9]+/gi, '_')}`,
|
|
105
|
+
roomId: 'room_dynamic_board',
|
|
106
|
+
appPackId: composition.app.id,
|
|
107
|
+
appPackHash: manifestHash({ id: composition.app.id, version: composition.app.version }),
|
|
108
|
+
pluginId: composition.primaryPlugin.id,
|
|
109
|
+
type,
|
|
110
|
+
actor,
|
|
111
|
+
seq: overrides.seq || 1,
|
|
112
|
+
createdAt,
|
|
113
|
+
payload,
|
|
114
|
+
auth: { credentialId: 'cred', signature: 'sig' }
|
|
115
|
+
}, { now: createdAt, nodeId: actor.deviceId || actor.memberId });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test('defineApp emits JSON fragments and a concrete frontend type namespace', () => {
|
|
119
|
+
const app = dynamicBoardApp();
|
|
120
|
+
const composition = app.toJSON();
|
|
121
|
+
assert.equal(composition.primaryPlugin.model.operations['settings.update'].payload.required.media.type, 'object');
|
|
122
|
+
assert.equal(composition.primaryPlugin.model.operations['settings.update'].payload.optional.roleAccess.type, 'record');
|
|
123
|
+
assert.doesNotThrow(() => JSON.stringify(composition));
|
|
124
|
+
|
|
125
|
+
const types = app.toTypes({ namespace: 'DynamicBoard' });
|
|
126
|
+
assert.match(types, /export namespace DynamicBoard/);
|
|
127
|
+
assert.match(types, /cardCreate: \{ cardId: string; listId: string; title: string \}/);
|
|
128
|
+
assert.match(types, /cardIds: string\[\]/);
|
|
129
|
+
assert.match(types, /settingsUpdate: \{ media: \{ audio: boolean; video\?: boolean; screen\?: boolean \}; roleAccess\?: Record<string, "editor" \| "readonly"> \}/);
|
|
130
|
+
assert.match(types, /export type ActionName = keyof Actions/);
|
|
131
|
+
assert.match(types, /Requires `member` role\./);
|
|
132
|
+
assert.match(types, /title: string, <= 80/);
|
|
133
|
+
assert.match(types, /wipLimit: number, >= 0/);
|
|
134
|
+
assert.doesNotMatch(types, /ActionHelpers|DispatchResult|ConnectionStatus|export interface Client/);
|
|
135
|
+
assert.doesNotMatch(types, /dispatch<K extends ActionName>|query<K extends QueryName>/);
|
|
136
|
+
|
|
137
|
+
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'matterhorn-builder-'));
|
|
138
|
+
const emitted = app.emit({ outDir, namespace: 'DynamicBoard' });
|
|
139
|
+
assert.equal(fs.existsSync(emitted.schemaPath), true);
|
|
140
|
+
assert.equal(fs.existsSync(emitted.typesPath), true);
|
|
141
|
+
assert.equal(path.basename(emitted.schemaPath), 'matterhorn.schema.json');
|
|
142
|
+
assert.equal(path.basename(emitted.typesPath), 'matterhorn.types.d.ts');
|
|
143
|
+
assert.equal("modelPath" in emitted, false);
|
|
144
|
+
assert.equal("actionsPath" in emitted, false);
|
|
145
|
+
assert.equal("compositionPath" in emitted, false);
|
|
146
|
+
assert.equal("clientPath" in emitted, false);
|
|
147
|
+
assert.equal(fs.existsSync(path.join(outDir, "client.ts")), false);
|
|
148
|
+
assert.equal(fs.existsSync(path.join(outDir, "actions.json")), false);
|
|
149
|
+
assert.equal(fs.existsSync(path.join(outDir, "model.json")), false);
|
|
150
|
+
assert.equal(fs.existsSync(path.join(outDir, "composition.json")), false);
|
|
151
|
+
const schema = JSON.parse(fs.readFileSync(emitted.schemaPath, 'utf8'));
|
|
152
|
+
assert.equal(schema.primaryPlugin.model.operations['card.create'].guards[0].kind, 'lt');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('defineApp builds self-contained Matterhorn app exports without JSON sidecars', () => {
|
|
156
|
+
const app = dynamicBoardApp();
|
|
157
|
+
const matterhornOptions = {
|
|
158
|
+
slug: 'dynamic-board',
|
|
159
|
+
packageName: '@mh-gg/test-dynamic-board',
|
|
160
|
+
exportPrefix: 'dynamicBoard',
|
|
161
|
+
constantPrefix: 'DYNAMIC_BOARD',
|
|
162
|
+
frontend: false,
|
|
163
|
+
matterhorn: { bridgeGlobalName: 'MATTERHORN_DYNAMIC_BOARD_HOST', frontendProjection: 'board' },
|
|
164
|
+
demo: {
|
|
165
|
+
roomId: 'demo_board',
|
|
166
|
+
operations: [{
|
|
167
|
+
id: 'demo_list',
|
|
168
|
+
plugin: '$primary',
|
|
169
|
+
type: 'list.create',
|
|
170
|
+
actor: { memberId: 'ada', deviceId: 'dev_ada', role: 'member', displayName: 'Ada' },
|
|
171
|
+
payload: { listId: 'todo', title: 'Todo', wipLimit: 2 }
|
|
172
|
+
}]
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const exported = app.toMatterhornExports(matterhornOptions);
|
|
176
|
+
const bundle = app.toMatterhornBundle(matterhornOptions);
|
|
177
|
+
|
|
178
|
+
assert.equal(validateAppPackManifest(exported.appPack).id, 'com.matterhorn.tests.dynamic-board');
|
|
179
|
+
assert.equal(validateHostPackManifest(exported.hostPack).appPackId, exported.appPack.id);
|
|
180
|
+
assert.equal(validatePlayerPackManifest(exported.playerPack).supports[0].appPackId, exported.appPack.id);
|
|
181
|
+
assert.equal(playerSupportsApp(exported.playerPack, exported.appPack), true);
|
|
182
|
+
assert.equal(exported.matterhornApp.matterhorn.bridgeGlobalName, 'MATTERHORN_DYNAMIC_BOARD_HOST');
|
|
183
|
+
assert.equal(exported.matterhornApp.frontend, undefined);
|
|
184
|
+
assert.match(exported.generatedTypes, /export namespace DynamicBoard/);
|
|
185
|
+
assert.equal(exported.DYNAMIC_BOARD_APP_ID, 'com.matterhorn.tests.dynamic-board');
|
|
186
|
+
assert.equal(exported.DYNAMIC_BOARD_PLUGIN_ID, 'com.matterhorn.tests.dynamic-board.plugin');
|
|
187
|
+
assert.equal(exported.dynamicBoardAppPack, exported.appPack);
|
|
188
|
+
assert.equal(exported.dynamicBoardHostPlugin, exported.hostPlugin);
|
|
189
|
+
assert.equal(exported.schemaArtifacts.types, exported.generatedTypes);
|
|
190
|
+
assert.equal(exported.schemaArtifacts.schema.app.id, exported.appPack.id);
|
|
191
|
+
assert.deepEqual(exported.schemaArtifacts.files, { schema: 'matterhorn.schema.json', types: 'matterhorn.types.d.ts' });
|
|
192
|
+
|
|
193
|
+
const operations = exported.createDemoOperations('room_override');
|
|
194
|
+
assert.equal(operations.length, 1);
|
|
195
|
+
assert.equal(operations[0].roomId, 'room_override');
|
|
196
|
+
assert.equal(operations[0].pluginId, exported.hostPlugin.id);
|
|
197
|
+
assert.equal(operations[0].type, 'list.create');
|
|
198
|
+
|
|
199
|
+
assert.equal(bundle.kind, 'matterhorn.app-bundle');
|
|
200
|
+
assert.equal(bundle.id, exported.appPack.id);
|
|
201
|
+
assert.equal(bundle.appPack.id, exported.appPack.id);
|
|
202
|
+
assert.equal(bundle.hostPack.appPackId, exported.appPack.id);
|
|
203
|
+
assert.equal(bundle.hostPlugins[0].kind, 'matterhorn.schema-model-host-plugin');
|
|
204
|
+
assert.equal(bundle.hostPlugins[0].composition.primaryPlugin.id, exported.hostPlugin.id);
|
|
205
|
+
assert.equal('types' in bundle, false);
|
|
206
|
+
assert.equal('types' in bundle.artifacts, false);
|
|
207
|
+
assert.equal(bundle.artifacts.composition.app.id, exported.appPack.id);
|
|
208
|
+
assert.doesNotThrow(() => JSON.stringify(bundle));
|
|
209
|
+
|
|
210
|
+
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'matterhorn-bundle-'));
|
|
211
|
+
const emitted = app.emitMatterhornBundle({ ...matterhornOptions, outDir });
|
|
212
|
+
assert.equal(emitted.bundlePath, path.join(outDir, 'com.matterhorn.tests.dynamic-board.json'));
|
|
213
|
+
const emittedBundle = JSON.parse(fs.readFileSync(emitted.bundlePath, 'utf8'));
|
|
214
|
+
assert.equal(emittedBundle.kind, 'matterhorn.app-bundle');
|
|
215
|
+
assert.equal('types' in emittedBundle, false);
|
|
216
|
+
assert.equal('types' in emittedBundle.artifacts, false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('defineApp matterhorn exports honor app-stack options and host plugin helpers', () => {
|
|
220
|
+
const app = dynamicBoardApp();
|
|
221
|
+
const explicitHostPlugin = createSchemaDefinedHostPlugin(app.toJSON());
|
|
222
|
+
const sidecarPlugin = inertPlugin('com.matterhorn.tests.sidecar');
|
|
223
|
+
const nestedPlugin = inertPlugin('com.matterhorn.tests.nested');
|
|
224
|
+
const suitePlugin = inertPlugin('com.matterhorn.tests.suite');
|
|
225
|
+
const exported = app.toMatterhornExports({
|
|
226
|
+
exportPrefix: 'dynamicBoard',
|
|
227
|
+
constantPrefix: 'DYNAMIC_BOARD',
|
|
228
|
+
packageName: '@mh-gg/test-dynamic-board',
|
|
229
|
+
hostPlugin: explicitHostPlugin,
|
|
230
|
+
hostPluginExport: 'explicitHostPlugin',
|
|
231
|
+
hostPluginSource: 'workspace:test#explicitHostPlugin',
|
|
232
|
+
hostPluginDependsOn: [{ id: 'matterhorn.core', optional: true }],
|
|
233
|
+
hostPluginConflictsWith: ['com.matterhorn.tests.conflict'],
|
|
234
|
+
hostPlugins: [
|
|
235
|
+
null,
|
|
236
|
+
[{ plugin: sidecarPlugin, config: { enabled: true }, packageName: '@mh-gg/sidecar', exportName: 'sidecarPlugin' }],
|
|
237
|
+
{ entries: [nestedPlugin] },
|
|
238
|
+
{ kind: 'matterhorn.example-plugin-suite', entries: [{ plugin: suitePlugin, source: 'workspace:test#suitePlugin' }] }
|
|
239
|
+
],
|
|
240
|
+
frontend: {
|
|
241
|
+
root: __dirname,
|
|
242
|
+
port: 4555,
|
|
243
|
+
label: 'Dynamic Board test frontend',
|
|
244
|
+
backgroundColor: ' #102030 ',
|
|
245
|
+
icon: ' assets/board.svg ',
|
|
246
|
+
devEntry: 'src/main.ts',
|
|
247
|
+
bundle: {
|
|
248
|
+
builtEntry: 'dynamic-board.js',
|
|
249
|
+
prebuilt: true
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
matterhornApp: { matterhorn: { bridgeGlobalName: 'MATTERHORN_DYNAMIC_BOARD_HOST', frontendProjection: 'board' } },
|
|
253
|
+
deployment: { relay: { mode: 'embedded', autoStart: false } },
|
|
254
|
+
publisher: { id: 'com.matterhorn.tests', name: 'Matterhorn Tests', publicKey: 'rk_pub_tests' },
|
|
255
|
+
trust: { signatures: [{ publicKey: 'rk_pub_tests', signature: 'sig_tests' }] },
|
|
256
|
+
appCapabilities: { required: ['room.state'], optional: [] },
|
|
257
|
+
hostCapabilities: { required: ['room.state'], optional: [] },
|
|
258
|
+
entrypoints: { default: 'https://example.test/player/' },
|
|
259
|
+
recommendedFor: { devices: ['desktop'], roles: ['member'] },
|
|
260
|
+
playerRoutes: [{ path: '/', component: 'BoardShell', requiredPlugins: [explicitHostPlugin.id] }],
|
|
261
|
+
playerActions: { custom: async () => {} },
|
|
262
|
+
optimisticReducers: { custom: (state) => state },
|
|
263
|
+
navigation: { adminRoute: '/settings' },
|
|
264
|
+
deviceHints: { phone: true },
|
|
265
|
+
exportAliases: { primaryPack: 'dynamicBoardAppPack' },
|
|
266
|
+
createdAt: '2026-06-04T00:00:00Z'
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const explicitRef = exported.hostPack.plugins.find((plugin) => plugin.id === explicitHostPlugin.id);
|
|
270
|
+
assert.equal(explicitRef.source, 'workspace:test#explicitHostPlugin');
|
|
271
|
+
assert.deepEqual(explicitRef.dependsOn, [{ id: 'matterhorn.core', optional: true }]);
|
|
272
|
+
assert.deepEqual(explicitRef.conflictsWith, ['com.matterhorn.tests.conflict']);
|
|
273
|
+
assert.equal(exported.hostPlugins.find((plugin) => plugin.id === sidecarPlugin.id).config.enabled, true);
|
|
274
|
+
assert.equal(exported.hostPack.plugins.some((plugin) => plugin.id === nestedPlugin.id), true);
|
|
275
|
+
assert.equal(exported.hostPack.plugins.some((plugin) => plugin.id === suitePlugin.id), true);
|
|
276
|
+
assert.equal(exported.matterhornApp.frontend.bundle.dev.command, 'npm');
|
|
277
|
+
assert.equal(exported.matterhornApp.frontend.bundle.build.command, 'npm');
|
|
278
|
+
assert.equal(exported.matterhornApp.frontend.bundle.prebuilt, true);
|
|
279
|
+
assert.equal(exported.matterhornApp.frontend.backgroundColor, '#102030');
|
|
280
|
+
assert.deepEqual(exported.matterhornApp.frontend.icon, { path: 'assets/board.svg' });
|
|
281
|
+
assert.equal(exported.matterhornApp.deployment.relay.autoStart, false);
|
|
282
|
+
assert.equal(exported.appPack.publisher.id, 'com.matterhorn.tests');
|
|
283
|
+
assert.equal(exported.appPack.trust.createdAt, '2026-06-04T00:00:00Z');
|
|
284
|
+
assert.equal(exported.playerPack.entrypoints.default, 'https://example.test/player/');
|
|
285
|
+
assert.deepEqual(exported.playerPack.recommendedFor.roles, ['member']);
|
|
286
|
+
assert.equal(exported.playerPlugin.routes[0].component, 'BoardShell');
|
|
287
|
+
assert.equal(typeof exported.playerPlugin.actions.custom, 'function');
|
|
288
|
+
assert.equal(exported.playerPlugin.navigation.adminRoute, '/settings');
|
|
289
|
+
assert.equal(exported.playerPlugin.deviceHints.phone, true);
|
|
290
|
+
assert.equal(exported.primaryPack, exported.dynamicBoardAppPack);
|
|
291
|
+
|
|
292
|
+
assert.throws(() => app.toMatterhornExports({ hostPlugins: [inertPlugin('com.matterhorn.tests.dup'), inertPlugin('com.matterhorn.tests.dup')] }), /Duplicate host plugin/);
|
|
293
|
+
assert.throws(() => app.toMatterhornExports({ exportAliases: { missingAlias: 'missingExport' } }), /missingExport/);
|
|
294
|
+
assert.throws(() => app.toMatterhornExports({ hostPlugin: { id: 'bad.plugin', version: '1.0.0', operationSchemaDescriptor: {} } }), /canonical operationSchemaDescriptor/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('defineApp demo helpers replay schema operations', async () => {
|
|
298
|
+
const app = dynamicBoardApp();
|
|
299
|
+
const exported = app.toMatterhornExports({
|
|
300
|
+
slug: 'dynamic-board',
|
|
301
|
+
packageName: '@mh-gg/test-dynamic-board',
|
|
302
|
+
exportPrefix: 'dynamicBoard',
|
|
303
|
+
demo: {
|
|
304
|
+
roomId: 'demo_board',
|
|
305
|
+
startTime: 1700000000000,
|
|
306
|
+
operations: [{
|
|
307
|
+
id: 'demo_list',
|
|
308
|
+
type: 'list.create',
|
|
309
|
+
payload: { listId: 'todo', title: 'Todo', wipLimit: 2 }
|
|
310
|
+
}]
|
|
311
|
+
},
|
|
312
|
+
summary(state) {
|
|
313
|
+
return { version: state.version, lists: Object.keys(state.plugins[exported.hostPlugin.id].lists).length };
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const operations = exported.createDemoOperations();
|
|
318
|
+
assert.equal(operations[0].roomId, 'demo_board');
|
|
319
|
+
assert.equal(operations[0].actor.memberId, 'admin');
|
|
320
|
+
assert.equal(operations[0].pluginId, exported.hostPlugin.id);
|
|
321
|
+
|
|
322
|
+
const replay = await exported.createDemo({ roomId: 'demo_override' });
|
|
323
|
+
assert.equal(replay.operations[0].roomId, 'demo_override');
|
|
324
|
+
assert.equal(replay.state.version, 1);
|
|
325
|
+
assert.equal(replay.summary.lists, 1);
|
|
326
|
+
assert.equal(replay.guestView.version, 1);
|
|
327
|
+
|
|
328
|
+
const fileEvent = partyEventToNostrEvent(createSignedPartyEvent({
|
|
329
|
+
kind: 'file.upload',
|
|
330
|
+
partyId: 'demo_board',
|
|
331
|
+
identity: {
|
|
332
|
+
privateKey: '0000000000000000000000000000000000000000000000000000000000000002',
|
|
333
|
+
pubkey: 'c6047f9441ed7d6d3045406e95c07cd85a5c778e4b8cef3ca7abac09b95c709e'
|
|
334
|
+
},
|
|
335
|
+
payload: { fileName: 'demo.txt', mimeType: 'text/plain', sizeBytes: 4, sha256: '0'.repeat(64), dataBase64: 'ZGVtbw==' },
|
|
336
|
+
createdAt: 1700000000000
|
|
337
|
+
}));
|
|
338
|
+
const fileDemo = app.toMatterhornExports({
|
|
339
|
+
slug: 'dynamic-board',
|
|
340
|
+
packageName: '@mh-gg/test-dynamic-board',
|
|
341
|
+
exportPrefix: 'dynamicBoard',
|
|
342
|
+
demo: {
|
|
343
|
+
roomId: 'demo_board',
|
|
344
|
+
operations: [{ id: 'demo_file', type: 'file.upload', pluginId: 'com.matterhorn.examples.plugins.files', payload: { scopeType: 'list', scopeId: 'todo', event: fileEvent } }]
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
const fileOperation = fileDemo.createDemoOperations('demo_override')[0];
|
|
348
|
+
assert.equal(nostrEventToPartyEvent(fileOperation.payload.event).partyId, 'demo_override');
|
|
349
|
+
assert.notEqual(fileOperation.payload.event.id, fileEvent.id);
|
|
350
|
+
|
|
351
|
+
const summary = genericSummary({
|
|
352
|
+
version: 2,
|
|
353
|
+
plugins: {
|
|
354
|
+
[exported.hostPlugin.id]: {
|
|
355
|
+
lists: { todo: { id: 'todo' }, done: { id: 'done', archivedAt: 1 } },
|
|
356
|
+
cards: [{ id: 'card_1' }, { id: 'card_2', deletedAt: 1 }],
|
|
357
|
+
settings: { theme: 'dark' },
|
|
358
|
+
details: { title: 'Board' }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}, exported.hostPlugin.id);
|
|
362
|
+
assert.deepEqual(summary, { version: 2, lists: 1, cards: 1, title: 'Board' });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('declarative comparison guards run in the schema interpreter', async () => {
|
|
366
|
+
const app = dynamicBoardApp();
|
|
367
|
+
const composition = app.toJSON();
|
|
368
|
+
const plugin = createSchemaDefinedHostPlugin(composition);
|
|
369
|
+
const runtime = new HostPluginRuntime({
|
|
370
|
+
room: { id: 'room_dynamic_board', appPack: { id: composition.app.id, version: composition.app.version, hash: manifestHash({ id: composition.app.id, version: composition.app.version }), protocolHash: composition.app.id } },
|
|
371
|
+
plugins: [plugin],
|
|
372
|
+
store: createMemoryRoomStore(),
|
|
373
|
+
operationLog: createMemoryOperationLog(),
|
|
374
|
+
authenticateActor: async (_auth, actor) => actor
|
|
375
|
+
});
|
|
376
|
+
await runtime.start();
|
|
377
|
+
|
|
378
|
+
assert.equal((await runtime.handleOperation(operation(app, 'list.create', { listId: 'todo', title: 'Todo', wipLimit: 1 }, { id: 'op_1', seq: 1 }))).ok, true);
|
|
379
|
+
assert.equal((await runtime.handleOperation(operation(app, 'card.create', { cardId: 'card_1', listId: 'todo', title: 'First' }, { id: 'op_2', seq: 2 }))).ok, true);
|
|
380
|
+
const denied = await runtime.handleOperation(operation(app, 'card.create', { cardId: 'card_2', listId: 'todo', title: 'Second' }, { id: 'op_3', seq: 3 }));
|
|
381
|
+
assert.equal(denied.ok, false);
|
|
382
|
+
assert.match(denied.reason, /WIP limit/);
|
|
383
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
const schemaModule = require("../src/index.cjs");
|
|
4
|
+
const { createConfiguredSchemaApi } = require("../src/configured.cjs");
|
|
5
|
+
const { registryRecords } = require("@mh-gg/base-plugins/composer/registry");
|
|
6
|
+
const {
|
|
7
|
+
APP_COMPOSITION_SCHEMA_KIND,
|
|
8
|
+
actionDescriptorsForPlugin,
|
|
9
|
+
builtinMicroPluginRegistry,
|
|
10
|
+
createActionDispatchersFromComposition,
|
|
11
|
+
createAppCompositionSchema,
|
|
12
|
+
createMicroPluginSchema,
|
|
13
|
+
listMicroPluginSchemas,
|
|
14
|
+
resolveCompositionHostPluginEntries,
|
|
15
|
+
validateAppCompositionSchema,
|
|
16
|
+
loadAppCompositionSchemaFile,
|
|
17
|
+
assertValidJsonSchema
|
|
18
|
+
} = createConfiguredSchemaApi(schemaModule, registryRecords());
|
|
19
|
+
const { COMMENTS_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID, PRESENCE_PLUGIN_ID } = require("@mh-gg/base-plugins");
|
|
20
|
+
|
|
21
|
+
test("micro plugin schemas are JSON-only and describe operations, views, and actions", () => {
|
|
22
|
+
const comments = builtinMicroPluginRegistry.get("comments");
|
|
23
|
+
assert.equal(comments.schema.kind, "matterhorn.micro-plugin.schema");
|
|
24
|
+
assert.equal(comments.schema.id, COMMENTS_PLUGIN_ID);
|
|
25
|
+
assert.equal(typeof comments.schema.schemaHash, "string");
|
|
26
|
+
assert.equal([...comments.schema.schemas.operations["comments.add"].required, ...comments.schema.schemas.operations["comments.add"].optional].includes("scopeType"), true);
|
|
27
|
+
assert.equal(comments.schema.views.some((view) => view.name === "commentsForScope"), true);
|
|
28
|
+
assert.equal(comments.schema.actions.some((action) => action.type === "comments.add"), true);
|
|
29
|
+
assert.doesNotThrow(() => JSON.stringify(comments.schema));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("micro plugin schemas reject function-bearing glue", () => {
|
|
33
|
+
assert.throws(() => createMicroPluginSchema({
|
|
34
|
+
key: "bad",
|
|
35
|
+
id: "bad.plugin",
|
|
36
|
+
version: "1.0.0",
|
|
37
|
+
stateSchema: { type: "object" },
|
|
38
|
+
operationSchemas: { "bad.run": { parse() {} } }
|
|
39
|
+
}), /JSON serializable/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("app composition schemas resolve reusable micro plugins without passing plugin JS through app code", () => {
|
|
43
|
+
const schema = createAppCompositionSchema({
|
|
44
|
+
app: { id: "app.test", version: "1.0.0", name: "Schema App" },
|
|
45
|
+
primaryPlugin: { id: "app.test.primary", version: "1.0.0" },
|
|
46
|
+
plugins: ["comments", "presence", { key: "mediaRooms", config: { defaultRooms: [{ id: "event_room", name: "Event room" }] } }, "comments"],
|
|
47
|
+
sharedScopes: {
|
|
48
|
+
directMessages: {
|
|
49
|
+
scopeType: "chat.direct",
|
|
50
|
+
scopeId: "$app.id",
|
|
51
|
+
identity: "$profile.id",
|
|
52
|
+
participants: "userIds",
|
|
53
|
+
collections: ["directThreads", "directMessages"]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
views: [{ name: "onlineMembers", plugin: "presence", kind: "query", query: "onlineMembers" }],
|
|
57
|
+
actions: {
|
|
58
|
+
comment: { plugin: "comments", type: "comments.add", payloadDefaults: { scopeType: "task" } },
|
|
59
|
+
updatePresence: { plugin: "presence", type: "presence.update" },
|
|
60
|
+
createRoom: { plugin: "mediaRooms", type: "media.room.create" }
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(schema.kind, APP_COMPOSITION_SCHEMA_KIND);
|
|
65
|
+
assert.deepEqual(schema.plugins.map((plugin) => plugin.id), [COMMENTS_PLUGIN_ID, PRESENCE_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID]);
|
|
66
|
+
assert.equal(validateAppCompositionSchema(schema).schemaHash, schema.schemaHash);
|
|
67
|
+
assert.deepEqual(schema.sharedScopes.directMessages.collections, ["directThreads", "directMessages"]);
|
|
68
|
+
|
|
69
|
+
const entries = resolveCompositionHostPluginEntries(schema);
|
|
70
|
+
assert.deepEqual(entries.map((entry) => entry.plugin.id), [COMMENTS_PLUGIN_ID, PRESENCE_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID]);
|
|
71
|
+
assert.deepEqual(entries[2].config.defaultRooms[0], { id: "event_room", name: "Event room" });
|
|
72
|
+
|
|
73
|
+
const dispatched = [];
|
|
74
|
+
const actions = createActionDispatchersFromComposition(schema);
|
|
75
|
+
actions.comment({ dispatch: (op) => dispatched.push(op) }, { scopeId: "card_1", body: "Ship it" });
|
|
76
|
+
actions.createRoom({ dispatch: (op) => dispatched.push(op) }, { name: "Standup", allowsVideo: true });
|
|
77
|
+
assert.deepEqual(dispatched, [
|
|
78
|
+
{ schemaAction: "comment", pluginId: COMMENTS_PLUGIN_ID, type: "comments.add", payload: { scopeType: "task", scopeId: "card_1", body: "Ship it" } },
|
|
79
|
+
{ schemaAction: "createRoom", pluginId: MEDIA_ROOMS_PLUGIN_ID, type: "media.room.create", payload: { name: "Standup", allowsVideo: true } }
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(actionDescriptorsForPlugin(schema, "comments").map((action) => action.name), ["comment"]);
|
|
82
|
+
assert.deepEqual(actionDescriptorsForPlugin(schema, { key: "mediaRooms" }).map((action) => action.name), ["createRoom"]);
|
|
83
|
+
assert.deepEqual(actionDescriptorsForPlugin({
|
|
84
|
+
...schema,
|
|
85
|
+
actions: [...schema.actions, { name: "idBasedPresence", plugin: PRESENCE_PLUGIN_ID, type: "presence.update" }]
|
|
86
|
+
}, { id: PRESENCE_PLUGIN_ID }).map((action) => action.name), ["idBasedPresence"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("raw JSON composition files are normalized and validated", () => {
|
|
90
|
+
const eventsSchema = loadAppCompositionSchemaFile(require("node:path").join(__dirname, "../../../examples/events/src/composition.json"));
|
|
91
|
+
const normalized = validateAppCompositionSchema(eventsSchema);
|
|
92
|
+
assert.equal(normalized.kind, APP_COMPOSITION_SCHEMA_KIND);
|
|
93
|
+
assert.equal(normalized.actions.find((action) => action.name === "updateEvent").plugin, "primary");
|
|
94
|
+
assert.equal(normalized.plugins.some((plugin) => plugin.id === MEDIA_ROOMS_PLUGIN_ID), true);
|
|
95
|
+
assert.equal(JSON.parse(JSON.stringify(normalized)).schemaHash, normalized.schemaHash);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("first-party reusable registry exposes every micro plugin schema", () => {
|
|
99
|
+
const schemas = listMicroPluginSchemas();
|
|
100
|
+
assert.equal(schemas.length >= 12, true);
|
|
101
|
+
assert.equal(new Set(schemas.map((schema) => schema.id)).size, schemas.length);
|
|
102
|
+
assert.equal(schemas.every((schema) => schema.kind === "matterhorn.micro-plugin.schema"), true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
test("composition JSON Schema rejects arbitrary app glue", () => {
|
|
107
|
+
assert.throws(() => assertValidJsonSchema("app-composition.schema.json", {
|
|
108
|
+
kind: "matterhorn.app-composition.schema",
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
app: { id: "bad", version: "1", name: "Bad" },
|
|
111
|
+
primaryPlugin: { id: "bad.plugin", version: "1" },
|
|
112
|
+
plugins: [],
|
|
113
|
+
views: [],
|
|
114
|
+
actions: [],
|
|
115
|
+
routes: [],
|
|
116
|
+
run() {}
|
|
117
|
+
}, { label: "composition" }), /failed: .*not allowed/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("composition shared scopes require an explicit scope id", () => {
|
|
121
|
+
assert.throws(() => assertValidJsonSchema("app-composition.schema.json", {
|
|
122
|
+
kind: "matterhorn.app-composition.schema",
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
app: { id: "app.test", version: "1", name: "App" },
|
|
125
|
+
primaryPlugin: { id: "app.test.primary", version: "1" },
|
|
126
|
+
plugins: [],
|
|
127
|
+
sharedScopes: {
|
|
128
|
+
directMessages: {
|
|
129
|
+
scopeType: "chat.direct",
|
|
130
|
+
collections: ["directThreads", "directMessages"]
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
views: [],
|
|
134
|
+
actions: [],
|
|
135
|
+
routes: []
|
|
136
|
+
}, { label: "composition" }), /scopeId/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("composition imports let actions and models live in separate files", () => {
|
|
140
|
+
const schema = loadAppCompositionSchemaFile(require("node:path").join(__dirname, "../../../examples/events/src/composition.json"));
|
|
141
|
+
assert.equal(schema.primaryPlugin.model.kind, "matterhorn.primary-model.schema");
|
|
142
|
+
assert.equal(schema.actions.some((action) => action.name === "updateEvent"), true);
|
|
143
|
+
assert.equal(schema.schemaHash, validateAppCompositionSchema(schema).schemaHash);
|
|
144
|
+
});
|