@starci/hfs 4.7.22 → 4.7.24
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/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/candidate-ci.mjs +20 -10
- package/release/gate.mjs +37 -19
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/ci-standard.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +17 -17
- package/runtime/knowledge/hfs/slots.yaml +20 -1
- package/runtime/knowledge/patterns/be/saga.yaml +1 -1
- package/runtime/knowledge/sonar-gate.yaml +12 -12
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +0 -3
- package/runtime/packages/cli/src/catalog.generated.mjs +67 -4
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +2 -2
- package/runtime/scripts/hfs/rules/ci-standard.mjs +24 -14
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +68 -2
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +21 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/lib/gate-loosening.mjs +248 -0
- package/runtime/scripts/lib/managed-json.mjs +51 -0
- package/runtime/scripts/lib/uat-run-folder.mjs +118 -0
- package/stack/prepare.mjs +10 -3
- package/stack/release-store.mjs +3 -3
- package/stack/remote.mjs +9 -0
- package/sync/ci-commands.mjs +38 -90
- package/sync/index.mjs +16 -5
- package/templates/app/browser-config/browser/tsconfig.json +11 -0
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -3
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -3
- package/templates/app/hooks/husky/pre-push +4 -2
- package/templates/app/package-scripts/package.json +0 -1
- package/templates/app/package-scripts-lite/package.json +0 -1
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +5 -6
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +5 -6
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +6 -2
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +4 -1
- package/templates/be/patterns/queues/platform/bullmq-queue-transport.client.spec.ts.tpl +5 -2
- package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +1 -1
- package/templates/be/patterns/saga/saga.service.spec.ts.tpl +4 -1
- package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +4 -4
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +2 -6
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +4 -12
- package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +1 -3
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +5 -7
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +5 -2
- package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/http-security/request-validation.service.spec.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +8 -10
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +11 -7
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +2 -2
- package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +6 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +16 -10
- package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +4 -1
- package/templates/fe/auth/supabase-client.ts.tpl +7 -7
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/apps/landing/src/modules/i18n/index.ts +1 -1
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +18 -3
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +3 -4
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +11 -1
- package/templates/fe/transport/graphql/clients/clients/auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +2 -2
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +2 -1
- package/templates/fe/transport/graphql/clients/links/context-headers.ts +9 -0
- package/templates/fe/transport/graphql/clients/links/http.ts +3 -3
- package/templates/fe/transport/graphql/clients/links/locale.ts +2 -1
- package/templates/fe/transport/graphql/clients/response.ts +1 -1
package/sync/ci-commands.mjs
CHANGED
|
@@ -10,91 +10,30 @@ const exampleCommand = (source, command) => source?.runtimeExample
|
|
|
10
10
|
|
|
11
11
|
const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
|
|
12
12
|
const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
|
|
13
|
-
const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v')
|
|
13
|
+
const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}";
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
function codecovProbe(codecov, condition) {
|
|
17
|
-
if (!codecov.optional) return [];
|
|
18
|
-
return [
|
|
19
|
-
' - name: Codecov availability',
|
|
20
|
-
' id: codecov-availability',
|
|
21
|
-
` if: ${condition}`,
|
|
22
|
-
' env:',
|
|
23
|
-
` CODECOV_AUTH: ${codecov.auth}`,
|
|
24
|
-
' run: |',
|
|
25
|
-
" node --input-type=module <<'NODE'",
|
|
26
|
-
" import fs from 'node:fs';",
|
|
27
|
-
' let available = false;',
|
|
28
|
-
' try {',
|
|
29
|
-
" const upload = await fetch('https://ingest.codecov.io/upload/v4', { signal: AbortSignal.timeout(10000) });",
|
|
30
|
-
' available = upload.status < 500;',
|
|
31
|
-
" if (available && process.env.CODECOV_AUTH === 'oidc') {",
|
|
32
|
-
" const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);",
|
|
33
|
-
" url.searchParams.set('audience', 'https://codecov.io');",
|
|
34
|
-
" const identity = await fetch(url, { headers: { Authorization: 'Bearer ' + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }, signal: AbortSignal.timeout(10000) });",
|
|
35
|
-
' available = identity.ok && Boolean((await identity.json()).value);',
|
|
36
|
-
' }',
|
|
37
|
-
' } catch { available = false; }',
|
|
38
|
-
" fs.appendFileSync(process.env.GITHUB_OUTPUT, 'up=' + available + '\\n');",
|
|
39
|
-
" if (!available) console.log('::notice::codecov upload skipped: upload endpoint or OIDC token unavailable (services.codecov.optional)');",
|
|
40
|
-
' NODE',
|
|
41
|
-
];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Optional Sonar only runs analysis when the provider reports UP within ten seconds. */
|
|
45
|
-
function sonarProbe(sonar) {
|
|
46
|
-
if (!sonar.optional) return [];
|
|
47
|
-
return [
|
|
48
|
-
' - name: Sonar availability',
|
|
49
|
-
' id: sonar-availability',
|
|
50
|
-
` if: ${SONAR_CONDITION}`,
|
|
51
|
-
' run: |',
|
|
52
|
-
` if curl -fsS --max-time 10 "$SONAR_HOST_URL/api/system/status" | node -e "let s='';process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{try{process.exit(JSON.parse(s).status==='UP'?0:1)}catch{process.exit(1)}})"; then`,
|
|
53
|
-
' echo "up=true" >> "$GITHUB_OUTPUT"',
|
|
54
|
-
' else',
|
|
55
|
-
' echo "up=false" >> "$GITHUB_OUTPUT"',
|
|
56
|
-
' echo "::notice::sonar skipped: $SONAR_HOST_URL not reachable (services.sonar.optional)"',
|
|
57
|
-
' fi',
|
|
58
|
-
];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
|
|
15
|
+
/** Codecov uploads on release tags with its required authentication; a missing report, an unavailable endpoint or a rejected upload fails the job. */
|
|
62
16
|
function codecovSteps(lcovReport, codecov) {
|
|
63
17
|
if (codecov.enabled === false || codecov.wiring === 'not-used') return [
|
|
64
18
|
' - name: coverage upload',
|
|
65
19
|
' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
|
|
66
20
|
].join('\n');
|
|
67
|
-
const condition = `\${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`;
|
|
68
|
-
const uploadCondition = codecov.optional ? condition.replace(' }}', " && steps.codecov-availability.outputs.up == 'true' }}") : condition;
|
|
69
21
|
return [
|
|
70
|
-
' - name: coverage upload skipped',
|
|
71
|
-
` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
|
|
72
|
-
' run: |',
|
|
73
|
-
` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
|
|
74
|
-
' exit 0',
|
|
75
|
-
...codecovProbe(codecov, condition),
|
|
76
22
|
' - name: coverage upload',
|
|
77
|
-
...(codecov.optional ? [' id: codecov-upload', ' continue-on-error: true'] : []),
|
|
78
23
|
' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
|
|
79
|
-
|
|
24
|
+
" if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}",
|
|
80
25
|
' with:',
|
|
81
26
|
codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
|
|
82
27
|
` files: ${lcovReport}`,
|
|
83
28
|
' disable_search: true',
|
|
84
|
-
|
|
85
|
-
...(codecov.optional ? [
|
|
86
|
-
' - name: Codecov upload unavailable',
|
|
87
|
-
" if: ${{ !cancelled() && steps.codecov-upload.outcome == 'failure' }}",
|
|
88
|
-
' run: |',
|
|
89
|
-
' echo "::notice::codecov upload skipped: upload unavailable (services.codecov.optional)"',
|
|
90
|
-
] : []),
|
|
29
|
+
' fail_ci_if_error: true',
|
|
91
30
|
].join('\n');
|
|
92
31
|
}
|
|
93
32
|
|
|
94
|
-
/** Sonar consumes the test coverage and
|
|
33
|
+
/** Sonar is required: on release tags it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
|
|
95
34
|
function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
96
35
|
if (!sonar) return '';
|
|
97
|
-
const condition =
|
|
36
|
+
const condition = SONAR_CONDITION;
|
|
98
37
|
const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
|
|
99
38
|
const header = [
|
|
100
39
|
' sonar:',
|
|
@@ -107,7 +46,10 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
|
107
46
|
' steps:',
|
|
108
47
|
].join('\n');
|
|
109
48
|
const steps = [
|
|
110
|
-
|
|
49
|
+
' - name: Sonar token',
|
|
50
|
+
` if: ${condition}`,
|
|
51
|
+
' run: |',
|
|
52
|
+
` if [ -z "$SONAR_TOKEN" ]; then echo "::error::SONAR_TOKEN is not set: Sonar is required, so a missing token fails this job (secret ${sonar.token})"; exit 1; fi`,
|
|
111
53
|
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
112
54
|
` if: ${condition}`,
|
|
113
55
|
' with:',
|
|
@@ -208,12 +150,22 @@ function sonarMergeSteps(condition) {
|
|
|
208
150
|
}
|
|
209
151
|
|
|
210
152
|
const STANDARD = new URL('../runtime/knowledge/hfs/ci-standard.yaml', import.meta.url);
|
|
211
|
-
/**
|
|
212
|
-
function
|
|
153
|
+
/** jest --shard=i/N gives each shard ceil(specs / N) spec files in order, so shard N is empty unless (N - 1) * ceil(specs / N) < specs. */
|
|
154
|
+
export function filledShards(specs, wanted) {
|
|
155
|
+
for (let shards = wanted; shards > 1; shards -= 1) if ((shards - 1) * Math.ceil(specs / shards) < specs) return shards;
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The unit specs of the be source: every *.spec.ts outside the test kind folders (src/tests/<kind>), the files the unit project runs. */
|
|
160
|
+
const unitSpecCount = (source) => (source?.files ?? []).filter((file) => file.endsWith('.spec.ts') && !file.includes('/tests/')).length;
|
|
161
|
+
|
|
162
|
+
/** The per-job settings of the managed CI: ci.jobs.<job>.timeoutMinutes and ci.jobs.test.shards of the manifest over the defaults of the CI standard, the shards capped at the count the unit specs fill (an empty shard would fail, never pass empty). */
|
|
163
|
+
function ciJobSettings(declaration, source) {
|
|
213
164
|
const { defaults, heap } = parseYaml(fs.readFileSync(STANDARD, 'utf8'));
|
|
214
165
|
const jobs = declaration?.ci?.jobs ?? {};
|
|
215
166
|
const timeout = (job) => String(jobs[job]?.timeoutMinutes ?? defaults.timeoutMinutes[job]);
|
|
216
|
-
const
|
|
167
|
+
const wanted = Number(jobs.test?.shards ?? defaults.shards);
|
|
168
|
+
const shards = source?.files?.length ? filledShards(unitSpecCount(source), wanted) : wanted;
|
|
217
169
|
return {
|
|
218
170
|
nodeOptions: `--max-old-space-size=${heap.minMiB}`,
|
|
219
171
|
timeoutFast: timeout('fast'), timeoutLint: timeout('lint'), timeoutTest: timeout('test'), timeoutCoverage: timeout('coverage'),
|
|
@@ -230,7 +182,7 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
230
182
|
const audit = quality?.audit ?? { level: 'high', omitDev: true };
|
|
231
183
|
const lcovArtifact = 'coverage-lcov';
|
|
232
184
|
const build = frontendBuild(source, app);
|
|
233
|
-
const settings = ciJobSettings(buildDeclaration(source));
|
|
185
|
+
const settings = ciJobSettings(buildDeclaration(source), source);
|
|
234
186
|
const side = '${{ matrix.side }}';
|
|
235
187
|
return {
|
|
236
188
|
...settings,
|
|
@@ -247,28 +199,24 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
|
|
|
247
199
|
auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
|
|
248
200
|
sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
|
|
249
201
|
sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
202
|
+
// One lint job per side (the lint matrix): with Sonar each side writes its own report, uploaded as sonar-lint-<side> and merged by the sonar job.
|
|
203
|
+
ciLintSteps: (sonar ? [
|
|
204
|
+
' - name: lint with Sonar import',
|
|
205
|
+
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
206
|
+
' env:',
|
|
207
|
+
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
208
|
+
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
209
|
+
// Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
|
|
210
|
+
" if: always() && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
|
|
211
|
+
' with:',
|
|
212
|
+
` name: sonar-lint-${side}`,
|
|
213
|
+
` path: reports/lint.${side}.sonar.json`,
|
|
214
|
+
] : [
|
|
253
215
|
' - name: lint',
|
|
254
|
-
...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
|
|
255
216
|
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
|
|
256
217
|
' env:',
|
|
257
218
|
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
258
|
-
|
|
259
|
-
' - name: lint with Sonar import',
|
|
260
|
-
" if: env.SONAR_TOKEN != ''",
|
|
261
|
-
` run: ${exampleCommand(source, 'npm run lint -- --side ' + side + ' --sonar reports/lint.' + side + '.sonar.json')}`,
|
|
262
|
-
' env:',
|
|
263
|
-
` NODE_OPTIONS: ${settings.nodeOptions}`,
|
|
264
|
-
` - uses: ${UPLOAD_ARTIFACT}`,
|
|
265
|
-
// Inside an if: expression the matrix value is an argument of format(), never a nested ${{ }} (actionlint: a nested expression makes the condition always true).
|
|
266
|
-
" if: always() && env.SONAR_TOKEN != '' && hashFiles(format('reports/lint.{0}.sonar.json', matrix.side)) != ''",
|
|
267
|
-
' with:',
|
|
268
|
-
` name: sonar-lint-${side}`,
|
|
269
|
-
` path: reports/lint.${side}.sonar.json`,
|
|
270
|
-
] : []),
|
|
271
|
-
].join('\n'),
|
|
219
|
+
]).join('\n'),
|
|
272
220
|
codecovSteps: codecovSteps(lcovReport, codecov),
|
|
273
221
|
sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
|
|
274
222
|
lcovArtifact,
|
package/sync/index.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { braceVariants } from '../runtime/scripts/lib/glob.mjs';
|
|
|
24
24
|
import { codecovPaths, coverageComponents, jestCoverage, sonarCoverageExclusions } from '../runtime/scripts/hfs/coverage-scope.mjs';
|
|
25
25
|
import { APP_SCOPE, SIDES, litePresenceOf, loadSlotManifest, resolveRepoDeclaration, slotInEdition } from '../runtime/scripts/hfs/slots.mjs';
|
|
26
26
|
import { managedGroupOf } from '../runtime/scripts/hfs/edition-slots.mjs';
|
|
27
|
+
import { declaredSlotEnabled } from '../runtime/scripts/hfs/declaration-slots.mjs';
|
|
27
28
|
import { feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
|
|
28
29
|
import { DEFAULT_PORT, NODE_IMAGE, dockerfilePath } from '../runtime/scripts/hfs/rules/docker.mjs';
|
|
29
30
|
import { isFile } from '../runtime/scripts/lib/fs-kind.mjs';
|
|
@@ -74,8 +75,10 @@ function managedTemplate(scope, group, file, hasTemplate) {
|
|
|
74
75
|
return directories.map(dir => `${dir}/${group}/${file}`).find(name => hasTemplate(name));
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
function slotTargets(slot, scope, edition, manifest, hasTemplate) {
|
|
78
|
+
function slotTargets(slot, scope, edition, manifest, hasTemplate, enabled) {
|
|
78
79
|
if (!slot.profiles.includes(scope) || !slotInEdition(manifest, slot, edition)) return [];
|
|
80
|
+
// An opt-in slot is rendered only for a repository that enables it (app.browser-config with `browser: true`).
|
|
81
|
+
if (slot.presence === 'opt-in' && !enabled(slot)) return [];
|
|
79
82
|
if (edition === 'lite' && litePresenceOf(slot, scope) === 'forbidden') return [];
|
|
80
83
|
const group = managedGroupOf(slot, edition);
|
|
81
84
|
if (group === undefined) return [];
|
|
@@ -100,10 +103,11 @@ function unlistedTargets(scope, edition) {
|
|
|
100
103
|
/**
|
|
101
104
|
* Every target of `scope` (app, be or fe), its path relative to that scope's folder: the files the manifest's managedBy slots
|
|
102
105
|
* list, then (app root) the unlisted ones. A slot that names managedBy lists literal files only, and each has a template under
|
|
103
|
-
* its scope or under common.
|
|
106
|
+
* its scope or under common. `enabled(slot)` decides an opt-in slot (renderTargets passes the declaration's); without it every slot
|
|
107
|
+
* counts, which is what an inventory of the templates needs.
|
|
104
108
|
*/
|
|
105
|
-
export function targetsOf(scope, { edition = 'full', manifest = loadSlotManifest(), hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)) } = {}) {
|
|
106
|
-
const listed = manifest.slots.flatMap(slot => slotTargets(slot, scope, edition, manifest, hasTemplate));
|
|
109
|
+
export function targetsOf(scope, { edition = 'full', manifest = loadSlotManifest(), hasTemplate = name => fs.existsSync(path.join(TEMPLATES_DIR, name)), enabled = () => true } = {}) {
|
|
110
|
+
const listed = manifest.slots.flatMap(slot => slotTargets(slot, scope, edition, manifest, hasTemplate, enabled));
|
|
107
111
|
return [...listed, ...unlistedTargets(scope, edition)];
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -179,6 +183,11 @@ export function bundledPresets() {
|
|
|
179
183
|
|
|
180
184
|
/** Where the be unit run writes the lcov report (jest `coverageDirectory` coverage under be/, reporter lcov), from the app root. */
|
|
181
185
|
const LCOV_REPORT = 'be/coverage/lcov.info';
|
|
186
|
+
/**
|
|
187
|
+
* The message catalogs Sonar's copy-paste detection leaves out (owner ruling 2026-10-22): a catalog holds one block per language
|
|
188
|
+
* with the same keys, which CPD reads as duplicated code. They are still sources: linted, analysed and held to every other rule.
|
|
189
|
+
*/
|
|
190
|
+
export const SONAR_CPD_EXCLUSIONS = Object.freeze(['**/messages/*.messages.ts', '**/messages/*.json']);
|
|
182
191
|
|
|
183
192
|
const SOURCE_FILE = /\.[cm]?ts$/;
|
|
184
193
|
const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
|
|
@@ -343,6 +352,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
343
352
|
sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
|
|
344
353
|
sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
|
|
345
354
|
lcovReport: LCOV_REPORT,
|
|
355
|
+
cpdExclusions: SONAR_CPD_EXCLUSIONS.join(','),
|
|
346
356
|
...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality, { app }),
|
|
347
357
|
// The coverage scope is the same in both editions: lite measures exactly what full measures (ruling:R212/case-1, amended by ruling:R212/case-4).
|
|
348
358
|
jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
|
|
@@ -383,7 +393,8 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
|
|
|
383
393
|
const hasTemplate = name => { try { return readTemplate(name) !== undefined; } catch { return false; } };
|
|
384
394
|
return SCOPES.flatMap(scope => {
|
|
385
395
|
const vars = variables(app, scope, presets, sonarKey, manifest, source);
|
|
386
|
-
const
|
|
396
|
+
const repo = scope === APP_SCOPE ? app : app.sides[scope];
|
|
397
|
+
const declaredTargets = targetsOf(scope, { edition: app.edition, manifest, hasTemplate, enabled: slot => declaredSlotEnabled(slot, repo) });
|
|
387
398
|
if (scope === APP_SCOPE && source?.badges) declaredTargets.push({ path: 'README.md', mode: 'block', markdown: true, template: 'app/readme-badges/README.md' });
|
|
388
399
|
const targets = declaredTargets
|
|
389
400
|
.filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
|
|
@@ -36,7 +36,6 @@ jobs:
|
|
|
36
36
|
lint:
|
|
37
37
|
timeout-minutes: {{timeoutLint}}
|
|
38
38
|
runs-on: ubuntu-latest
|
|
39
|
-
{{sonarEnv}}
|
|
40
39
|
strategy:
|
|
41
40
|
fail-fast: false
|
|
42
41
|
matrix:
|
|
@@ -57,9 +56,9 @@ jobs:
|
|
|
57
56
|
{{> app/ci-workflows/install-steps.yml}}
|
|
58
57
|
{{> app/ci-workflows/jest-cache.yml}}
|
|
59
58
|
# A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
|
|
60
|
-
# JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report.
|
|
59
|
+
# JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. The shard count never exceeds what the unit specs fill.
|
|
61
60
|
- name: unit
|
|
62
|
-
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}}
|
|
61
|
+
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}}
|
|
63
62
|
env:
|
|
64
63
|
STARCI_COVERAGE_SHARD: "1"
|
|
65
64
|
{{> app/ci-workflows/jest-cache-save.yml}}
|
|
@@ -36,7 +36,6 @@ jobs:
|
|
|
36
36
|
lint:
|
|
37
37
|
timeout-minutes: {{timeoutLint}}
|
|
38
38
|
runs-on: ubuntu-latest
|
|
39
|
-
{{sonarEnv}}
|
|
40
39
|
strategy:
|
|
41
40
|
fail-fast: false
|
|
42
41
|
matrix:
|
|
@@ -57,9 +56,9 @@ jobs:
|
|
|
57
56
|
{{> app/ci-workflows/install-steps.yml}}
|
|
58
57
|
{{> app/ci-workflows/jest-cache.yml}}
|
|
59
58
|
# A shard (STARCI_COVERAGE_SHARD=1, the jest preset) measures only the files its specs load, without the per-file threshold, and writes istanbul
|
|
60
|
-
# JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report.
|
|
59
|
+
# JSON; the coverage job merges the shards by source location and proves 100 per file on the merged report. The shard count never exceeds what the unit specs fill.
|
|
61
60
|
- name: unit
|
|
62
|
-
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}}
|
|
61
|
+
run: npm test -- --ci --forceExit --cacheDirectory .jest-cache --shard=${{ matrix.shard }}/{{testShardTotal}}
|
|
63
62
|
env:
|
|
64
63
|
STARCI_COVERAGE_SHARD: "1"
|
|
65
64
|
{{> app/ci-workflows/jest-cache-save.yml}}
|
|
@@ -21,8 +21,10 @@ refuse() {
|
|
|
21
21
|
release_gate() {
|
|
22
22
|
if [ -n "$gate" ]; then return 0; fi
|
|
23
23
|
gate="no annotated tag v[0-9]* on HEAD $head"
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
tags=$(git tag --points-at HEAD --list 'v[0-9]*' 2>/dev/null) || { gate="cannot list the tags of HEAD $head"; return 0; }
|
|
25
|
+
for tag in $tags; do
|
|
26
|
+
kind=$(git cat-file -t "refs/tags/$tag" 2>/dev/null) || continue
|
|
27
|
+
if [ "$kind" != "tag" ]; then continue; fi
|
|
26
28
|
if [ ! -f "$record" ]; then gate="no L4 record $record"; continue; fi
|
|
27
29
|
flat=$(tr -d ' \t\r\n' < "$record")
|
|
28
30
|
case "$flat" in
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
{{> app/package-scripts/core.json}}
|
|
6
6
|
"typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
|
|
7
7
|
"test": "cd be && jest --selectProjects unit --coverage",
|
|
8
|
-
"test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
|
|
9
8
|
"test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
|
|
10
9
|
"test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
|
|
11
10
|
"test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
{{> app/package-scripts/core.json}}
|
|
6
6
|
"typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
|
|
7
7
|
"test": "cd be && jest --selectProjects unit --coverage",
|
|
8
|
-
"test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
|
|
9
8
|
"test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
|
|
10
9
|
{{> app/package-scripts/supabase.json}}
|
|
11
10
|
"contract:emit": "{{appBinary}} emit"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
{{sonarHostLine}}
|
|
3
3
|
sonar.tests=be/apps,be/src
|
|
4
4
|
sonar.exclusions={{sonarExclusions}}
|
|
5
|
+
sonar.cpd.exclusions={{cpdExclusions}}
|
|
5
6
|
sonar.test.inclusions=**/*.spec.ts
|
|
6
7
|
sonar.typescript.tsconfigPaths={{tsconfigPaths}}
|
|
7
8
|
sonar.externalIssuesReportPaths=reports/lint.sonar.json
|
|
@@ -137,18 +137,17 @@ k8s:
|
|
|
137
137
|
reason: This starter declares Docker Swarm production; no Kubernetes environment is provisioned.
|
|
138
138
|
ci:
|
|
139
139
|
secrets:
|
|
140
|
-
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app
|
|
140
|
+
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
|
|
141
141
|
- {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
|
|
142
142
|
- {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
|
|
143
143
|
- {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
|
|
144
144
|
vars:
|
|
145
|
-
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host
|
|
145
|
+
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
|
|
146
146
|
- {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
|
|
147
147
|
- {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
|
|
148
148
|
services:
|
|
149
149
|
sonar:
|
|
150
150
|
provider: sonarqube
|
|
151
|
-
optional: true
|
|
152
151
|
mode: local
|
|
153
152
|
qualityGate: {{sonarGate}}
|
|
154
153
|
host: {local: http://127.0.0.1:9000, public: "https://sonar.{{project}}.example.invalid"}
|
|
@@ -160,7 +159,7 @@ services:
|
|
|
160
159
|
auth: token
|
|
161
160
|
credentials: []
|
|
162
161
|
projects: [{repository: "{{project}}", key: "{{project}}"}]
|
|
163
|
-
ci: {wiring:
|
|
162
|
+
ci: {wiring: required, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
|
|
164
163
|
ownerAction:
|
|
165
164
|
needed: Provision this app's Sonar host and project, then set SONAR_TOKEN with starci app ci secret set and sync SONAR_HOST_URL.
|
|
166
165
|
reason: A fresh app declares its quality service without fabricating credentials or a successful analysis.
|
|
@@ -172,7 +171,7 @@ services:
|
|
|
172
171
|
target: 100
|
|
173
172
|
components: auto
|
|
174
173
|
required: true
|
|
175
|
-
ci: {wiring:
|
|
174
|
+
ci: {wiring: required, workflow: .github/workflows/ci.yml}
|
|
176
175
|
ownerAction: none
|
|
177
176
|
dependabot:
|
|
178
177
|
provider: github
|
|
@@ -199,5 +198,5 @@ services:
|
|
|
199
198
|
host: {public: https://ghcr.io}
|
|
200
199
|
auth: github-token
|
|
201
200
|
projects: [{repository: "{{project}}", key: "{{project}}"}]
|
|
202
|
-
ci: {wiring:
|
|
201
|
+
ci: {wiring: required}
|
|
203
202
|
ownerAction: none
|
|
@@ -182,18 +182,17 @@ k8s:
|
|
|
182
182
|
reason: Lite production uses Docker Swarm with managed Supabase backing; Kubernetes belongs to a future full-edition deployment.
|
|
183
183
|
ci:
|
|
184
184
|
secrets:
|
|
185
|
-
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app
|
|
185
|
+
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
|
|
186
186
|
- {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
|
|
187
187
|
- {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
|
|
188
188
|
- {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
|
|
189
189
|
vars:
|
|
190
|
-
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host
|
|
190
|
+
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
|
|
191
191
|
- {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
|
|
192
192
|
- {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
|
|
193
193
|
services:
|
|
194
194
|
sonar:
|
|
195
195
|
provider: sonarqube
|
|
196
|
-
optional: true
|
|
197
196
|
mode: local
|
|
198
197
|
qualityGate: {{sonarGate}}
|
|
199
198
|
host: {local: http://127.0.0.1:9000, public: "https://sonar.{{project}}.example.invalid"}
|
|
@@ -205,7 +204,7 @@ services:
|
|
|
205
204
|
auth: token
|
|
206
205
|
credentials: []
|
|
207
206
|
projects: [{repository: "{{project}}", key: "{{project}}"}]
|
|
208
|
-
ci: {wiring:
|
|
207
|
+
ci: {wiring: required, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
|
|
209
208
|
ownerAction:
|
|
210
209
|
needed: Provision this app's Sonar host and project, then set SONAR_TOKEN with starci app ci secret set and sync SONAR_HOST_URL.
|
|
211
210
|
reason: A fresh app declares its quality service without fabricating credentials or a successful analysis.
|
|
@@ -217,7 +216,7 @@ services:
|
|
|
217
216
|
target: 100
|
|
218
217
|
components: auto
|
|
219
218
|
required: true
|
|
220
|
-
ci: {wiring:
|
|
219
|
+
ci: {wiring: required, workflow: .github/workflows/ci.yml}
|
|
221
220
|
ownerAction: none
|
|
222
221
|
dependabot:
|
|
223
222
|
provider: github
|
|
@@ -244,5 +243,5 @@ services:
|
|
|
244
243
|
host: {public: https://ghcr.io}
|
|
245
244
|
auth: github-token
|
|
246
245
|
projects: [{repository: "{{project}}", key: "{{project}}"}]
|
|
247
|
-
ci: {wiring:
|
|
246
|
+
ci: {wiring: required}
|
|
248
247
|
ownerAction: none
|
|
@@ -32,24 +32,23 @@ k8s:
|
|
|
32
32
|
reason: The upgrade establishes the full local stack first; deployment topology is selected separately.
|
|
33
33
|
ci:
|
|
34
34
|
secrets:
|
|
35
|
-
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app
|
|
35
|
+
- {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
|
|
36
36
|
- {name: SOPS_AGE_KEY, scope: repository, purpose: Decrypt app members during deployment, optional: true}
|
|
37
37
|
- {name: VPS_SSH_KEY, scope: repository, purpose: Deployment SSH key, optional: true}
|
|
38
38
|
- {name: VPS_KNOWN_HOSTS, scope: repository, purpose: Pinned deployment SSH host keys, optional: true}
|
|
39
39
|
vars:
|
|
40
|
-
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host
|
|
40
|
+
- {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
|
|
41
41
|
- {name: VPS_HOST, scope: repository, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
|
|
42
42
|
- {name: VPS_USER, scope: repository, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
|
|
43
43
|
services:
|
|
44
44
|
sonar:
|
|
45
45
|
provider: sonarqube
|
|
46
|
-
optional: true
|
|
47
46
|
mode: disabled
|
|
48
47
|
reason: Enable the repository's Sonar service when a host and project token are provisioned.
|
|
49
48
|
qualityGate: starci-quality
|
|
50
49
|
credentials: []
|
|
51
50
|
projects: [{repository: "{{project}}", key: "{{project}}"}]
|
|
52
|
-
ci: {wiring:
|
|
51
|
+
ci: {wiring: required, workflow: .github/workflows/ci.yml, token: SONAR_TOKEN, hostVar: SONAR_HOST_URL}
|
|
53
52
|
ownerAction: none
|
|
54
53
|
codecov:
|
|
55
54
|
provider: codecov
|
|
@@ -59,7 +58,7 @@ services:
|
|
|
59
58
|
target: 100
|
|
60
59
|
components: auto
|
|
61
60
|
required: true
|
|
62
|
-
ci: {wiring:
|
|
61
|
+
ci: {wiring: required, workflow: .github/workflows/ci.yml}
|
|
63
62
|
ownerAction: none
|
|
64
63
|
dependabot:
|
|
65
64
|
provider: github
|
|
@@ -86,5 +85,5 @@ services:
|
|
|
86
85
|
host: {public: https://ghcr.io}
|
|
87
86
|
auth: github-token
|
|
88
87
|
projects: [{repository: "{{project}}", key: "{{project}}"}]
|
|
89
|
-
ci: {wiring:
|
|
88
|
+
ci: {wiring: required}
|
|
90
89
|
ownerAction: none
|
|
@@ -13,6 +13,10 @@ import { readEnvelope } from "./event-envelope.policy"
|
|
|
13
13
|
import { EventRunnerService } from "./event-runner.service"
|
|
14
14
|
import type { EventSubscription, EventTransport, InboundMessage } from "./event-transport.port"
|
|
15
15
|
|
|
16
|
+
/** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
|
|
17
|
+
const anyOf = (type: unknown): unknown => expect.any(type)
|
|
18
|
+
const objectLike = (shape: Record<string, unknown>): unknown => expect.objectContaining(shape)
|
|
19
|
+
|
|
16
20
|
const AT = "2026-02-03T04:05:06.000Z"
|
|
17
21
|
const NOW = new Date(AT).getTime()
|
|
18
22
|
|
|
@@ -169,7 +173,7 @@ describe("EventRunnerService", () => {
|
|
|
169
173
|
await runner.receive(message({ value: JSON.stringify([1]) }))
|
|
170
174
|
|
|
171
175
|
expect(handle).not.toHaveBeenCalled()
|
|
172
|
-
expect(logger.error).toHaveBeenCalledWith(EventBusLogEvent.MessageSkipped,
|
|
176
|
+
expect(logger.error).toHaveBeenCalledWith(EventBusLogEvent.MessageSkipped, anyOf(SyntaxError), {
|
|
173
177
|
topic: "run-1.events.probe",
|
|
174
178
|
})
|
|
175
179
|
expect(logger.warn).toHaveBeenCalledTimes(1)
|
|
@@ -269,7 +273,7 @@ describe("EventRunnerService", () => {
|
|
|
269
273
|
)
|
|
270
274
|
|
|
271
275
|
expect(transport.wait).toHaveBeenCalledWith(800)
|
|
272
|
-
expect(handle).toHaveBeenCalledWith(
|
|
276
|
+
expect(handle).toHaveBeenCalledWith(objectLike({ attempt: 2 }))
|
|
273
277
|
})
|
|
274
278
|
|
|
275
279
|
it("does not wait for a retry message whose backoff has passed", async () => {
|
|
@@ -9,6 +9,9 @@ import type { Logger } from "@modules/platform/logging"
|
|
|
9
9
|
import { OutboxLogEvent } from "./outbox.log-events"
|
|
10
10
|
import { OutboxRelayPolicy } from "./outbox-relay.policy"
|
|
11
11
|
|
|
12
|
+
/** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
|
|
13
|
+
const anyOf = (type: unknown): unknown => expect.any(type)
|
|
14
|
+
|
|
12
15
|
interface Row {
|
|
13
16
|
readonly id: string
|
|
14
17
|
}
|
|
@@ -60,7 +63,7 @@ describe("OutboxRelayPolicy", () => {
|
|
|
60
63
|
await expect(relay.relay()).resolves.toBe(2)
|
|
61
64
|
|
|
62
65
|
expect(relay.deliver).toHaveBeenCalledWith(rows)
|
|
63
|
-
expect(relay.mark).toHaveBeenCalledWith(
|
|
66
|
+
expect(relay.mark).toHaveBeenCalledWith(anyOf(Object), rows)
|
|
64
67
|
expect(relay.mark).toHaveBeenCalledTimes(1)
|
|
65
68
|
expect(first.commits).toBe(1)
|
|
66
69
|
expect(second.commits).toBe(1)
|
|
@@ -7,6 +7,9 @@ import type { QueueOptions } from "./queue.options"
|
|
|
7
7
|
import { ATTEMPTS, BACKOFF_MS, KEEP_COMPLETED_SECONDS } from "./queue.policy"
|
|
8
8
|
import type { BullmqJob, BullmqQueue, BullmqWorker, QueueFactory } from "./queue-transport.port"
|
|
9
9
|
|
|
10
|
+
/** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
|
|
11
|
+
const anyOf = (type: unknown): unknown => expect.any(type)
|
|
12
|
+
|
|
10
13
|
const options: QueueOptions = {
|
|
11
14
|
redisHost: "redis.test",
|
|
12
15
|
redisPort: 6380,
|
|
@@ -83,14 +86,14 @@ describe("BullmqQueueTransportClient", () => {
|
|
|
83
86
|
|
|
84
87
|
await client.work(handler, 3)
|
|
85
88
|
|
|
86
|
-
expect(factory.worker).toHaveBeenCalledWith("orders",
|
|
89
|
+
expect(factory.worker).toHaveBeenCalledWith("orders", anyOf(Function), {
|
|
87
90
|
prefix: "spec.",
|
|
88
91
|
connection: { host: "redis.test", port: 6380, db: 7, maxRetriesPerRequest: null },
|
|
89
92
|
concurrency: 3,
|
|
90
93
|
})
|
|
91
94
|
const processor = factory.worker.mock.calls[0]?.[1]
|
|
92
95
|
await processor?.(mock<BullmqJob>({ id: "job-1", data: { orderId: "order-1" }, attemptsMade: 2 }))
|
|
93
|
-
await processor?.(
|
|
96
|
+
await processor?.({ data: { orderId: "order-2" }, attemptsMade: 0 } satisfies BullmqJob)
|
|
94
97
|
|
|
95
98
|
expect(handler.handle).toHaveBeenNthCalledWith(1, {
|
|
96
99
|
id: "job-1",
|
|
@@ -70,7 +70,7 @@ export class @@Channel@@WebsocketModule extends ConfigurableModuleClass implemen
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
private async authenticate(client: @@Channel@@Socket): Promise<void> {
|
|
73
|
-
client.data.principal
|
|
73
|
+
delete client.data.principal
|
|
74
74
|
const token = client.handshake.auth.accessToken
|
|
75
75
|
if (typeof token !== "string" || token.length === 0) throw new Error("Unauthorized")
|
|
76
76
|
const verified = await this.verifyAccessToken(token)
|
|
@@ -7,6 +7,9 @@ import { @@Step@@Compensation } from "./compensations/@@step@@.compensation"
|
|
|
7
7
|
import { @@Saga@@SagaService } from "./@@saga@@.saga.service"
|
|
8
8
|
import { @@Step@@Step } from "./steps/@@step@@.saga-step"
|
|
9
9
|
|
|
10
|
+
/** Asymmetric matchers read as `unknown`: jest types them `any`, which may not flow into a property or a typed argument. */
|
|
11
|
+
const anyOf = (type: unknown): unknown => expect.any(type)
|
|
12
|
+
|
|
10
13
|
const build = async () => {
|
|
11
14
|
const sagas = mock<SagaService>()
|
|
12
15
|
const compensation = mock<@@Step@@Compensation>()
|
|
@@ -33,7 +36,7 @@ describe("@@Saga@@SagaService", () => {
|
|
|
33
36
|
saga: @@sagaUpper@@_SAGA,
|
|
34
37
|
correlationId: "x-1",
|
|
35
38
|
eventId: "e-1",
|
|
36
|
-
step:
|
|
39
|
+
step: anyOf(@@Step@@Step),
|
|
37
40
|
compensation,
|
|
38
41
|
})
|
|
39
42
|
})
|
|
@@ -16,16 +16,16 @@ export const formatGraphqlError = (
|
|
|
16
16
|
if (error instanceof GraphQLError && error.originalError === undefined) {
|
|
17
17
|
return {
|
|
18
18
|
message: formatted.message,
|
|
19
|
-
locations: formatted.locations,
|
|
20
|
-
path: formatted.path,
|
|
19
|
+
...(formatted.locations === undefined ? {} : { locations: formatted.locations }),
|
|
20
|
+
...(formatted.path === undefined ? {} : { path: formatted.path }),
|
|
21
21
|
extensions: { kind: "invalid" },
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
const description = errors.describe(error instanceof GraphQLError ? error.originalError : error)
|
|
25
25
|
return {
|
|
26
26
|
message: description.code,
|
|
27
|
-
locations: formatted.locations,
|
|
28
|
-
path: formatted.path,
|
|
27
|
+
...(formatted.locations === undefined ? {} : { locations: formatted.locations }),
|
|
28
|
+
...(formatted.path === undefined ? {} : { path: formatted.path }),
|
|
29
29
|
extensions: { code: description.code, kind: description.kind, params: description.params },
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -8,7 +8,7 @@ import type { GraphqlContext } from "./graphql.contracts"
|
|
|
8
8
|
import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
|
|
9
9
|
|
|
10
10
|
const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
|
|
11
|
-
mock<GraphQLResponse>({ body: { kind: "single", singleResult: { errors } } })
|
|
11
|
+
mock<GraphQLResponse>({ body: { kind: "single", singleResult: errors === undefined ? {} : { errors } } })
|
|
12
12
|
|
|
13
13
|
const requestContext = (
|
|
14
14
|
response: GraphQLResponse,
|