create-qpq-app 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +2 -2
  2. package/template/README.md +4 -0
  3. package/template/apps/qpqjs/account.qpq.ts +4 -0
  4. package/template/apps/qpqjs/bootstrap.qpq.ts +13 -2
  5. package/template/apps/qpqjs/packages/service-utils/src/defineQpqjsService.ts +4 -0
  6. package/template/apps/qpqjs/services/test/models/src/SmokeProbeRecord.ts +9 -0
  7. package/template/apps/qpqjs/services/test/models/src/SmokeRunSummary.ts +8 -1
  8. package/template/apps/qpqjs/services/test/models/src/SmokeTestStatus.ts +2 -0
  9. package/template/apps/qpqjs/services/test/service/src/smoke/config/defineSmoke.ts +96 -6
  10. package/template/apps/qpqjs/services/test/service/src/smoke/constants/smokeProbe.ts +25 -0
  11. package/template/apps/qpqjs/services/test/service/src/smoke/email/index.ts +2 -0
  12. package/template/apps/qpqjs/services/test/service/src/smoke/email/onSmokeEmailReceived.ts +33 -0
  13. package/template/apps/qpqjs/services/test/service/src/smoke/email/smokeEmailMarkerId.ts +4 -0
  14. package/template/apps/qpqjs/services/test/service/src/smoke/index.ts +3 -0
  15. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/index.ts +2 -0
  16. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/onSmokeStreamRecord.ts +31 -0
  17. package/template/apps/qpqjs/services/test/service/src/smoke/kvsStream/smokeStreamMarkerId.ts +8 -0
  18. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeTest.ts +3 -2
  19. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askStartSmokeRun.ts +27 -13
  20. package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/summarizeSmokeRun.ts +5 -1
  21. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/index.ts +4 -0
  22. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/onSmokeFileEvent.ts +38 -0
  23. package/template/apps/qpqjs/services/test/service/src/smoke/storageDrive/smokeFileEventMarkerId.ts +14 -0
  24. package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +3 -0
  25. package/template/apps/qpqjs/services/test/service/src/smoke/tests/askSmokePollForMarker.ts +44 -0
  26. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/askRunEmailReceiveTest.ts +41 -0
  27. package/template/apps/qpqjs/services/test/service/src/smoke/tests/emailReceive/index.ts +1 -0
  28. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/askRunEncryptedResourcesTest.ts +63 -0
  29. package/template/apps/qpqjs/services/test/service/src/smoke/tests/encryptedResources/index.ts +1 -0
  30. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventBus/askRunEventBusTest.ts +2 -26
  31. package/template/apps/qpqjs/services/test/service/src/smoke/tests/eventDoc/askAwaitSmokeEventDocLog.ts +2 -1
  32. package/template/apps/qpqjs/services/test/service/src/smoke/tests/index.ts +5 -0
  33. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/askRunKeyValueStoreStreamTest.ts +60 -0
  34. package/template/apps/qpqjs/services/test/service/src/smoke/tests/keyValueStoreStream/index.ts +1 -0
  35. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/askRunScopedKeyValueStoreTest.ts +102 -0
  36. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedKeyValueStore/index.ts +1 -0
  37. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/askRunScopedStorageDriveTest.ts +116 -0
  38. package/template/apps/qpqjs/services/test/service/src/smoke/tests/scopedStorageDrive/index.ts +1 -0
  39. package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +12 -0
  40. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/askRunStorageDriveEventTest.ts +80 -0
  41. package/template/apps/qpqjs/services/test/service/src/smoke/tests/storageDriveEvent/index.ts +1 -0
  42. package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt-stream.md +2 -2
  43. package/template/docusaurus/docs/actions/core/ai/ask-ai-prompt.md +3 -0
  44. package/template/docusaurus/docs/actions/core/file/ask-file-write-text-contents.md +1 -1
  45. package/template/docusaurus/docs/actions/core/platform/ask-platform-get-name.md +32 -0
  46. package/template/docusaurus/docs/actions/core/system/ask-get-runtime-remaining-time.md +52 -0
  47. package/template/docusaurus/docs/actions/features/event-doc/ask-event-doc-event-append.md +3 -3
  48. package/template/docusaurus/docs/actions/features/event-doc-ai/active-chat-state.md +3 -2
  49. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-continue.md +46 -0
  50. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-process-send.md +6 -25
  51. package/template/docusaurus/docs/actions/features/event-doc-ai/ask-event-doc-ai-stream-turn.md +67 -0
  52. package/template/docusaurus/docs/actions/features/event-doc-ai/chat-list-state.md +2 -1
  53. package/template/docusaurus/docs/actions/features/event-doc-ai/streaming-and-status-state.md +7 -6
  54. package/template/docusaurus/docs/actions/features/web-socket-queue/ask-service-request.md +15 -0
  55. package/template/docusaurus/docs/actions/webserver/email/ask-email-parse.md +54 -0
  56. package/template/docusaurus/docs/config/config-aws/account-email-receiving.md +33 -0
  57. package/template/docusaurus/docs/config/core/crypto-key.md +3 -1
  58. package/template/docusaurus/docs/config/core/key-value-store.md +4 -3
  59. package/template/docusaurus/docs/config/core/storage-drive.md +6 -4
  60. package/template/docusaurus/docs/config/features/event-doc-ai.md +4 -2
  61. package/template/docusaurus/docs/config/features/event-doc-routes.md +1 -0
  62. package/template/docusaurus/docs/config/features/event-doc.md +2 -1
  63. package/template/docusaurus/docs/config/features/tenant-stores.md +10 -5
  64. package/template/docusaurus/docs/config/features/tenant.md +36 -12
  65. package/template/docusaurus/docs/config/features/tenanted-email-receiver.md +79 -0
  66. package/template/docusaurus/docs/config/features/tenanted-event-doc.md +22 -7
  67. package/template/docusaurus/docs/config/webserver/email-receiver.md +65 -0
  68. package/template/docusaurus/docs/config/webserver/email-receiving-domain.md +41 -0
  69. package/template/scripts/smoke/runSmokeRun.mjs +1 -1
  70. package/template/docusaurus/docs/config/config-aws/aws-kms-key.md +0 -100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-qpq-app",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Scaffold a new quidproquo app: npx create-qpq-app my-app",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/esm/index.js",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^22.13.13",
58
- "quidproquo-tsconfig": "0.1.24"
58
+ "quidproquo-tsconfig": "0.1.26"
59
59
  },
60
60
  "bin": {
61
61
  "create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
@@ -103,3 +103,7 @@ npx qpq go # interactive aws deploy: pick app, services, stacks (inf
103
103
  1. Create its `config`, `models`, `x-models`, `shared-logic`, `x-logic`, `service-utils`, and `service` packages.
104
104
  2. Each lib gets a `package.json` with `"build": "tsc -b"` and the dist-pointing `main`, `types`, and `exports`; a `tsconfig.json` (extends the root base, **no paths**, references `./tsconfig.lib.json`); and a `tsconfig.lib.json` (`composite`, `outDir ./dist`, `rootDir .`, with `references` to its workspace deps' `tsconfig.lib.json`). There is no root-level list to touch, since `npm run build --workspaces` discovers the new lib automatically.
105
105
  3. `service/` gets a `package.json` (`@qpqjs/<svc>-service`, `private`, `"build": "tsc -p tsconfig.app.json"`) and a `tsconfig.app.json`. No rspack.config.ts and no bundle script, because `qpq go` bundles it by convention. `npm run build` then picks it up automatically.
106
+
107
+ ## Test
108
+
109
+ Adding a note to test build / deploy - this is a second test.
@@ -7,6 +7,7 @@ import { QPQConfig } from 'quidproquo';
7
7
  import {
8
8
  AwsDataStoreRemovalPolicy,
9
9
  defineAccountBudget,
10
+ defineAccountEmailReceiving,
10
11
  defineAccountGithubOidcProvider,
11
12
  defineAccountSecurityServices,
12
13
  defineAwsDataStoreRemovalPolicy,
@@ -21,6 +22,9 @@ export default (): QPQConfig => [
21
22
  // GitHub Actions OIDC provider, trusted by every app's deploy role in this account.
22
23
  defineAccountGithubOidcProvider(),
23
24
 
25
+ // The one active SES receipt rule set; every app's inbound-email rules live in it.
26
+ defineAccountEmailReceiving(),
27
+
24
28
  defineAccountSecurityServices({
25
29
  // GuardDuty is org-managed for this account
26
30
  // (delegated admin 816650600287 auto-enables detectors - a stack-created one
@@ -2,7 +2,12 @@
2
2
  // domain certificates. The identity plumbing (defineApplication +
3
3
  // defineAwsServiceAccountInfo) is provided by quidproquo-deploy-awscdk's
4
4
  // workspace CDK app; this fragment must not declare its own.
5
- import { defineApi, defineDns, QPQConfig } from 'quidproquo';
5
+ import {
6
+ defineApi,
7
+ defineDns,
8
+ defineEmailReceivingDomain,
9
+ QPQConfig,
10
+ } from 'quidproquo';
6
11
  import {
7
12
  defineAwsGithubDeployRole,
8
13
  defineBootstrapWaf,
@@ -17,8 +22,14 @@ import { QPQJS_DOMAINS, QpqjsServiceEnum } from '@qpqjs/constants';
17
22
  export default ({ region }: QpqAppDeployContext): QPQConfig => [
18
23
  defineDns(QPQJS_DOMAINS),
19
24
 
25
+ // inbox.<env>.<root>: verified with SES and MX-routed to it, for every defineEmailReceiver.
26
+ defineEmailReceivingDomain('inbox'),
27
+
20
28
  // The role the deploy workflow assumes (ids from `gh api repos/qpqjs/quidproquo`).
21
- defineAwsGithubDeployRole('qpqjs/quidproquo', { ownerId: 314167689, repositoryId: 571382961 }),
29
+ defineAwsGithubDeployRole('qpqjs/quidproquo', {
30
+ ownerId: 314167689,
31
+ repositoryId: 571382961,
32
+ }),
22
33
 
23
34
  defineBootstrapWaf({
24
35
  rateLimits: [{ name: 'all-traffic', limit: 2000 }],
@@ -6,6 +6,7 @@ import {
6
6
  defineCache,
7
7
  defineDefaultRouteOptions,
8
8
  defineDns,
9
+ defineEmailReceivingDomain,
9
10
  defineFrontendBundleOptions,
10
11
  QPQConfig,
11
12
  } from 'quidproquo';
@@ -59,6 +60,9 @@ export const defineQpqjsService = (
59
60
  ),
60
61
 
61
62
  defineDns(QPQJS_DOMAINS),
63
+ // Same domain bootstrap.qpq.ts declares: bootstrap owns the records, a service's
64
+ // defineEmailReceiver resolves its recipients from it.
65
+ defineEmailReceivingDomain('inbox'),
62
66
 
63
67
  defineAdminUserDirectory({
64
68
  owner: {
@@ -5,4 +5,13 @@ export type SmokeProbeRecord = {
5
5
  probeId: string;
6
6
  category: string;
7
7
  value: number;
8
+
9
+ // Set by the storage drive event and kvs stream handlers: what the event
10
+ // said its scope and path/key were, and (for file events) the ambient
11
+ // storage scope the handler ran under, so a test can prove both agree.
12
+ scope?: string;
13
+ ambientScope?: string;
14
+ path?: string;
15
+ // Set by the email handler: the provider's spf/dkim verdicts on the message.
16
+ authentication?: string;
8
17
  };
@@ -4,9 +4,16 @@ import { z } from 'zod/v4';
4
4
  // cannot drift from the entries themselves.
5
5
  export const SmokeRunSummarySchema = z.object({
6
6
  total: z.int().nonnegative().describe('Tests registered for the run'),
7
- completed: z.int().nonnegative().describe('Tests that have passed or failed'),
7
+ completed: z
8
+ .int()
9
+ .nonnegative()
10
+ .describe('Tests that have passed, failed or been skipped'),
8
11
  passed: z.int().nonnegative(),
9
12
  failed: z.int().nonnegative(),
13
+ skipped: z
14
+ .int()
15
+ .nonnegative()
16
+ .describe('Deployed-only tests not run on this platform'),
10
17
  });
11
18
 
12
19
  export type SmokeRunSummary = z.infer<typeof SmokeRunSummarySchema>;
@@ -3,4 +3,6 @@ export enum SmokeTestStatus {
3
3
  running = 'running',
4
4
  passed = 'passed',
5
5
  failed = 'failed',
6
+ // Deployed-only test on a platform with no equivalent; never queued, never counts either way.
7
+ skipped = 'skipped',
6
8
  }
@@ -1,4 +1,7 @@
1
1
  import {
2
+ defineCryptoKey,
3
+ defineEmailReceiver,
4
+ defineEmailSender,
2
5
  defineEventBus,
3
6
  defineKeyValueStore,
4
7
  defineParameter,
@@ -31,12 +34,20 @@ import {
31
34
  SMOKE_EVENT_DOC_BASE_PATH,
32
35
  } from '../constants/smokeEventDoc';
33
36
  import {
37
+ SMOKE_CRYPTO_KEY,
38
+ SMOKE_EMAIL_RECEIVER,
39
+ SMOKE_ENCRYPTED_PROBE_DRIVE,
40
+ SMOKE_ENCRYPTED_PROBE_STORE,
41
+ SMOKE_FILE_EVENT_DRIVE,
34
42
  SMOKE_PROBE_EVENT_BUS,
35
43
  SMOKE_PROBE_EVENT_QUEUE,
36
44
  SMOKE_PROBE_EVENT_TYPE,
37
45
  SMOKE_PROBE_PARAMETER,
38
46
  SMOKE_PROBE_PARAMETER_VALUE,
39
47
  SMOKE_PROBE_SECRET,
48
+ SMOKE_SCOPED_PROBE_DRIVE,
49
+ SMOKE_SCOPED_PROBE_STORE,
50
+ SMOKE_STREAM_PROBE_STORE,
40
51
  } from '../constants/smokeProbe';
41
52
  import {
42
53
  SMOKE_RUN_QUEUE,
@@ -62,6 +73,12 @@ export const defineSmoke = (): QPQConfig => {
62
73
  },
63
74
  ];
64
75
 
76
+ const onSmokeFileEvent: QpqFunctionRuntime = {
77
+ basePath: __dirname,
78
+ relativePath: '../storageDrive/onSmokeFileEvent',
79
+ functionName: 'onSmokeFileEvent',
80
+ };
81
+
65
82
  return [
66
83
  // One record per run, keyed by runId; the queue workers each write their
67
84
  // own test's entry as it completes and the GET route polls it.
@@ -93,6 +110,73 @@ export const defineSmoke = (): QPQConfig => {
93
110
  // defineCrossServiceProbe) for the cross-service verify test.
94
111
  defineSigningKey(SMOKE_PROBE_SIGNING_KEY),
95
112
 
113
+ // Scoped probe resources: the scope gate in both directions (a scoped
114
+ // resource refuses an unscoped call, an unscoped one refuses a scope) and
115
+ // partition isolation between two scopes.
116
+ defineKeyValueStore<SmokeProbeRecord>(
117
+ SMOKE_SCOPED_PROBE_STORE,
118
+ 'probeId',
119
+ [],
120
+ { indexes: ['category'], scoped: true }
121
+ ),
122
+ defineStorageDrive(SMOKE_SCOPED_PROBE_DRIVE, {
123
+ scoped: true,
124
+ onEvent: { create: onSmokeFileEvent, delete: onSmokeFileEvent },
125
+ }),
126
+
127
+ // File event test path: a write or delete on either drive fires the
128
+ // handler, which writes a marker into the probe store; the test polls for
129
+ // it. The scoped drive's events must arrive with the scope split off.
130
+ defineStorageDrive(SMOKE_FILE_EVENT_DRIVE, {
131
+ onEvent: { create: onSmokeFileEvent, delete: onSmokeFileEvent },
132
+ }),
133
+
134
+ // Kvs stream test path: a change on this scoped store streams to the
135
+ // handler, which writes a marker into the probe store; the test polls for
136
+ // it. The record must carry the scope as its own field with a raw key.
137
+ defineKeyValueStore<SmokeProbeRecord>(
138
+ SMOKE_STREAM_PROBE_STORE,
139
+ 'probeId',
140
+ [],
141
+ {
142
+ scoped: true,
143
+ onStream: {
144
+ runtime: {
145
+ basePath: __dirname,
146
+ relativePath: '../kvsStream/onSmokeStreamRecord',
147
+ functionName: 'onSmokeStreamRecord',
148
+ },
149
+ },
150
+ }
151
+ ),
152
+
153
+ // Encrypted probe resources: a drive and a store encrypted with an owned
154
+ // crypto key, so the test proves the role's KMS grant covers data at rest.
155
+ defineCryptoKey(SMOKE_CRYPTO_KEY),
156
+ defineKeyValueStore<SmokeProbeRecord>(
157
+ SMOKE_ENCRYPTED_PROBE_STORE,
158
+ 'probeId',
159
+ [],
160
+ { cryptoKeyName: SMOKE_CRYPTO_KEY }
161
+ ),
162
+ defineStorageDrive(SMOKE_ENCRYPTED_PROBE_DRIVE, {
163
+ cryptoKeyName: SMOKE_CRYPTO_KEY,
164
+ }),
165
+
166
+ // Inbound email test path: the test sends to its own receiving domain, the
167
+ // receiver hands the parsed message to onSmokeEmailReceived, which writes a
168
+ // marker into the probe store, and the test polls for it. The sender is what
169
+ // lets the test send; the receiving domain is a verified identity, so the
170
+ // SES sandbox allows it.
171
+ defineEmailSender(),
172
+ defineEmailReceiver(SMOKE_EMAIL_RECEIVER, {
173
+ onEmail: {
174
+ basePath: __dirname,
175
+ relativePath: '../email/onSmokeEmailReceived',
176
+ functionName: 'onSmokeEmailReceived',
177
+ },
178
+ }),
179
+
96
180
  // Event bus test path: publish to the bus, the subscribed queue's entry
97
181
  // writes a marker into the probe store, the test polls for it.
98
182
  defineEventBus(SMOKE_PROBE_EVENT_BUS),
@@ -123,13 +207,19 @@ export const defineSmoke = (): QPQConfig => {
123
207
  },
124
208
  { basePath: SMOKE_EVENT_DOC_BASE_PATH }
125
209
  ),
126
- defineQueue(SMOKE_EVENT_DOC_APPEND_QUEUE, {
127
- [SMOKE_EVENT_DOC_APPEND_MESSAGE_TYPE]: {
128
- basePath: __dirname,
129
- relativePath: '../queue/onSmokeEventDocAppend',
130
- functionName: 'onSmokeEventDocAppend',
210
+ // A writer that loses the slot race past the append retry cap fails its message; a
211
+ // quick redelivery lets it land instead of vanishing, as a production queue should.
212
+ defineQueue(
213
+ SMOKE_EVENT_DOC_APPEND_QUEUE,
214
+ {
215
+ [SMOKE_EVENT_DOC_APPEND_MESSAGE_TYPE]: {
216
+ basePath: __dirname,
217
+ relativePath: '../queue/onSmokeEventDocAppend',
218
+ functionName: 'onSmokeEventDocAppend',
219
+ },
131
220
  },
132
- }),
221
+ { maxTries: 3, ttRetryInSeconds: 10 }
222
+ ),
133
223
 
134
224
  // The zod models double as the routes' published contract, flattened to
135
225
  // JSON Schema here for the OpenAPI document at /v1/docs.
@@ -12,3 +12,28 @@ export const SMOKE_PROBE_EVENT_BUS = 'smokeProbe';
12
12
  // flows bus -> queue -> store marker for the event bus test.
13
13
  export const SMOKE_PROBE_EVENT_QUEUE = 'smokeProbeEvents';
14
14
  export const SMOKE_PROBE_EVENT_TYPE = 'smokeProbeEvent';
15
+
16
+ // Scoped probe resources: every call must carry a scope, an unscoped one is
17
+ // refused. Only the test service uses them, so they stay service-level.
18
+ export const SMOKE_SCOPED_PROBE_STORE = 'smokeScopedProbe';
19
+ export const SMOKE_SCOPED_PROBE_DRIVE = 'smoke-scoped-probe';
20
+
21
+ // The file event drives: an unscoped and a scoped one, both with create and
22
+ // delete handlers that write a marker row the file event test polls for.
23
+ export const SMOKE_FILE_EVENT_DRIVE = 'smoke-file-event';
24
+
25
+ // A scoped store with a change stream; the handler writes a marker row the
26
+ // kvs stream test polls for.
27
+ export const SMOKE_STREAM_PROBE_STORE = 'smokeStreamProbe';
28
+
29
+ // The crypto key the encrypted probe drive and store name: proves the
30
+ // alias-conditioned KMS grant lets the service role write and read data
31
+ // encrypted with a defineCryptoKey. Not 'smokeProbe': the signing key of that
32
+ // name is a KMS alias too, and the two would collide.
33
+ export const SMOKE_CRYPTO_KEY = 'smokeCrypto';
34
+ export const SMOKE_ENCRYPTED_PROBE_STORE = 'smokeEncryptedProbe';
35
+ export const SMOKE_ENCRYPTED_PROBE_DRIVE = 'smoke-encrypted-probe';
36
+
37
+ // Inbound email: the receiver's onEmail writes a marker the email test polls for.
38
+ // Deployed only.
39
+ export const SMOKE_EMAIL_RECEIVER = 'smoke';
@@ -0,0 +1,2 @@
1
+ export * from './onSmokeEmailReceived';
2
+ export * from './smokeEmailMarkerId';
@@ -0,0 +1,33 @@
1
+ import {
2
+ askKeyValueStoreUpsert,
3
+ AskResponse,
4
+ EmailReceivedEvent,
5
+ EmailReceivedEventResponse,
6
+ } from 'quidproquo';
7
+
8
+ import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
9
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
10
+
11
+ import { smokeEmailMarkerId } from './smokeEmailMarkerId';
12
+
13
+ // The smoke receiver's onEmail: one marker per delivered recipient, carrying
14
+ // the subject and the provider's SPF/DKIM verdicts so the test can check the
15
+ // message came through intact and authenticated.
16
+ export function* onSmokeEmailReceived({
17
+ message,
18
+ }: EmailReceivedEvent): AskResponse<EmailReceivedEventResponse> {
19
+ for (const recipient of message.recipients) {
20
+ const marker: SmokeProbeRecord = {
21
+ probeId: smokeEmailMarkerId(recipient),
22
+ category: 'email',
23
+ value: 1,
24
+ path: message.subject,
25
+ authentication: [
26
+ message.authentication.spf,
27
+ message.authentication.dkim,
28
+ ].join('/'),
29
+ };
30
+
31
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(SMOKE_PROBE_STORE, marker);
32
+ }
33
+ }
@@ -0,0 +1,4 @@
1
+ // The marker row id for one received message: the recipient's local part, which the email
2
+ // test sets to its own run id so the marker it polls for is provably its own.
3
+ export const smokeEmailMarkerId = (recipient: string): string =>
4
+ `email-${recipient.split('@')[0]}`;
@@ -2,9 +2,12 @@ export * from './config';
2
2
  export * from './constants';
3
3
  export * from './controller';
4
4
  export * from './data';
5
+ export * from './email';
5
6
  export * from './eventDoc';
7
+ export * from './kvsStream';
6
8
  export * from './logic';
7
9
  export * from './models';
8
10
  export * from './processors';
9
11
  export * from './queue';
12
+ export * from './storageDrive';
10
13
  export * from './tests';
@@ -0,0 +1,2 @@
1
+ export * from './onSmokeStreamRecord';
2
+ export * from './smokeStreamMarkerId';
@@ -0,0 +1,31 @@
1
+ import {
2
+ askKeyValueStoreUpsert,
3
+ AskResponse,
4
+ KvsStreamEventResponse,
5
+ KvsStreamRecord,
6
+ } from 'quidproquo';
7
+
8
+ import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
9
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
10
+
11
+ import { smokeStreamMarkerId } from './smokeStreamMarkerId';
12
+
13
+ // Fan-in for the kvs stream test: the scoped stream store's changes land
14
+ // here. The marker records the scope the record carried and the raw key, so
15
+ // the test can check the stream hands back the scope as its own field with
16
+ // the key stripped of it.
17
+ export function* onSmokeStreamRecord(
18
+ record: KvsStreamRecord<SmokeProbeRecord>
19
+ ): AskResponse<KvsStreamEventResponse> {
20
+ const key = String(record.keys.probeId);
21
+
22
+ const marker: SmokeProbeRecord = {
23
+ probeId: smokeStreamMarkerId(key, record.eventType),
24
+ category: 'kvsStream',
25
+ value: 1,
26
+ scope: record.scope,
27
+ path: key,
28
+ };
29
+
30
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(SMOKE_PROBE_STORE, marker);
31
+ }
@@ -0,0 +1,8 @@
1
+ import { KvsStreamEventType } from 'quidproquo';
2
+
3
+ // The marker row id for one stream record: the changed item's key plus the
4
+ // event type, so an insert and its remove are two rows the test polls for.
5
+ export const smokeStreamMarkerId = (
6
+ key: string,
7
+ eventType: KvsStreamEventType
8
+ ): string => `${key}-stream-${eventType.toLowerCase()}`;
@@ -40,7 +40,8 @@ function* askRunSmokeTest(
40
40
 
41
41
  const isTerminal = (result: SmokeTestResult): boolean =>
42
42
  result.status === SmokeTestStatus.passed ||
43
- result.status === SmokeTestStatus.failed;
43
+ result.status === SmokeTestStatus.failed ||
44
+ result.status === SmokeTestStatus.skipped;
44
45
 
45
46
  // Executes one registered test for an existing run (created by
46
47
  // askStartSmokeRun) and records its result. Every test of a run executes
@@ -86,7 +87,7 @@ export function* askExecuteSmokeTest(
86
87
 
87
88
  const finishedAt = yield* askDateNow();
88
89
  const anyFailed = recorded.tests.some(
89
- (t) => t.status !== SmokeTestStatus.passed
90
+ (t) => t.status === SmokeTestStatus.failed
90
91
  );
91
92
 
92
93
  return yield* askSetSmokeRunOutcome(
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  askDateNow,
3
3
  askNewGuid,
4
+ askPlatformGetName,
4
5
  askQueueSendMessages,
5
6
  AskResponse,
6
7
  QueueMessage,
@@ -22,14 +23,15 @@ import { SmokeTestRequestedPayload } from '../../models/SmokeTestRequestedQueueE
22
23
  import { SmokeTestDefinition } from '../../tests/SmokeTestDefinition';
23
24
  import { smokeTestRegistry } from '../../tests/smokeTestRegistry';
24
25
 
25
- const createPendingResult = (
26
+ const createInitialResult = (
26
27
  test: SmokeTestDefinition,
27
- index: number
28
+ index: number,
29
+ skip: boolean
28
30
  ): SmokeTestResult => ({
29
31
  id: index + 1,
30
32
  name: test.name,
31
- status: SmokeTestStatus.pending,
32
- message: '',
33
+ status: skip ? SmokeTestStatus.skipped : SmokeTestStatus.pending,
34
+ message: skip ? 'deployed only' : '',
33
35
  startedAt: null,
34
36
  finishedAt: null,
35
37
  });
@@ -42,29 +44,41 @@ const createTestRequestedMessage = (
42
44
  payload: { runId, testName: test.name },
43
45
  });
44
46
 
45
- // Creates the run record with every registered test pending, then sends one
46
- // queue message per test so they execute in parallel. The record exists
47
- // before the messages are sent so a poll (or a worker) that races the queue
48
- // still finds it.
47
+ // Creates the run record with every registered test pending (or skipped, for
48
+ // a deployed-only test on the dev server), then sends one queue message per
49
+ // pending test so they execute in parallel. The record exists before the
50
+ // messages are sent so a poll (or a worker) that races the queue still finds
51
+ // it. A run of nothing but skips never gets a worker, so it is finished here.
49
52
  export function* askStartSmokeRun(): AskResponse<SmokeRun> {
50
53
  const runId = yield* askNewGuid();
51
54
  const startedAt = yield* askDateNow();
55
+ const platformName = yield* askPlatformGetName();
56
+
57
+ const shouldSkip = (test: SmokeTestDefinition): boolean =>
58
+ !!test.deployedOnly && platformName === 'devServer';
59
+
60
+ const queued = smokeTestRegistry.filter((test) => !shouldSkip(test));
52
61
 
53
62
  const smokeRun: SmokeRun = {
54
63
  runId,
55
- status: SmokeRunStatus.running,
64
+ status:
65
+ queued.length === 0 ? SmokeRunStatus.passed : SmokeRunStatus.running,
56
66
  startedAt,
57
- finishedAt: null,
58
- tests: smokeTestRegistry.map(createPendingResult),
67
+ finishedAt: queued.length === 0 ? startedAt : null,
68
+ tests: smokeTestRegistry.map((test, index) =>
69
+ createInitialResult(test, index, shouldSkip(test))
70
+ ),
59
71
  };
60
72
 
61
73
  yield* askSaveSmokeRun(smokeRun);
62
74
 
63
- const messages = smokeTestRegistry.map((test) =>
75
+ const messages = queued.map((test) =>
64
76
  createTestRequestedMessage(runId, test)
65
77
  );
66
78
 
67
- yield* askQueueSendMessages(SMOKE_RUN_QUEUE, ...messages);
79
+ if (messages.length > 0) {
80
+ yield* askQueueSendMessages(SMOKE_RUN_QUEUE, ...messages);
81
+ }
68
82
 
69
83
  return smokeRun;
70
84
  }
@@ -12,14 +12,18 @@ export const summarizeSmokeRun = (smokeRun: SmokeRun): SmokeRunWithSummary => {
12
12
  const failed = smokeRun.tests.filter(
13
13
  (t) => t.status === SmokeTestStatus.failed
14
14
  ).length;
15
+ const skipped = smokeRun.tests.filter(
16
+ (t) => t.status === SmokeTestStatus.skipped
17
+ ).length;
15
18
 
16
19
  return {
17
20
  ...smokeRun,
18
21
  summary: {
19
22
  total: smokeRun.tests.length,
20
- completed: passed + failed,
23
+ completed: passed + failed + skipped,
21
24
  passed,
22
25
  failed,
26
+ skipped,
23
27
  },
24
28
  };
25
29
  };
@@ -0,0 +1,4 @@
1
+ export * from './onSmokeEmailReceived';
2
+ export * from './onSmokeFileEvent';
3
+ export * from './smokeEmailMarkerId';
4
+ export * from './smokeFileEventMarkerId';
@@ -0,0 +1,38 @@
1
+ import {
2
+ askKeyValueStoreUpsert,
3
+ AskResponse,
4
+ askStorageScopeRead,
5
+ StorageDriveEvent,
6
+ StorageDriveEventResponse,
7
+ } from 'quidproquo';
8
+
9
+ import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
10
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
11
+
12
+ import { smokeFileEventMarkerId } from './smokeFileEventMarkerId';
13
+
14
+ // Fan-in for the file event test: both the scoped and the unscoped event
15
+ // drive point create and delete here. The marker records what the event
16
+ // carried and the scope the handler ran under, so the test can check that a
17
+ // scoped drive's event arrives with its scope split off the path and the
18
+ // handler re-entered that scope.
19
+ export function* onSmokeFileEvent(
20
+ event: StorageDriveEvent
21
+ ): AskResponse<StorageDriveEventResponse> {
22
+ const ambientScope = yield* askStorageScopeRead();
23
+
24
+ for (const filepath of event.filePaths) {
25
+ const marker: SmokeProbeRecord = {
26
+ probeId: smokeFileEventMarkerId(filepath, event.eventType),
27
+ category: 'fileEvent',
28
+ value: 1,
29
+ scope: event.scope,
30
+ ambientScope: ambientScope ?? undefined,
31
+ path: filepath,
32
+ };
33
+
34
+ // The probe store is unscoped, so the marker is written without a scope
35
+ // whatever scope this handler runs under.
36
+ yield* askKeyValueStoreUpsert<SmokeProbeRecord>(SMOKE_PROBE_STORE, marker);
37
+ }
38
+ }
@@ -0,0 +1,14 @@
1
+ import { StorageDriveEventType } from 'quidproquo';
2
+
3
+ // The marker row id for one file event: the file's own id (its basename
4
+ // without extension) plus the event type, so a create and its delete are two
5
+ // rows the test can poll for separately.
6
+ export const smokeFileEventMarkerId = (
7
+ filepath: string,
8
+ eventType: StorageDriveEventType
9
+ ): string => {
10
+ const basename = filepath.split('/').pop() ?? filepath;
11
+ const fileId = basename.replace(/\.[^.]*$/, '');
12
+
13
+ return `${fileId}-${eventType.toLowerCase()}`;
14
+ };
@@ -11,4 +11,7 @@ import { AskResponse } from 'quidproquo';
11
11
  export type SmokeTestDefinition = {
12
12
  name: string;
13
13
  askRun: (runId: string) => AskResponse<void>;
14
+ // The test exercises something only a deployed platform has (inbound email,
15
+ // say). On the dev server it is recorded as skipped and never queued.
16
+ deployedOnly?: boolean;
14
17
  };
@@ -0,0 +1,44 @@
1
+ import {
2
+ askDelay,
3
+ askKeyValueStoreDelete,
4
+ askKeyValueStoreGet,
5
+ AskResponse,
6
+ Nullable,
7
+ } from 'quidproquo';
8
+
9
+ import { SMOKE_PROBE_STORE } from '@qpqjs/constants';
10
+ import { SmokeProbeRecord } from '@qpqjs/test-models';
11
+
12
+ import { askSmokeAssert } from './askSmokeAssert';
13
+
14
+ const POLL_ATTEMPTS = 15;
15
+ const POLL_INTERVAL_MS = 2000;
16
+
17
+ // Fan-in for every async probe: polls the probe store for the marker row an
18
+ // event handler writes, fails the test if it never arrives, and removes it
19
+ // so a rerun starts clean. The default wait covers the dev server's file
20
+ // watcher settle window and S3 / stream delivery latency; a slower path
21
+ // (mail delivery) asks for more attempts.
22
+ export function* askSmokePollForMarker(
23
+ markerId: string,
24
+ via: string,
25
+ attempts: number = POLL_ATTEMPTS
26
+ ): AskResponse<SmokeProbeRecord> {
27
+ let marker: Nullable<SmokeProbeRecord> = null;
28
+ for (let attempt = 0; attempt < attempts && !marker; attempt += 1) {
29
+ yield* askDelay(POLL_INTERVAL_MS);
30
+ marker = yield* askKeyValueStoreGet<SmokeProbeRecord>(
31
+ SMOKE_PROBE_STORE,
32
+ markerId
33
+ );
34
+ }
35
+
36
+ yield* askSmokeAssert(
37
+ !!marker,
38
+ `marker [${markerId}] never arrived via ${via}`
39
+ );
40
+
41
+ yield* askKeyValueStoreDelete(SMOKE_PROBE_STORE, markerId);
42
+
43
+ return marker!;
44
+ }