@appsemble/utils 0.23.5 → 0.23.6

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 CHANGED
@@ -1,9 +1,9 @@
1
- # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.23.5/config/assets/logo.svg) Appsemble Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.23.6/config/assets/logo.svg) Appsemble Utilities
2
2
 
3
3
  > Internal utility functions used across multiple Appsemble projects.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@appsemble/utils)](https://www.npmjs.com/package/@appsemble/utils)
6
- [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.23.5/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.23.5)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.23.6/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.23.6)
7
7
  [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io)
8
8
 
9
9
  ## Table of Contents
@@ -26,5 +26,5 @@ not guaranteed.
26
26
 
27
27
  ## License
28
28
 
29
- [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.23.5/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.23.6/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
@@ -26,6 +26,7 @@ export const ActionDefinition = {
26
26
  {
27
27
  anyOf: [
28
28
  { $ref: '#/components/schemas/AnalyticsActionDefinition' },
29
+ { $ref: '#/components/schemas/ControllerActionDefinition' },
29
30
  { $ref: '#/components/schemas/ConditionActionDefinition' },
30
31
  { $ref: '#/components/schemas/DialogActionDefinition' },
31
32
  { $ref: '#/components/schemas/DialogErrorActionDefinition' },
@@ -104,6 +104,14 @@ domain fall back to use the Appsemble server Sentry DSN.
104
104
 
105
105
  This is only applied when \`sentryDsn\` is specified.`,
106
106
  },
107
+ controllerCode: {
108
+ type: 'string',
109
+ description: 'Custom app logic as a JavaScript string',
110
+ },
111
+ controllerImplementations: {
112
+ type: 'string',
113
+ description: 'Appsemble SDK interfaces implementations',
114
+ },
107
115
  },
108
116
  };
109
117
  //# sourceMappingURL=App.js.map
@@ -65,6 +65,9 @@ This **must** match the name of a page defined for the app.
65
65
  minLength: 2,
66
66
  description: 'The default language for the app.',
67
67
  },
68
+ controller: {
69
+ $ref: '#/components/schemas/ControllerDefinition',
70
+ },
68
71
  resources: {
69
72
  type: 'object',
70
73
  description: `Resources define how Appsemble can store data for an app.
@@ -0,0 +1 @@
1
+ export declare const ControllerActionDefinition: import("openapi-types").OpenAPIV3.SchemaObject;
@@ -0,0 +1,18 @@
1
+ import { BaseActionDefinition } from './BaseActionDefinition.js';
2
+ import { extendJSONSchema } from './utils.js';
3
+ export const ControllerActionDefinition = extendJSONSchema(BaseActionDefinition, {
4
+ type: 'object',
5
+ additionalProperties: false,
6
+ required: ['type', 'handler'],
7
+ properties: {
8
+ type: {
9
+ enum: ['controller'],
10
+ description: 'Use the controller to handle the action.',
11
+ },
12
+ handler: {
13
+ type: 'string',
14
+ description: 'The name of the function in the controller that will handle the action',
15
+ },
16
+ },
17
+ });
18
+ //# sourceMappingURL=ControllerActionDefinition.js.map
@@ -0,0 +1,2 @@
1
+ import { type OpenAPIV3 } from 'openapi-types';
2
+ export declare const ControllerDefinition: OpenAPIV3.NonArraySchemaObject;
@@ -0,0 +1,17 @@
1
+ export const ControllerDefinition = {
2
+ type: 'object',
3
+ description: 'A controller for application logic.',
4
+ required: [],
5
+ additionalProperties: false,
6
+ properties: {
7
+ actions: {
8
+ type: 'object',
9
+ description: 'A mapping of actions that can be fired by the controller to action handlers.',
10
+ additionalProperties: {
11
+ $ref: '#/components/schemas/ActionDefinition',
12
+ },
13
+ },
14
+ events: { $ref: '#/components/schemas/EventsDefinition' },
15
+ },
16
+ };
17
+ //# sourceMappingURL=ControllerDefinition.js.map
@@ -2,6 +2,8 @@ export * from './ActionDefinition.js';
2
2
  export * from './AnalyticsActionDefinition.js';
3
3
  export * from './App.js';
4
4
  export * from './AppCollection.js';
5
+ export * from './ControllerDefinition.js';
6
+ export * from './ControllerActionDefinition.js';
5
7
  export * from './AppCollectionDefinition.js';
6
8
  export * from './AppAccount.js';
7
9
  export * from './AppDefinition.js';
@@ -2,6 +2,8 @@ export * from './ActionDefinition.js';
2
2
  export * from './AnalyticsActionDefinition.js';
3
3
  export * from './App.js';
4
4
  export * from './AppCollection.js';
5
+ export * from './ControllerDefinition.js';
6
+ export * from './ControllerActionDefinition.js';
5
7
  export * from './AppCollectionDefinition.js';
6
8
  export * from './AppAccount.js';
7
9
  export * from './AppDefinition.js';
package/api/paths/apps.js CHANGED
@@ -76,6 +76,14 @@ export const paths = {
76
76
  format: 'binary',
77
77
  },
78
78
  },
79
+ controllerCode: {
80
+ type: 'string',
81
+ description: 'Custom app logic as a JavaScript string',
82
+ },
83
+ controllerImplementations: {
84
+ type: 'string',
85
+ description: 'Appsemble SDK interfaces implementations',
86
+ },
79
87
  },
80
88
  },
81
89
  encoding: {
@@ -217,6 +225,14 @@ export const paths = {
217
225
  format: 'binary',
218
226
  },
219
227
  },
228
+ controllerCode: {
229
+ type: 'string',
230
+ description: 'Custom app logic as a JavaScript string',
231
+ },
232
+ controllerImplementations: {
233
+ type: 'string',
234
+ description: 'Appsemble SDK interfaces implementations',
235
+ },
220
236
  showAppsembleLogin: {
221
237
  type: 'boolean',
222
238
  description: 'Whether the Appsemble login method should be shown.',
@@ -19,7 +19,7 @@ export declare const partialNormalized: RegExp;
19
19
  */
20
20
  export declare const normalized: RegExp;
21
21
  /**
22
- * A pattern for matching the block name pattern of @organization/block.
22
+ * A pattern for matching the block name pattern of @organization/project.
23
23
  */
24
24
  export declare const blockNamePattern: RegExp;
25
25
  export declare const domainPattern: RegExp;
@@ -19,7 +19,7 @@ export const partialNormalized = /([\da-z](?:(?!.*--)[\da-z-]*[\da-z])?)/;
19
19
  */
20
20
  export const normalized = new RegExp(`^${partialNormalized.source}$`);
21
21
  /**
22
- * A pattern for matching the block name pattern of @organization/block.
22
+ * A pattern for matching the block name pattern of @organization/project.
23
23
  */
24
24
  export const blockNamePattern = new RegExp(`^@${partialNormalized.source}/${partialNormalized.source}$`);
25
25
  export const domainPattern = new RegExp(`^(${partialNormalized.source}+\\.)+[a-z]{2,}$`);
package/iterApp.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ActionDefinition, type AppDefinition, type BlockDefinition, type PageDefinition } from '@appsemble/types';
1
+ import { type ActionDefinition, type AppDefinition, type BlockDefinition, type ControllerDefinition, type PageDefinition } from '@appsemble/types';
2
2
  export type Prefix = (number | string)[];
3
3
  type IterCallback<T> = (item: T, path: Prefix) => boolean | void;
4
4
  interface IterCallbacks {
@@ -6,6 +6,7 @@ interface IterCallbacks {
6
6
  onBlockList?: IterCallback<BlockDefinition[]>;
7
7
  onBlock?: IterCallback<BlockDefinition>;
8
8
  onAction?: IterCallback<ActionDefinition>;
9
+ onController?: IterCallback<ControllerDefinition>;
9
10
  }
10
11
  /**
11
12
  * Iterate over an action definition and call each callback if relevant.
@@ -18,6 +19,7 @@ interface IterCallbacks {
18
19
  * @returns True if any callback returns true, false otherwise.
19
20
  */
20
21
  export declare function iterAction(action: ActionDefinition, callbacks: IterCallbacks, prefix?: Prefix): boolean;
22
+ export declare function iterController(controller: ControllerDefinition, callbacks: IterCallbacks, prefix?: Prefix): boolean;
21
23
  /**
22
24
  * Iterate over a block definition and call each callback if relevant.
23
25
  *
package/iterApp.js CHANGED
@@ -32,6 +32,16 @@ export function iterAction(action, callbacks, prefix = []) {
32
32
  }
33
33
  return false;
34
34
  }
35
+ export function iterController(controller, callbacks, prefix = []) {
36
+ var _a;
37
+ if ((_a = callbacks.onController) === null || _a === void 0 ? void 0 : _a.call(callbacks, controller, prefix)) {
38
+ return true;
39
+ }
40
+ if (controller.actions) {
41
+ return Object.entries(controller.actions).some(([key, action]) => iterAction(action, callbacks, [...prefix, 'actions', key]));
42
+ }
43
+ return false;
44
+ }
35
45
  /**
36
46
  * Iterate over a block definition and call each callback if relevant.
37
47
  *
@@ -116,6 +126,9 @@ export function iterApp(app, callbacks) {
116
126
  app.pages.some((page, index) => iterPage(page, callbacks, ['pages', index]))) {
117
127
  return true;
118
128
  }
129
+ if (app.controller && iterController(app.controller, callbacks, ['controller'])) {
130
+ return true;
131
+ }
119
132
  if (app.cron) {
120
133
  for (const [name, job] of Object.entries(app.cron)) {
121
134
  if ((job === null || job === void 0 ? void 0 : job.action) && iterAction(job.action, callbacks, ['cron', name, 'action'])) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/utils",
3
- "version": "0.23.5",
3
+ "version": "0.23.6",
4
4
  "description": "Utility functions used in Appsemble internally",
5
5
  "keywords": [
6
6
  "app",
@@ -37,7 +37,7 @@
37
37
  "test": "vitest"
38
38
  },
39
39
  "dependencies": {
40
- "@appsemble/types": "0.23.5",
40
+ "@appsemble/types": "0.23.6",
41
41
  "axios": "^1.0.0",
42
42
  "cron-parser": "^4.0.0",
43
43
  "date-fns": "^2.0.0",
package/validation.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AppDefinition, type BlockManifest } from '@appsemble/types';
1
+ import { type AppDefinition, type BlockManifest, type ProjectImplementations } from '@appsemble/types';
2
2
  import { type ValidatorResult } from 'jsonschema';
3
3
  import { type Promisable } from 'type-fest';
4
4
  import { type IdentifiableBlock } from './blockUtils.js';
@@ -17,8 +17,9 @@ export type BlockVersionsGetter = (blockMap: IdentifiableBlock[]) => Promisable<
17
17
  *
18
18
  * @param definition The app validation to check.
19
19
  * @param getBlockVersions A function for getting block manifests from block versions.
20
+ * @param controllerImplementations App controller implementations of interfaces.
20
21
  * @param validatorResult If specified, error messages will be applied to this existing validator
21
22
  * result.
22
23
  * @returns A validator result which contains all app validation violations.
23
24
  */
24
- export declare function validateAppDefinition(definition: AppDefinition, getBlockVersions: BlockVersionsGetter, validatorResult?: ValidatorResult): Promise<ValidatorResult>;
25
+ export declare function validateAppDefinition(definition: AppDefinition, getBlockVersions: BlockVersionsGetter, controllerImplementations?: ProjectImplementations, validatorResult?: ValidatorResult): Promise<ValidatorResult>;
package/validation.js CHANGED
@@ -120,6 +120,72 @@ function validateResourceSchemas(definition, report) {
120
120
  }
121
121
  }
122
122
  }
123
+ function validateController(definition, controllerImplementations, report) {
124
+ if (!definition.controller || !controllerImplementations) {
125
+ return;
126
+ }
127
+ iterApp(definition, {
128
+ onController(controller, path) {
129
+ var _a, _b, _c, _d, _e, _f;
130
+ const actionParameters = new Set();
131
+ if (controller.actions) {
132
+ if (controllerImplementations.actions) {
133
+ for (const [key, action] of Object.entries(controller.actions)) {
134
+ if (action.type in
135
+ [
136
+ 'link',
137
+ 'link.back',
138
+ 'link.next',
139
+ 'dialog',
140
+ 'dialog.ok',
141
+ 'dialog.error',
142
+ 'flow.back',
143
+ 'flow.cancel',
144
+ 'flow.finish',
145
+ 'flow.next',
146
+ 'flow.to',
147
+ ]) {
148
+ report(action, 'cannot be used in controllers', [...path, 'actions', key]);
149
+ }
150
+ if (controllerImplementations.actions.$any) {
151
+ if (actionParameters.has(key)) {
152
+ continue;
153
+ }
154
+ if (!has(controllerImplementations.actions, key)) {
155
+ report(action, 'is unused', [...path, 'actions', key]);
156
+ }
157
+ }
158
+ else if (!has(controllerImplementations.actions, key)) {
159
+ report(action, 'is an unknown action for this controller', [...path, 'actions', key]);
160
+ }
161
+ }
162
+ }
163
+ else {
164
+ report(controller.actions, 'is not allowed on this controller', [...path, 'actions']);
165
+ }
166
+ }
167
+ if (!controller.events) {
168
+ return;
169
+ }
170
+ if (controller.events.emit) {
171
+ for (const [key, value] of Object.entries(controller.events.emit)) {
172
+ if (!((_b = (_a = controllerImplementations.events) === null || _a === void 0 ? void 0 : _a.emit) === null || _b === void 0 ? void 0 : _b.$any) &&
173
+ !has((_c = controllerImplementations.events) === null || _c === void 0 ? void 0 : _c.emit, key)) {
174
+ report(value, 'is an unknown event emitter', [...path, 'events', 'emit', key]);
175
+ }
176
+ }
177
+ }
178
+ if (controller.events.listen) {
179
+ for (const [key, value] of Object.entries(controller.events.listen)) {
180
+ if (!((_e = (_d = controllerImplementations.events) === null || _d === void 0 ? void 0 : _d.listen) === null || _e === void 0 ? void 0 : _e.$any) &&
181
+ !has((_f = controllerImplementations.events) === null || _f === void 0 ? void 0 : _f.listen, key)) {
182
+ report(value, 'is an unknown event listener', [...path, 'events', 'listen', key]);
183
+ }
184
+ }
185
+ }
186
+ },
187
+ });
188
+ }
123
189
  function validateBlocks(definition, blockVersions, report) {
124
190
  iterApp(definition, {
125
191
  onBlock(block, path) {
@@ -506,17 +572,29 @@ function validateEvents(definition, blockVersions, report) {
506
572
  const indexMap = new Map();
507
573
  function collect(prefix, name, isEmitter) {
508
574
  const [firstKey, pageIndex] = prefix;
509
- // Ignore anything not part of a page. For example cron actions never support events.
510
- if (firstKey !== 'pages') {
511
- return;
512
- }
513
- if (typeof pageIndex !== 'number') {
514
- return;
515
- }
516
- if (!indexMap.has(pageIndex)) {
517
- indexMap.set(pageIndex, { emitters: new Map(), listeners: new Map() });
575
+ let mapAtKey;
576
+ // Ignore anything not part of controller or a page.
577
+ // For example cron actions never support events.
578
+ switch (firstKey) {
579
+ case 'controller':
580
+ if (!indexMap.has('controller')) {
581
+ indexMap.set('controller', { emitters: new Map(), listeners: new Map() });
582
+ }
583
+ mapAtKey = indexMap.get('controller');
584
+ break;
585
+ case 'pages':
586
+ if (typeof pageIndex !== 'number') {
587
+ return;
588
+ }
589
+ if (!indexMap.has(pageIndex)) {
590
+ indexMap.set(pageIndex, { emitters: new Map(), listeners: new Map() });
591
+ }
592
+ mapAtKey = indexMap.get(pageIndex);
593
+ break;
594
+ default:
595
+ return;
518
596
  }
519
- const { emitters, listeners } = indexMap.get(pageIndex);
597
+ const { emitters, listeners } = mapAtKey;
520
598
  const context = isEmitter ? emitters : listeners;
521
599
  if (!context.has(name)) {
522
600
  context.set(name, []);
@@ -525,6 +603,21 @@ function validateEvents(definition, blockVersions, report) {
525
603
  prefixes.push(prefix);
526
604
  }
527
605
  iterApp(definition, {
606
+ onController(controller, path) {
607
+ if (!controller.events) {
608
+ return;
609
+ }
610
+ if (controller.events.emit) {
611
+ for (const [prefix, name] of Object.entries(controller.events.emit)) {
612
+ collect([...path, 'events', 'emit', prefix], name, true);
613
+ }
614
+ }
615
+ if (controller.events.listen) {
616
+ for (const [prefix, name] of Object.entries(controller.events.listen)) {
617
+ collect([...path, 'events', 'listen', prefix], name, false);
618
+ }
619
+ }
620
+ },
528
621
  onAction(action, path) {
529
622
  if (action.type === 'dialog') {
530
623
  for (const block of action.blocks) {
@@ -567,16 +660,50 @@ function validateEvents(definition, blockVersions, report) {
567
660
  }
568
661
  },
569
662
  });
663
+ let controllerEvents = {
664
+ emitters: new Map(),
665
+ listeners: new Map(),
666
+ };
667
+ if (indexMap.has('controller')) {
668
+ controllerEvents = { ...indexMap.get('controller') };
669
+ }
670
+ indexMap.delete('controller');
671
+ for (const [name, prefixes] of controllerEvents.emitters.entries()) {
672
+ let found = false;
673
+ for (const { listeners } of indexMap.values()) {
674
+ if (listeners.has(name)) {
675
+ found = true;
676
+ }
677
+ }
678
+ if (!found) {
679
+ for (const prefix of prefixes) {
680
+ report(name, 'does not match any listeners', prefix);
681
+ }
682
+ }
683
+ }
684
+ for (const [name, prefixes] of controllerEvents.listeners.entries()) {
685
+ let found = false;
686
+ for (const { emitters } of indexMap.values()) {
687
+ if (emitters.has(name)) {
688
+ found = true;
689
+ }
690
+ }
691
+ if (!found) {
692
+ for (const prefix of prefixes) {
693
+ report(name, 'does not match any emitters', prefix);
694
+ }
695
+ }
696
+ }
570
697
  for (const { emitters, listeners } of indexMap.values()) {
571
698
  for (const [name, prefixes] of listeners.entries()) {
572
- if (!emitters.has(name)) {
699
+ if (!emitters.has(name) && !controllerEvents.emitters.has(name)) {
573
700
  for (const prefix of prefixes) {
574
701
  report(name, 'does not match any event emitters', prefix);
575
702
  }
576
703
  }
577
704
  }
578
705
  for (const [name, prefixes] of emitters.entries()) {
579
- if (!listeners.has(name)) {
706
+ if (!listeners.has(name) && !controllerEvents.listeners.has(name)) {
580
707
  for (const prefix of prefixes) {
581
708
  report(name, 'does not match any event listeners', prefix);
582
709
  }
@@ -591,11 +718,12 @@ function validateEvents(definition, blockVersions, report) {
591
718
  *
592
719
  * @param definition The app validation to check.
593
720
  * @param getBlockVersions A function for getting block manifests from block versions.
721
+ * @param controllerImplementations App controller implementations of interfaces.
594
722
  * @param validatorResult If specified, error messages will be applied to this existing validator
595
723
  * result.
596
724
  * @returns A validator result which contains all app validation violations.
597
725
  */
598
- export async function validateAppDefinition(definition, getBlockVersions, validatorResult) {
726
+ export async function validateAppDefinition(definition, getBlockVersions, controllerImplementations, validatorResult) {
599
727
  let result = validatorResult;
600
728
  if (!result) {
601
729
  const validator = new Validator();
@@ -617,6 +745,7 @@ export async function validateAppDefinition(definition, getBlockVersions, valida
617
745
  result.errors.push(new ValidationError(message, instance, undefined, path));
618
746
  };
619
747
  try {
748
+ validateController(definition, controllerImplementations, report);
620
749
  validateCronJobs(definition, report);
621
750
  validateDefaultPage(definition, report);
622
751
  validateHooks(definition, report);
@@ -332,6 +332,28 @@ describe('validateAppDefinition', () => {
332
332
  ]),
333
333
  ]);
334
334
  });
335
+ it('should validate controller actions', async () => {
336
+ const app = createTestApp();
337
+ app.controller = {
338
+ actions: {
339
+ onClick: { type: 'noop' },
340
+ onSubmit: { type: 'noop' },
341
+ },
342
+ };
343
+ const result = await validateAppDefinition(app, () => [], {
344
+ actions: {
345
+ onClick: {},
346
+ },
347
+ });
348
+ expect(result.valid).toBe(false);
349
+ expect(result.errors).toStrictEqual([
350
+ new ValidationError('is an unknown action for this controller', { type: 'noop' }, undefined, [
351
+ 'controller',
352
+ 'actions',
353
+ 'onSubmit',
354
+ ]),
355
+ ]);
356
+ });
335
357
  it('should report if a block doesn’t support actions', async () => {
336
358
  const app = createTestApp();
337
359
  app.pages[0].blocks.push({
@@ -358,6 +380,20 @@ describe('validateAppDefinition', () => {
358
380
  ]),
359
381
  ]);
360
382
  });
383
+ it('should report if a controller doesn’t support actions', async () => {
384
+ const app = createTestApp();
385
+ app.controller = {
386
+ actions: {},
387
+ };
388
+ const result = await validateAppDefinition(app, () => [], {});
389
+ expect(result.valid).toBe(false);
390
+ expect(result.errors).toStrictEqual([
391
+ new ValidationError('is not allowed on this controller', {}, undefined, [
392
+ 'controller',
393
+ 'actions',
394
+ ]),
395
+ ]);
396
+ });
361
397
  it('should report unused block actions based on parameters', async () => {
362
398
  const app = createTestApp();
363
399
  app.pages[0].blocks.push({
@@ -439,7 +475,7 @@ describe('validateAppDefinition', () => {
439
475
  ]);
440
476
  expect(result.valid).toBe(true);
441
477
  });
442
- it('should report unknown event emitters', async () => {
478
+ it('should report unknown event emitters on blocks', async () => {
443
479
  const app = createTestApp();
444
480
  app.pages[0].blocks.push({
445
481
  type: 'test',
@@ -479,7 +515,51 @@ describe('validateAppDefinition', () => {
479
515
  ]),
480
516
  ]);
481
517
  });
482
- it('should allow $any matching unknown event emitters', async () => {
518
+ it('should report unknown event emitters on controller', async () => {
519
+ const app = createTestApp();
520
+ app.pages[0].blocks.push({
521
+ type: 'test',
522
+ version: '1.2.3',
523
+ events: {
524
+ listen: {
525
+ foo: 'bar',
526
+ },
527
+ },
528
+ });
529
+ app.controller = {
530
+ events: {
531
+ emit: {
532
+ foo: 'bar',
533
+ },
534
+ },
535
+ };
536
+ const result = await validateAppDefinition(app, () => [
537
+ {
538
+ name: '@appsemble/test',
539
+ version: '1.2.3',
540
+ files: [],
541
+ languages: [],
542
+ wildcardActions: true,
543
+ events: {
544
+ listen: { foo: {} },
545
+ },
546
+ },
547
+ ], {
548
+ events: {
549
+ emit: {},
550
+ },
551
+ });
552
+ expect(result.valid).toBe(false);
553
+ expect(result.errors).toStrictEqual([
554
+ new ValidationError('is an unknown event emitter', 'bar', undefined, [
555
+ 'controller',
556
+ 'events',
557
+ 'emit',
558
+ 'foo',
559
+ ]),
560
+ ]);
561
+ });
562
+ it('should allow $any matching unknown event emitters on blocks', async () => {
483
563
  const app = createTestApp();
484
564
  app.pages[0].blocks.push({
485
565
  type: 'test',
@@ -508,7 +588,7 @@ describe('validateAppDefinition', () => {
508
588
  ]);
509
589
  expect(result.valid).toBe(true);
510
590
  });
511
- it('should report unknown event listeners', async () => {
591
+ it('should report unknown event listeners on blocks', async () => {
512
592
  const app = createTestApp();
513
593
  app.pages[0].blocks.push({
514
594
  type: 'test',
@@ -548,6 +628,50 @@ describe('validateAppDefinition', () => {
548
628
  ]),
549
629
  ]);
550
630
  });
631
+ it('should report unknown event listeners on controller', async () => {
632
+ const app = createTestApp();
633
+ app.pages[0].blocks.push({
634
+ type: 'test',
635
+ version: '1.2.3',
636
+ events: {
637
+ emit: {
638
+ foo: 'bar',
639
+ },
640
+ },
641
+ });
642
+ app.controller = {
643
+ events: {
644
+ listen: {
645
+ foo: 'bar',
646
+ },
647
+ },
648
+ };
649
+ const result = await validateAppDefinition(app, () => [
650
+ {
651
+ name: '@appsemble/test',
652
+ version: '1.2.3',
653
+ files: [],
654
+ languages: [],
655
+ wildcardActions: true,
656
+ events: {
657
+ emit: { foo: {} },
658
+ },
659
+ },
660
+ ], {
661
+ events: {
662
+ listen: {},
663
+ },
664
+ });
665
+ expect(result.valid).toBe(false);
666
+ expect(result.errors).toStrictEqual([
667
+ new ValidationError('is an unknown event listener', 'bar', undefined, [
668
+ 'controller',
669
+ 'events',
670
+ 'listen',
671
+ 'foo',
672
+ ]),
673
+ ]);
674
+ });
551
675
  it('should allow $any matching unknown event listener', async () => {
552
676
  const app = createTestApp();
553
677
  app.pages[0].blocks.push({
@@ -577,7 +701,7 @@ describe('validateAppDefinition', () => {
577
701
  ]);
578
702
  expect(result.valid).toBe(true);
579
703
  });
580
- it('should report unmatched event listeners', async () => {
704
+ it('should report unmatched event listeners when there is no controller present', async () => {
581
705
  const app = createTestApp();
582
706
  app.pages[0].blocks.push({
583
707
  type: 'test',
@@ -613,7 +737,7 @@ describe('validateAppDefinition', () => {
613
737
  ]),
614
738
  ]);
615
739
  });
616
- it('should report unmatched event emitters', async () => {
740
+ it('should report unmatched event emitters when there is no controller present', async () => {
617
741
  const app = createTestApp();
618
742
  app.pages[0].blocks.push({
619
743
  type: 'test',
@@ -649,7 +773,7 @@ describe('validateAppDefinition', () => {
649
773
  ]),
650
774
  ]);
651
775
  });
652
- it('should report unmatched event from event actions', async () => {
776
+ it('should report unmatched event from event actions when there is no controller present', async () => {
653
777
  const app = createTestApp();
654
778
  app.pages[0].blocks.push({
655
779
  type: 'test',
@@ -702,6 +826,12 @@ describe('validateAppDefinition', () => {
702
826
  const result = await validateAppDefinition(app, () => []);
703
827
  expect(result.valid).toBe(true);
704
828
  });
829
+ it('should not crash if controller is undefined', async () => {
830
+ const app = createTestApp();
831
+ app.controller = undefined;
832
+ const result = await validateAppDefinition(app, () => [], {});
833
+ expect(result.valid).toBe(true);
834
+ });
705
835
  it('should report if notifications is "login" without a security definition', async () => {
706
836
  const app = createTestApp();
707
837
  delete app.security;
@@ -1559,7 +1689,7 @@ describe('validateAppDefinition', () => {
1559
1689
  new ValidationError('was defined but ‘onFlowCancel’ page action wasn’t defined', 'flow.cancel', undefined, ['pages', 3, 'steps', 1, 'blocks', 0, 'actions', 'onWhatever', 'type']),
1560
1690
  ]);
1561
1691
  });
1562
- it('should report an error if a resource action refers to a non-existent resource', async () => {
1692
+ it('should report an error if a resource action on a block refers to a non-existent resource', async () => {
1563
1693
  const app = createTestApp();
1564
1694
  app.pages[0].blocks.push({
1565
1695
  type: 'test',
@@ -1595,7 +1725,32 @@ describe('validateAppDefinition', () => {
1595
1725
  ]),
1596
1726
  ]);
1597
1727
  });
1598
- it('should report an error if a resource action refers to a private resource action', async () => {
1728
+ it('should report an error if a resource action on the controller refers to a non-existent resource', async () => {
1729
+ const app = createTestApp();
1730
+ app.controller = {
1731
+ actions: {
1732
+ onWhatever: {
1733
+ type: 'resource.get',
1734
+ resource: 'Nonexistent',
1735
+ },
1736
+ },
1737
+ };
1738
+ const result = await validateAppDefinition(app, () => [], {
1739
+ actions: {
1740
+ onWhatever: {},
1741
+ },
1742
+ });
1743
+ expect(result.valid).toBe(false);
1744
+ expect(result.errors).toStrictEqual([
1745
+ new ValidationError('refers to a resource that doesn’t exist', 'resource.get', undefined, [
1746
+ 'controller',
1747
+ 'actions',
1748
+ 'onWhatever',
1749
+ 'resource',
1750
+ ]),
1751
+ ]);
1752
+ });
1753
+ it('should report an error if a resource action on a block refers to a private resource action', async () => {
1599
1754
  const app = createTestApp();
1600
1755
  app.pages[0].blocks.push({
1601
1756
  type: 'test',
@@ -1623,7 +1778,27 @@ describe('validateAppDefinition', () => {
1623
1778
  new ValidationError('refers to a resource action that is currently set to private', 'resource.get', undefined, ['pages', 0, 'blocks', 0, 'actions', 'onWhatever', 'resource']),
1624
1779
  ]);
1625
1780
  });
1626
- it('should report an error if a resource action refers is private action without a security definition', async () => {
1781
+ it('should report an error if a resource action on the controller refers to a private resource action', async () => {
1782
+ const app = createTestApp();
1783
+ app.controller = {
1784
+ actions: {
1785
+ onWhatever: {
1786
+ type: 'resource.get',
1787
+ resource: 'person',
1788
+ },
1789
+ },
1790
+ };
1791
+ const result = await validateAppDefinition(app, () => [], {
1792
+ actions: {
1793
+ onWhatever: {},
1794
+ },
1795
+ });
1796
+ expect(result.valid).toBe(false);
1797
+ expect(result.errors).toStrictEqual([
1798
+ new ValidationError('refers to a resource action that is currently set to private', 'resource.get', undefined, ['controller', 'actions', 'onWhatever', 'resource']),
1799
+ ]);
1800
+ });
1801
+ it('should report an error if a resource action on a block refers is private action without a security definition', async () => {
1627
1802
  const { security, ...app } = createTestApp();
1628
1803
  app.resources.person.roles = [];
1629
1804
  app.pages[0].blocks.push({
@@ -1652,6 +1827,27 @@ describe('validateAppDefinition', () => {
1652
1827
  new ValidationError('refers to a resource action that is accessible when logged in, but the app has no security definitions', 'resource.get', undefined, ['pages', 0, 'blocks', 0, 'actions', 'onWhatever', 'resource']),
1653
1828
  ]);
1654
1829
  });
1830
+ it('should report an error if a resource action on the controller refers is private action without a security definition', async () => {
1831
+ const { security, ...app } = createTestApp();
1832
+ app.resources.person.roles = [];
1833
+ app.controller = {
1834
+ actions: {
1835
+ onWhatever: {
1836
+ type: 'resource.get',
1837
+ resource: 'person',
1838
+ },
1839
+ },
1840
+ };
1841
+ const result = await validateAppDefinition(app, () => [], {
1842
+ actions: {
1843
+ onWhatever: {},
1844
+ },
1845
+ });
1846
+ expect(result.valid).toBe(false);
1847
+ expect(result.errors).toStrictEqual([
1848
+ new ValidationError('refers to a resource action that is accessible when logged in, but the app has no security definitions', 'resource.get', undefined, ['controller', 'actions', 'onWhatever', 'resource']),
1849
+ ]);
1850
+ });
1655
1851
  it('should ignore if an app is null', async () => {
1656
1852
  const result = await validateAppDefinition(null, () => []);
1657
1853
  expect(result.valid).toBe(true);