@volter/twin-standard 1.0.148 → 1.0.150
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 +12 -3
- package/client-sdks/bun.lock +0 -32
- package/client-sdks/package.json +0 -2
- package/dist/client-sdks/bun.lock +0 -32
- package/dist/client-sdks/package.json +0 -2
- package/dist/src/assessment.d.ts +6 -4
- package/dist/src/assessment.js +2 -1
- package/dist/src/cli.js +21 -6
- package/dist/src/clients/harness.d.ts +3 -1
- package/dist/src/clients/harness.js +4 -2
- package/dist/src/clients/jira.d.ts +2 -0
- package/dist/src/clients/jira.js +71 -0
- package/dist/src/clients/polar.d.ts +2 -0
- package/dist/src/clients/polar.js +73 -0
- package/dist/src/clients/resend.js +38 -0
- package/dist/src/clients/run.js +1 -1
- package/dist/src/clients/sentry.js +1 -26
- package/dist/src/clients/smtp.d.ts +2 -0
- package/dist/src/clients/smtp.js +54 -0
- package/dist/src/clients/stripe.js +1 -1
- package/dist/src/clients/volteridentity.js +4 -3
- package/dist/src/clients/x.d.ts +2 -0
- package/dist/src/clients/x.js +64 -0
- package/dist/src/conformance.d.ts +4 -3
- package/dist/src/conformance.js +293 -8
- package/dist/src/consumer.js +5 -1
- package/dist/src/create-pack.d.ts +5 -0
- package/dist/src/create-pack.js +38 -4
- package/dist/src/derive.js +21 -13
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +47 -4
- package/dist/src/grade.d.ts +1 -0
- package/dist/src/grade.js +1 -1
- package/dist/src/pack-facts.d.ts +9 -1
- package/dist/src/pack-facts.js +11 -2
- package/dist/src/pack-files.js +2 -1
- package/dist/src/protocol-3.js +40 -6
- package/dist/src/spec-ir-client.js +3 -2
- package/dist/src/spec-ir-commands.d.ts +73 -0
- package/dist/src/spec-ir-commands.js +63 -0
- package/dist/src/spec-ir.d.ts +2 -0
- package/dist/src/spec-ir.js +43 -0
- package/dist/src/tools/judge-brief.d.ts +1 -0
- package/dist/src/tools/judge-brief.js +67 -0
- package/dist/src/tools/life-coverage-walk.d.ts +2 -0
- package/dist/src/tools/life-coverage-walk.js +220 -0
- package/dist/src/tools/life-coverage.d.ts +2 -0
- package/dist/src/tools/life-coverage.js +271 -0
- package/dist/src/tools/packs-root.d.ts +9 -0
- package/dist/src/tools/packs-root.js +17 -0
- package/dist/src/tools/run-tool.d.ts +1 -0
- package/dist/src/tools/run-tool.js +18 -0
- package/dist/src/tools/score-pack.d.ts +2 -0
- package/dist/src/tools/score-pack.js +264 -0
- package/dist/src/tools/vendor-examples.d.ts +2 -0
- package/dist/src/tools/vendor-examples.js +1493 -0
- package/dist/src/types.d.ts +2 -0
- package/dist/src/verdict.js +1 -1
- package/dist/src/walk/behavior-journey.d.ts +6 -0
- package/dist/src/walk/behavior-journey.js +108 -8
- package/dist/src/walk/journey-kit.d.ts +23 -1
- package/dist/src/walk/journey-kit.js +7 -2
- package/dist/src/walk/rendered-scalar.d.ts +3 -0
- package/dist/src/walk/rendered-scalar.js +23 -0
- package/dist/src/walk/sse-example.js +45 -0
- package/package.json +2 -2
- package/src/assessment.ts +2 -1
- package/src/cli.ts +15 -2
- package/src/clients/harness.ts +4 -2
- package/src/clients/jira.ts +73 -0
- package/src/clients/polar.ts +73 -0
- package/src/clients/resend.ts +32 -0
- package/src/clients/run.ts +2 -2
- package/src/clients/sentry.ts +1 -25
- package/src/clients/smtp.ts +49 -0
- package/src/clients/stripe.ts +1 -1
- package/src/clients/volteridentity.ts +4 -3
- package/src/clients/x.ts +66 -0
- package/src/conformance.ts +226 -10
- package/src/consumer.ts +5 -1
- package/src/create-pack.ts +35 -5
- package/src/derive.ts +21 -14
- package/src/gate.ts +40 -6
- package/src/grade.ts +2 -2
- package/src/pack-facts.ts +10 -3
- package/src/pack-files.ts +2 -1
- package/src/protocol-3.ts +32 -6
- package/src/spec-ir-client.ts +3 -2
- package/src/spec-ir-commands.ts +81 -0
- package/src/spec-ir.ts +31 -0
- package/src/tools/judge-brief.ts +63 -0
- package/src/tools/life-coverage-walk.ts +173 -0
- package/src/tools/life-coverage.ts +237 -0
- package/src/tools/packs-root.ts +21 -0
- package/src/tools/run-tool.ts +10 -0
- package/src/tools/score-pack.ts +244 -0
- package/src/tools/vendor-examples.ts +1250 -0
- package/src/types.ts +2 -0
- package/src/verdict.ts +1 -1
- package/src/walk/behavior-journey.ts +65 -4
- package/src/walk/journey-kit.ts +21 -3
- package/src/walk/rendered-scalar.ts +17 -0
- package/src/walk/sse-example.ts +37 -0
package/dist/src/types.d.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface ScoreReport {
|
|
|
53
53
|
}
|
|
54
54
|
export interface CriterionResult {
|
|
55
55
|
pass: boolean;
|
|
56
|
+
/** An interrupted check is not a verdict about unchanged source. */
|
|
57
|
+
cacheable?: boolean;
|
|
56
58
|
/** why it failed, or what it measured when it passed */
|
|
57
59
|
reason: string;
|
|
58
60
|
}
|
package/dist/src/verdict.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export function attributionName(name) {
|
|
8
8
|
if (name === undefined)
|
|
9
9
|
return undefined;
|
|
10
|
-
if (/\bcx-|\bt_[0-9a-f]{8}|wt\/|\/root(?:\/|$|[: )])|\b(?:pane|session)[
|
|
10
|
+
if (/\bcx-|\bt_[0-9a-f]{8}|wt\/|\/root(?:\/|$|[: )])|\b(?:pane|session)[ :-]|[0-9a-f]{8}-[0-9a-f-]{27}|\b[0-9a-f]{16,64}\b/i.test(name))
|
|
11
11
|
throw new Error('attribution must use an author name, not an execution identifier');
|
|
12
12
|
return name;
|
|
13
13
|
}
|
|
@@ -18,6 +18,12 @@ export type Failure = {
|
|
|
18
18
|
beat: string;
|
|
19
19
|
detail: string;
|
|
20
20
|
};
|
|
21
|
+
/** What a stored row keeps for the twin that no schema of the vendor's spec names, by path, in anything the World shows a
|
|
22
|
+
* client: the pack's `_`-prefixed bookkeeping (GitHub's `_links` is the vendor's), and, in what no answer's SHAPE
|
|
23
|
+
* reads (an event's delivery, a socket frame), the kernel's own `updatedAt`, which a raw row filtered on `_` alone
|
|
24
|
+
* carries (Slack's message events once did). The kernel's `deleted` is not read there: vendors send their own
|
|
25
|
+
* (Stripe's deleted customer's `deleted: true`). */
|
|
26
|
+
export declare function leaked(v: unknown, vendorFields: Set<string>, at?: string, unshaped?: boolean): string[];
|
|
21
27
|
/** What a caller that scores a walk observes around each step: the step, its resolved path and body,
|
|
22
28
|
* and (after) the answer and the failures the step produced. */
|
|
23
29
|
export type StepHooks = {
|
|
@@ -8,7 +8,8 @@ import { attributionName } from "../verdict.js";
|
|
|
8
8
|
// score-pack walks the life through it twice, on two fresh roots, and compares what they answer.
|
|
9
9
|
//
|
|
10
10
|
// The caller names the pack repository first (usePacks).
|
|
11
|
-
import { mysql, redis } from '@volter/world-core';
|
|
11
|
+
import { mongodb, mysql, redis } from '@volter/world-core';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
12
13
|
import { gzipSync } from 'node:zlib';
|
|
13
14
|
import { parse, visit } from 'graphql';
|
|
14
15
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
@@ -16,7 +17,7 @@ import { join } from 'node:path';
|
|
|
16
17
|
import { databaseSession, getActivePackAssets, getActiveWorldStore, LocalPackAssets, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActivePackAssets, setActiveWorldStore, setDeliveryObserver, setInProcessTwins } from '@volter/world-core/runtime';
|
|
17
18
|
import { createHostTable } from '@volter/world-core/host-routing';
|
|
18
19
|
import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
|
|
19
|
-
import { isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from "./journey-kit.js";
|
|
20
|
+
import { isMongo, isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from "./journey-kit.js";
|
|
20
21
|
import { lanesOf, lineShape, shapeChecker } from "./shape.js";
|
|
21
22
|
import { walkPackDirectory } from "./pack-directory.js";
|
|
22
23
|
import { xmlMembers } from "./xml-view.js";
|
|
@@ -150,7 +151,7 @@ const claims = (keys, url) => {
|
|
|
150
151
|
* reads (an event's delivery, a socket frame), the kernel's own `updatedAt`, which a raw row filtered on `_` alone
|
|
151
152
|
* carries (Slack's message events once did). The kernel's `deleted` is not read there: vendors send their own
|
|
152
153
|
* (Stripe's deleted customer's `deleted: true`). */
|
|
153
|
-
function leaked(v, vendorFields, at = '', unshaped = false) {
|
|
154
|
+
export function leaked(v, vendorFields, at = '', unshaped = false) {
|
|
154
155
|
if (Array.isArray(v))
|
|
155
156
|
return v.flatMap((x, i) => leaked(x, vendorFields, `${at}[${i}]`, unshaped)).slice(0, 5);
|
|
156
157
|
if (!v || typeof v !== 'object')
|
|
@@ -579,6 +580,99 @@ export async function walk(journey, root, collect, opts) {
|
|
|
579
580
|
opts.hooks?.after?.(step, link, answered, collect.slice(before));
|
|
580
581
|
}
|
|
581
582
|
};
|
|
583
|
+
// A MongoDB step speaks the pack's MongoDB wire as an application's driver does (@volter/world-core/mongodb's client), on
|
|
584
|
+
// the same byte-stream adapter the owned TCP listener serves. The document codec is BSON, the library the pack
|
|
585
|
+
// installs: the walk loads it from the pack's own dependencies, so a step is encoded by the library the pack decodes with.
|
|
586
|
+
const mongoLinks = new Map();
|
|
587
|
+
let bson;
|
|
588
|
+
const bareNumber = (value, at = 'mongo') => {
|
|
589
|
+
if (typeof value === 'number')
|
|
590
|
+
return at;
|
|
591
|
+
if (Array.isArray(value)) {
|
|
592
|
+
for (const [i, item] of value.entries()) {
|
|
593
|
+
const found = bareNumber(item, `${at}[${i}]`);
|
|
594
|
+
if (found)
|
|
595
|
+
return found;
|
|
596
|
+
}
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
if (value && typeof value === 'object')
|
|
600
|
+
for (const [key, item] of Object.entries(value)) {
|
|
601
|
+
const found = bareNumber(item, `${at}.${key}`);
|
|
602
|
+
if (found)
|
|
603
|
+
return found;
|
|
604
|
+
}
|
|
605
|
+
return undefined;
|
|
606
|
+
};
|
|
607
|
+
const mongoStep = async (step) => {
|
|
608
|
+
const before = collect.length;
|
|
609
|
+
let answered = { status: 0, body: undefined };
|
|
610
|
+
const link = step.link ?? 'mongo';
|
|
611
|
+
opts.hooks?.before?.(step, link, step.mongo);
|
|
612
|
+
try {
|
|
613
|
+
if (!bson) {
|
|
614
|
+
const pack = walkPackDirectory(packsDir(), journey.vendor, dependenciesDir());
|
|
615
|
+
if (!pack)
|
|
616
|
+
throw new Error(`no pack directory for ${journey.vendor} to load its BSON library from`);
|
|
617
|
+
bson = createRequire(join(pack, 'package.json'))('bson');
|
|
618
|
+
}
|
|
619
|
+
const codec = { serialize: (document) => bson.serialize(document), deserialize: (bytes) => bson.deserialize(bytes, { promoteValues: false, promoteLongs: false, bsonRegExp: true }) };
|
|
620
|
+
let held = mongoLinks.get(link);
|
|
621
|
+
if (!held) {
|
|
622
|
+
if (!served?.stream)
|
|
623
|
+
streams ??= await vendorStream(packsDir(), journey.vendor, dependenciesDir());
|
|
624
|
+
const factory = served?.stream ?? streams?.(root, {});
|
|
625
|
+
if (!factory)
|
|
626
|
+
throw new Error(`no create<Name>TwinStream adapter enrolled for ${journey.vendor}`);
|
|
627
|
+
let out = [];
|
|
628
|
+
const conn = factory({ write: (b) => { out.push(...b); }, end: () => { } }, '127.0.0.1');
|
|
629
|
+
const exchange = async (bytes) => { conn.data(bytes); await conn.settled(); const got = Uint8Array.from(out); out = []; return got; };
|
|
630
|
+
held = { client: mongodb.mongoClient(exchange, codec), database: 'test', close: () => conn.close() };
|
|
631
|
+
mongoLinks.set(link, held);
|
|
632
|
+
}
|
|
633
|
+
if (step.database)
|
|
634
|
+
held.database = fill(step.database, captured);
|
|
635
|
+
const written = fillJson(step.mongo, captured);
|
|
636
|
+
const bare = bareNumber(written);
|
|
637
|
+
if (bare)
|
|
638
|
+
throw new Error(`${bare} is a bare JSON number: write its kind ({ "$numberInt": "…" }, $numberLong or $numberDouble)`);
|
|
639
|
+
const reply = await held.client.command(held.database, bson.EJSON.deserialize(written, { relaxed: false }));
|
|
640
|
+
const body = bson.EJSON.serialize(reply, { relaxed: false });
|
|
641
|
+
const ok = body.ok, failed = !ok || Number(Object.values(ok)[0]) !== 1;
|
|
642
|
+
const code = failed ? Number(Object.values(body.code ?? { none: Number.NaN })[0]) : undefined;
|
|
643
|
+
answered = { status: code ?? 0, body };
|
|
644
|
+
if (opts.verbose)
|
|
645
|
+
console.log(` ${String(code ?? 'OK').padEnd(5)} ${link.padEnd(6)} ${String(Object.keys(step.mongo)[0] ?? '').padEnd(20)} ${step.name}`);
|
|
646
|
+
if (step.refused !== undefined ? code !== step.refused : failed) {
|
|
647
|
+
collect.push({ step: step.name, beat: 'STATUS', detail: failed ? `MongoDB refused it: ${code} ${String(body.codeName ?? '')} ${String(body.errmsg ?? '')}${step.refused ? ` (wanted ${step.refused})` : ''}` : `MongoDB completed it, wanted ${step.refused}` });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
for (const [name, dot] of Object.entries(step.capture ?? {})) {
|
|
651
|
+
const value = captureAt(body, dot);
|
|
652
|
+
if (value === undefined || value === null)
|
|
653
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` });
|
|
654
|
+
else
|
|
655
|
+
remember(name, value);
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
for (const detail of expectFailures(step.expect ?? [], body, captured))
|
|
659
|
+
collect.push({ step: step.name, beat: 'EXPECT', detail });
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
collect.push({ step: step.name, beat: 'CAPTURE', detail: e.message });
|
|
663
|
+
}
|
|
664
|
+
if (step.close) {
|
|
665
|
+
mongoLinks.get(link)?.close();
|
|
666
|
+
mongoLinks.delete(link);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
catch (e) {
|
|
670
|
+
collect.push({ step: step.name, beat: 'MONGO', detail: e.message });
|
|
671
|
+
}
|
|
672
|
+
finally {
|
|
673
|
+
opts.hooks?.after?.(step, link, answered, collect.slice(before));
|
|
674
|
+
}
|
|
675
|
+
};
|
|
582
676
|
// A git step is a git client's clone or push on the pack's smart-HTTP plane (packages/twin-standard/src/walk/git-client.ts); its answer is
|
|
583
677
|
// checked as a request's is (status, captures, expectations).
|
|
584
678
|
const gitStep = async (step) => {
|
|
@@ -931,6 +1025,10 @@ export async function walk(journey, root, collect, opts) {
|
|
|
931
1025
|
try {
|
|
932
1026
|
for (const step of journey.steps) {
|
|
933
1027
|
stepNow = step.name;
|
|
1028
|
+
if (isMongo(step)) {
|
|
1029
|
+
await mongoStep(step);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
934
1032
|
if (isMysql(step)) {
|
|
935
1033
|
await mysqlStep(step);
|
|
936
1034
|
continue;
|
|
@@ -1074,13 +1172,15 @@ export async function walk(journey, root, collect, opts) {
|
|
|
1074
1172
|
let res;
|
|
1075
1173
|
if (deferred) {
|
|
1076
1174
|
pending.delete(step.await);
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1175
|
+
const got = await deferred.answer.then((a) => ({ a }), (error) => ({ error }));
|
|
1176
|
+
// a body the vendor broke off, as the collect expects (`breaks`): its words are the answer
|
|
1177
|
+
if ('error' in got) {
|
|
1178
|
+
if (step.breaks === undefined || !String(got.error).includes(step.breaks))
|
|
1179
|
+
collect.push({ step: step.name, beat: 'PENDING', detail: String(got.error) });
|
|
1082
1180
|
continue;
|
|
1083
1181
|
}
|
|
1182
|
+
// over a listener, which ends a broken stream as a short body, the ordinary checks hold the part that arrived
|
|
1183
|
+
res = got.a;
|
|
1084
1184
|
}
|
|
1085
1185
|
else
|
|
1086
1186
|
res = await send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path);
|
|
@@ -10,6 +10,8 @@ export type JourneyStep = {
|
|
|
10
10
|
defer?: string;
|
|
11
11
|
/** Collect a previously deferred request once, with all ordinary response checks. */
|
|
12
12
|
await?: string;
|
|
13
|
+
/** A collect whose answer's body breaks off with these words (a stream the vendor ends in an error), not arriving whole. */
|
|
14
|
+
breaks?: string;
|
|
13
15
|
body?: unknown;
|
|
14
16
|
/** A body of raw bytes (a gzip'd package, an image, an audio file), base64 in the journey and sent decoded: a
|
|
15
17
|
* string body is sent as UTF-8, which no binary format survives. A step carries `body` or `bodyBase64`, never both. */
|
|
@@ -226,7 +228,7 @@ export type Journey = {
|
|
|
226
228
|
}) => boolean;
|
|
227
229
|
steps: Step[];
|
|
228
230
|
};
|
|
229
|
-
export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep;
|
|
231
|
+
export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep | MongoStep;
|
|
230
232
|
/** A step on a MySQL wire (the kernel's @volter/world-core/mysql, a pack's `create<Name>TwinStream`), spoken as an
|
|
231
233
|
* application's driver speaks it. `signIn` opens a named connection (`link`, `mysql` by default) with a user and
|
|
232
234
|
* password (mysql_native_password); `mysql` sends a statement on it: a text query, or a prepared statement when
|
|
@@ -249,6 +251,26 @@ export type MysqlStep = {
|
|
|
249
251
|
expect?: JourneyStep['expect'];
|
|
250
252
|
};
|
|
251
253
|
export declare const isMysql: (step: Step) => step is MysqlStep;
|
|
254
|
+
/** A step on a pack's MongoDB wire, sent as an application's driver sends it (`@volter/world-core/mongodb`'s client)
|
|
255
|
+
* on a named `link`, opened by the first step that names it. `mongo` is ONE command document, written in
|
|
256
|
+
* canonical extended JSON so that what a document holds is written as it is: an ObjectId as `{ "$oid": … }`, a date
|
|
257
|
+
* as `{ "$date": … }`, and a number with its kind (`{ "$numberInt": "1" }`, `$numberLong`, `$numberDouble`); a bare
|
|
258
|
+
* JSON number is refused, because JSON does not say which of three kinds it is. `database` is the database it is
|
|
259
|
+
* sent to (the link's last one, else `test`). Its answer is the reply document in the same form. A reply whose
|
|
260
|
+
* `ok` is 0 fails the step unless `refused` names its `code`. */
|
|
261
|
+
export type MongoStep = {
|
|
262
|
+
name: string;
|
|
263
|
+
mongo: Record<string, unknown>;
|
|
264
|
+
database?: string;
|
|
265
|
+
link?: string;
|
|
266
|
+
close?: boolean;
|
|
267
|
+
refused?: number;
|
|
268
|
+
capture?: JourneyStep['capture'];
|
|
269
|
+
expect?: JourneyStep['expect'];
|
|
270
|
+
example?: string;
|
|
271
|
+
exampleDiffers?: Record<string, string>;
|
|
272
|
+
};
|
|
273
|
+
export declare const isMongo: (step: Step) => step is MongoStep;
|
|
252
274
|
/** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
|
|
253
275
|
* Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.
|
|
254
276
|
* `read` drains pushes or a blocking command's answer without sending bytes. */
|
|
@@ -112,8 +112,9 @@ export function expectFailures(expect, body, captured, headers = {}, wireText) {
|
|
|
112
112
|
if (e.contains !== undefined) {
|
|
113
113
|
const want = fill(e.contains, captured);
|
|
114
114
|
const text = typeof v === 'string' ? v : JSON.stringify(v ?? '');
|
|
115
|
+
// what it holds instead, its first characters, so a failure says what the twin answered
|
|
115
116
|
if (!text.includes(want))
|
|
116
|
-
out.push(`${e.path || 'the answer'} does not contain ${JSON.stringify(want)}`);
|
|
117
|
+
out.push(`${e.path || 'the answer'} does not contain ${JSON.stringify(want)} (it begins ${JSON.stringify(text.replace(/\s+/g, ' ').slice(0, 160))})`);
|
|
117
118
|
}
|
|
118
119
|
// `notContains`: the value does not hold the text (a list that omits what the caller may not see)
|
|
119
120
|
if (e.notContains !== undefined) {
|
|
@@ -198,6 +199,7 @@ export function expandFills(journey) {
|
|
|
198
199
|
return steps.some((x, i) => x !== journey.steps[i]) ? { ...journey, steps } : journey;
|
|
199
200
|
}
|
|
200
201
|
export const isMysql = (step) => 'mysql' in step || 'signIn' in step;
|
|
202
|
+
export const isMongo = (step) => 'mongo' in step;
|
|
201
203
|
export const isResp = (step) => 'connection' in step;
|
|
202
204
|
export const isLine = (step) => 'on' in step;
|
|
203
205
|
export const isSocket = (step) => 'socket' in step;
|
|
@@ -336,7 +338,10 @@ export function fill(template, captured, graphqlDocument = false) {
|
|
|
336
338
|
}
|
|
337
339
|
// a doubled brace is the vendor's own template syntax ({{sample.output_text}} in an OpenAI grader), not a capture; a
|
|
338
340
|
// capture's name starts with a letter, so a pattern's count (`[A-Za-z0-9]{40}` in an expectation) is no capture
|
|
339
|
-
|
|
341
|
+
// `{literal:word}` is `{word}` as the vendor's page prints it (a Redis hash tag, `{queue}`), never a capture
|
|
342
|
+
return encoded.replace(/\{literal:([^{}]*)\}|\{\{[^{}]*\}\}|\{([A-Za-z_][A-Za-z0-9_.]*)\}/g, (m, literal, key, offset) => {
|
|
343
|
+
if (literal !== undefined)
|
|
344
|
+
return `{${literal}}`;
|
|
340
345
|
if (selections.has(offset))
|
|
341
346
|
return m;
|
|
342
347
|
if (key === undefined)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Unfilled scalar type words in schema-rendered examples. Real values and enum words remain literal. */
|
|
2
|
+
const BOOLEAN = Symbol('rendered boolean');
|
|
3
|
+
const INTEGER = Symbol('rendered integer');
|
|
4
|
+
const NUMBER = Symbol('rendered number');
|
|
5
|
+
export function renderedScalar(value, types, enumerated) {
|
|
6
|
+
if (enumerated || typeof value !== 'string')
|
|
7
|
+
return undefined;
|
|
8
|
+
if (value === 'boolean' && types.includes('boolean'))
|
|
9
|
+
return BOOLEAN;
|
|
10
|
+
if (value === 'integer' && types.includes('integer'))
|
|
11
|
+
return INTEGER;
|
|
12
|
+
if (value === 'number' && types.includes('number'))
|
|
13
|
+
return NUMBER;
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
export function isRenderedScalar(value) { return value === BOOLEAN || value === INTEGER || value === NUMBER; }
|
|
17
|
+
export function matchesRenderedScalar(marker, value) {
|
|
18
|
+
if (marker === BOOLEAN)
|
|
19
|
+
return typeof value === 'boolean';
|
|
20
|
+
if (marker === INTEGER)
|
|
21
|
+
return typeof value === 'bigint' || (typeof value === 'number' && Number.isInteger(value));
|
|
22
|
+
return marker === NUMBER && typeof value === 'number' && Number.isFinite(value);
|
|
23
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { parseExactJson } from '@volter/world-core/exact-json';
|
|
2
2
|
/** Match a published SDK-decoded SSE sample to a real HTTP frame; preserve non-SSE answers and mismatches. */
|
|
3
3
|
export function sseExampleAnswer(example, answer, matches) {
|
|
4
|
+
if (typeof example === 'string' && typeof answer === 'string')
|
|
5
|
+
return streamSample(example, answer);
|
|
4
6
|
if (typeof answer !== 'string' || !example || typeof example !== 'object')
|
|
5
7
|
return answer;
|
|
6
8
|
const wrapped = 'data' in example;
|
|
@@ -30,3 +32,46 @@ export function sseExampleAnswer(example, answer, matches) {
|
|
|
30
32
|
const candidates = relevant.map((frame) => wrapped ? frame : frame.data);
|
|
31
33
|
return candidates.find(matches) ?? candidates[0] ?? answer;
|
|
32
34
|
}
|
|
35
|
+
/** A stream's frames: each block's `event:` and joined `data:` lines, or a block that is JSON alone (a page printing the
|
|
36
|
+
* chunks without their framing); `[DONE]`, an elision (`...`, `....`) and anything else not JSON are no frame. */
|
|
37
|
+
function framesOf(text) {
|
|
38
|
+
const frames = [];
|
|
39
|
+
for (const block of text.replace(/\r\n/g, '\n').split(/\n\s*\n/)) {
|
|
40
|
+
const lines = block.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
41
|
+
const framed = lines.some((l) => l.startsWith('data:'));
|
|
42
|
+
const data = framed ? lines.filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n') : lines.join('\n');
|
|
43
|
+
if (!data || data === '[DONE]' || /^\.{3,}$/.test(data))
|
|
44
|
+
continue;
|
|
45
|
+
const event = lines.find((l) => l.startsWith('event:'))?.slice(6).trim();
|
|
46
|
+
try {
|
|
47
|
+
frames.push({ data: parseExactJson(data), ...(event ? { event } : {}) });
|
|
48
|
+
}
|
|
49
|
+
catch { /* not a JSON frame */ }
|
|
50
|
+
}
|
|
51
|
+
return frames;
|
|
52
|
+
}
|
|
53
|
+
const kindOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
|
|
54
|
+
/** Whether an answer's frame holds a published frame's keys with the same kinds of value (a null published value admits
|
|
55
|
+
* any), its type or object the same. */
|
|
56
|
+
function holds(published, got) {
|
|
57
|
+
if (!published || typeof published !== 'object' || Array.isArray(published))
|
|
58
|
+
return kindOf(published) === kindOf(got) || published === null;
|
|
59
|
+
if (!got || typeof got !== 'object' || Array.isArray(got))
|
|
60
|
+
return false;
|
|
61
|
+
const p = published, g = got;
|
|
62
|
+
for (const key of ['type', 'object'])
|
|
63
|
+
if (typeof p[key] === 'string' && p[key] !== g[key])
|
|
64
|
+
return false;
|
|
65
|
+
return Object.entries(p).every(([k, v]) => k in g && (v === null || kindOf(v) === kindOf(g[k]) || (kindOf(v) === 'object' && holds(v, g[k]))));
|
|
66
|
+
}
|
|
67
|
+
/** A published stream printed as text (a page's sample of a stream, its middle elided): each frame it shows has a frame
|
|
68
|
+
* of the answer holding it, the answer then standing for the sample; else the answer, unchanged, which the literal
|
|
69
|
+
* comparison reports. */
|
|
70
|
+
function streamSample(example, answer) {
|
|
71
|
+
const published = framesOf(example);
|
|
72
|
+
if (published.length < 2)
|
|
73
|
+
return answer;
|
|
74
|
+
const frames = framesOf(answer);
|
|
75
|
+
const held = published.every((p) => frames.some((f) => (!p.event || p.event === f.event) && holds(p.data, f.data)));
|
|
76
|
+
return held ? example : answer;
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-standard",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.150",
|
|
4
4
|
"description": "The Protocol 3 standard for Volter twin packs: the criteria a pack is graded by, the grader, the gate a pack repository and the twin catalog run on every change and submission, and the spec derivation the grade checks against. Pin a version: every place a pack is judged judges by that standard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"yaml": "2.9.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@volter/world-core": "3.0.
|
|
78
|
+
"@volter/world-core": "3.0.157",
|
|
79
79
|
"@volter/world-runtime": "*",
|
|
80
80
|
"playwright": "^1.63.0"
|
|
81
81
|
},
|
package/src/assessment.ts
CHANGED
|
@@ -93,5 +93,6 @@ export async function assessPack(packDir: string, options: AssessmentOptions = {
|
|
|
93
93
|
try { consumer = options.consumer ? await runConsumerWorkflow(packDir, options.consumer) : { measured: false, passed: false, failures: ['Installed customer workflow was not prepared'] }; }
|
|
94
94
|
catch (error) { consumer = { measured: false, passed: false, failures: [(error as Error).message] }; }
|
|
95
95
|
const ready = gates.length > 0 && gates.every((gate) => !gate.failures.length) && !quick.failures.length && replayEqual && conform !== null && conform.length > 0 && conform.every((result) => !result.failures.length) && consumer.measured && consumer.passed && (!options.browser || !httpOnly || browser.measured === true && (browser.failures as unknown[]).length === 0 && (browser.replay as { equal: boolean }).equal);
|
|
96
|
-
|
|
96
|
+
// the artifact assessed, by the integrity its prepared consumer verified the installed candidate against (consumer.ts)
|
|
97
|
+
return { schemaVersion: 1, vendor, package: manifest.name, version: manifest.version, ...((consumer as { integrity?: string }).integrity ? { integrity: (consumer as { integrity?: string }).integrity } : {}), ready, gates: gates.map((gate) => ({ ...gate, unit: gate.unit === packDir ? vendor : `${vendor}/${gate.unit.slice(packDir.length + 1)}` })), quick, browser, conformance: conform, consumer };
|
|
97
98
|
}
|
package/src/cli.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
// the pack served and read as an application and the
|
|
14
14
|
// catalog meet it (conformance.ts): a checkout, or a
|
|
15
15
|
// published version as the catalog admits it; or exit 1
|
|
16
|
+
// twin-standard walk <vendor> [--write] [--report <file>] the journey test, from the pack repository's root: the
|
|
17
|
+
// life walked on a fresh World (twice, the same), its
|
|
18
|
+
// coverage and the published examples (tools/score-pack.ts)
|
|
19
|
+
// twin-standard examples <vendor> [--sweep] the vendor's published examples replayed (tools/vendor-examples.ts)
|
|
20
|
+
// twin-standard coverage <vendor> [--write | --out <file>] the code and state logic the walks reach (tools/life-coverage.ts)
|
|
21
|
+
// twin-standard judge-brief <vendor> --authors <names> what an independent judge reads and writes (tools/judge-brief.ts)
|
|
16
22
|
// twin-standard --version the package's version and each standard's
|
|
17
23
|
import { existsSync, readFileSync } from 'node:fs';
|
|
18
24
|
import { relative, resolve } from 'node:path';
|
|
@@ -24,7 +30,7 @@ import { gatePublished } from './published.ts';
|
|
|
24
30
|
|
|
25
31
|
const [command, ...args] = process.argv.slice(2);
|
|
26
32
|
const usage = (): never => {
|
|
27
|
-
console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | read-page <url> [--raw] | check-sources <unit> [--recheck] | conform <pack dir> | <name>@<version> [--registry <url>] | create <dir> --init <vendor> --package <@scope/name> | create <unit> --index [--building] | facts <pack dir> [--out <file>] | prepare-consumer <pack dir> --app <new dir> --cli <volter executable> --integrity <sha512> --out <json> [--candidate <tgz>] | walk-consumer <pack dir> --prepared-consumer <json> [--out <file>] | assess <pack dir> --prepared-consumer <json> [--browser] [--out <file>] | --version');
|
|
33
|
+
console.error('usage: twin-standard gate <repo> [units…] | gate-published <name>@<version>… [--registry <url>] | grade <unit>… [--static] [--form] | derive <unit> [--out <dir>] | read-page <url> [--raw] | check-sources <unit> [--recheck] | conform <pack dir> | <name>@<version> [--registry <url>] | create <dir> --init <vendor> --package <@scope/name> | create <unit> --index [--building] | facts <pack dir> [--out <file>] | prepare-consumer <pack dir> --app <new dir> --cli <volter executable> --integrity <sha512> --out <json> [--candidate <tgz>] | walk-consumer <pack dir> --prepared-consumer <json> [--out <file>] | assess <pack dir> --prepared-consumer <json> [--browser] [--out <file>] | walk <vendor> [--write] [--report <file>] | examples <vendor> [--sweep] | coverage <vendor> [--write | --out <file>] | judge-brief <vendor> --authors <names> | --version');
|
|
28
34
|
process.exit(2);
|
|
29
35
|
};
|
|
30
36
|
|
|
@@ -133,6 +139,13 @@ if (command === '--version') {
|
|
|
133
139
|
mkdirSync(dirname(out), { recursive: true });
|
|
134
140
|
writeFileSync(out, renderPackFacts(facts));
|
|
135
141
|
console.log(out);
|
|
142
|
+
} else if (command === 'walk' || command === 'examples' || command === 'coverage' || command === 'judge-brief') {
|
|
143
|
+
// the contributor's journey tools, run from the pack repository's root (tools/packs-root.ts), each a script over
|
|
144
|
+
// process.argv whose first argument is the vendor
|
|
145
|
+
if (!args[0] || args[0].startsWith('-')) usage();
|
|
146
|
+
const tool = { walk: 'score-pack', examples: 'vendor-examples', coverage: 'life-coverage', 'judge-brief': 'judge-brief' }[command];
|
|
147
|
+
process.argv = [process.argv[0]!, process.argv[1]!, ...args];
|
|
148
|
+
await import((await import('./tools/packs-root.ts')).toolPath(tool));
|
|
136
149
|
} else if (command === 'derive') {
|
|
137
150
|
if (!args[0]) usage();
|
|
138
151
|
// the derivation is a script over process.argv: it reads the unit from its first argument
|
|
@@ -157,7 +170,7 @@ if (command === '--version') {
|
|
|
157
170
|
const published = target.endsWith('.tgz') || (!existsSync(target) && /^@?[^@]+@[^@]+$/.test(target));
|
|
158
171
|
const results = published
|
|
159
172
|
? await (await import('./published.ts')).conformPublished(target, at >= 0 ? { registry: args[at + 1]! } : {})
|
|
160
|
-
: await (await import('./conformance.ts')).conformance(target);
|
|
173
|
+
: await (await import('./conformance.ts')).conformance(target, { onCheck: (id) => console.error(` … ${id} (${new Date().toISOString().slice(11, 19)})`) });
|
|
161
174
|
for (const r of results) {
|
|
162
175
|
console.log(`${r.failures.length ? '✗' : '✓'} ${r.id.padEnd(15)} ${r.asks}${r.note ? ` (${r.note})` : ''}`);
|
|
163
176
|
for (const f of r.failures) console.log(` ${f}`);
|
package/src/clients/harness.ts
CHANGED
|
@@ -106,13 +106,15 @@ export function servedHttpFetch(port: number): typeof fetch {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/** The context for a pack reached at `port` of this machine (the World's proxy in front of it). */
|
|
109
|
-
|
|
109
|
+
/** `raw`: a raw protocol's served port (SMTP's listener), which a connection reaches directly, as a World passes a raw TCP
|
|
110
|
+
* connection through untouched; the proxy speaks HTTP only. */
|
|
111
|
+
export function clientContext(port: number, host: string, env: Record<string, string>, scenarioPath: string, raw?: number): ClientContext {
|
|
110
112
|
const agent = new (class extends HttpsAgent {
|
|
111
113
|
createConnection(): Socket { return connect(port, '127.0.0.1'); }
|
|
112
114
|
})({ keepAlive: false });
|
|
113
115
|
return {
|
|
114
116
|
host, env, fetch: servedHttpFetch(port), agent,
|
|
115
|
-
connect: () => connect(port, '127.0.0.1'),
|
|
117
|
+
connect: () => connect(raw ?? port, '127.0.0.1'),
|
|
116
118
|
scenario: (doc) => writeFileSync(scenarioPath, JSON.stringify(doc)),
|
|
117
119
|
};
|
|
118
120
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// jira.js 5.4.0, unmodified (Twin's on-call agent's client: cookbook/on-call-agent/agent.mjs), as that agent constructs
|
|
2
|
+
// it: a Version3Client on the site's URL with Basic authentication by an account's email and API token, its axios
|
|
3
|
+
// requests through the World's routed HTTPS agent. The site, its admin and the token are made at the pack's doors, as a
|
|
4
|
+
// person makes them at Atlassian's sign-up and account console.
|
|
5
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
6
|
+
|
|
7
|
+
const README = 'https://www.npmjs.com/package/jira.js/v/5.4.0';
|
|
8
|
+
const CREATE = 'https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post';
|
|
9
|
+
const ADF = 'https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/';
|
|
10
|
+
|
|
11
|
+
/** A site named `slug`, its admin, the admin's API token and a project OPS led by them; the client on the site's URL. */
|
|
12
|
+
async function site(c: ClientContext, slug: string, apiToken?: string) {
|
|
13
|
+
const door = async (path: string, body: unknown) => {
|
|
14
|
+
const answer = await c.fetch(`https://${c.host}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
15
|
+
if (answer.status !== 201) throw new Error(`${path} answered ${answer.status}`);
|
|
16
|
+
return (await answer.json()) as Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
const email = `admin@${slug}.example`;
|
|
19
|
+
const made = await door('/_twin/sites', { name: slug, admin: { name: 'Admin', email, password: 'sdk-case-2026' } });
|
|
20
|
+
const token = apiToken ?? (await door('/_twin/api-token', { site: slug, email, password: 'sdk-case-2026' })).apiToken!;
|
|
21
|
+
const { Version3Client } = await import('jira.js');
|
|
22
|
+
const jira = new Version3Client({ host: `https://${slug}.atlassian.net`, authentication: { basic: { email, apiToken: token } }, baseRequestConfig: { httpsAgent: c.agent } });
|
|
23
|
+
return { jira, admin: made.admin! };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const cases: ClientCase[] = [
|
|
27
|
+
{
|
|
28
|
+
id: 'create-issue-string-description',
|
|
29
|
+
asks: "an issue filed as the on-call agent files it, its description a plain string the client sends as an ADF document, answers its id, key and self, and reads back with that text",
|
|
30
|
+
source: `${CREATE} "Creates an issue or, where the option to create subtasks is enabled in Jira, a subtask."`,
|
|
31
|
+
async run(c) {
|
|
32
|
+
const { jira, admin } = await site(c, 'sdk-file');
|
|
33
|
+
await jira.projects.createProject({ key: 'OPS', name: 'Operations', projectTypeKey: 'software', leadAccountId: admin });
|
|
34
|
+
const filed = await jira.issues.createIssue({ fields: { project: { key: 'OPS' }, issuetype: { name: 'Task' }, summary: 'acme/web#1: checkout fails', description: 'SEVERITY: high — checkout fails' } });
|
|
35
|
+
const failures: string[] = [];
|
|
36
|
+
if (!/^OPS-\d+$/.test(String(filed.key)) || !filed.id || !String(filed.self).includes(`/rest/api/3/issue/${filed.id}`)) failures.push(`createIssue answered ${JSON.stringify(filed)}`);
|
|
37
|
+
const read = await jira.issues.getIssue({ issueIdOrKey: String(filed.key), fields: ['summary', 'description'] });
|
|
38
|
+
const text = JSON.stringify(read.fields.description ?? null);
|
|
39
|
+
if (read.fields.summary !== 'acme/web#1: checkout fails') failures.push(`the issue reads back summary ${JSON.stringify(read.fields.summary)}`);
|
|
40
|
+
if (!text.includes('"type":"doc"') || !text.includes('SEVERITY: high — checkout fails')) failures.push(`the description reads back ${text.slice(0, 160)}, not the ADF document of the text sent`);
|
|
41
|
+
return failures;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'adf-document-shape',
|
|
46
|
+
asks: 'a description read back is an ADF document: type doc, version 1, its paragraph holding the text node',
|
|
47
|
+
source: `${ADF} "Atlassian Document Format (ADF) represents rich text stored in Atlassian products."`,
|
|
48
|
+
async run(c) {
|
|
49
|
+
const { jira, admin } = await site(c, 'sdk-adf');
|
|
50
|
+
await jira.projects.createProject({ key: 'OPS', name: 'Operations', projectTypeKey: 'software', leadAccountId: admin });
|
|
51
|
+
const filed = await jira.issues.createIssue({ fields: { project: { key: 'OPS' }, issuetype: { name: 'Task' }, summary: 'shape', description: 'one line' } });
|
|
52
|
+
const doc = (await jira.issues.getIssue({ issueIdOrKey: String(filed.key), fields: ['description'] })).fields.description as { type?: string; version?: number; content?: Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }> } | undefined;
|
|
53
|
+
return doc?.type === 'doc' && doc.version === 1 && doc.content?.[0]?.type === 'paragraph' && doc.content[0].content?.[0]?.type === 'text' && doc.content[0].content[0].text === 'one line'
|
|
54
|
+
? [] : [`the description is ${JSON.stringify(doc).slice(0, 200)}`];
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'wrong-token-http-exception',
|
|
59
|
+
asks: 'a request with an API token the account never made is refused, and the client throws its HttpException carrying the status',
|
|
60
|
+
source: `${README} "\`HttpException\`: Server responded with an error (includes parsed error details)"`,
|
|
61
|
+
async run(c) {
|
|
62
|
+
const { HttpException } = await import('jira.js');
|
|
63
|
+
const { jira } = await site(c, 'sdk-refused', 'ATATT-not-a-token-this-account-made');
|
|
64
|
+
try {
|
|
65
|
+
await jira.projects.searchProjects({ maxResults: 50 });
|
|
66
|
+
return ['the made-up token was accepted'];
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!(error instanceof HttpException)) return [`the refusal is not an HttpException: ${String(error).slice(0, 160)}`];
|
|
69
|
+
return error.status === 401 ? [] : [`the refusal's status is ${error.status}, not 401`];
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Polar's TypeScript SDK, unmodified (@polar-sh/sdk 0.49, Twin's billing app: apps/billing/src/polar.ts), with the
|
|
2
|
+
// Organization Access Token the World's credential door issues. The SDK validates every answer against its own models,
|
|
3
|
+
// so a case that reads one back has held the answer's shape too. It sends through the global fetch, so the World's
|
|
4
|
+
// routed fetch is set before it is made.
|
|
5
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
6
|
+
|
|
7
|
+
const INGEST = 'https://polar.sh/docs/api-reference/events/ingest';
|
|
8
|
+
const STATE = 'https://polar.sh/docs/api-reference/customers/state-external';
|
|
9
|
+
const PRODUCTS = 'https://polar.sh/docs/api-reference/products/create';
|
|
10
|
+
|
|
11
|
+
async function client(c: ClientContext) {
|
|
12
|
+
globalThis.fetch = c.fetch;
|
|
13
|
+
const { Polar } = await import('@polar-sh/sdk');
|
|
14
|
+
return new Polar({ accessToken: c.env.POLAR_ACCESS_TOKEN, server: 'production' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const cases: ClientCase[] = [
|
|
18
|
+
{
|
|
19
|
+
id: 'catalog-bootstrap',
|
|
20
|
+
asks: "the billing app's boot: a meter made by its filter and aggregation, and a monthly product priced fixed and per metered unit with a trial, each read back by name in the SDK's own models",
|
|
21
|
+
source: `${PRODUCTS} "plus any number of metered prices"`,
|
|
22
|
+
async run(c) {
|
|
23
|
+
const polar = await client(c);
|
|
24
|
+
const meter = await polar.meters.create({ name: 'case_world_hours', filter: { conjunction: 'and', clauses: [{ property: 'name', operator: 'eq', value: 'world.hour' }] }, aggregation: { func: 'sum', property: 'units' } });
|
|
25
|
+
const product = await polar.products.create({
|
|
26
|
+
name: 'Case Team', recurringInterval: 'month', description: 'A case plan',
|
|
27
|
+
prices: [{ amountType: 'fixed', priceAmount: 4900, priceCurrency: 'usd' }, { amountType: 'metered_unit', unitAmount: '2', meterId: meter.id }],
|
|
28
|
+
trialInterval: 'day', trialIntervalCount: 14,
|
|
29
|
+
});
|
|
30
|
+
const failures: string[] = [];
|
|
31
|
+
const meters = await polar.meters.list({ limit: 100 });
|
|
32
|
+
if (!meters.result.items.some((m) => m.id === meter.id && m.name === 'case_world_hours')) failures.push('the made meter is not listed by its name');
|
|
33
|
+
const products = await polar.products.list({ limit: 100 });
|
|
34
|
+
const listed = products.result.items.find((p) => p.id === product.id);
|
|
35
|
+
if (!listed || listed.name !== 'Case Team' || listed.isArchived) failures.push(`the made product lists as ${JSON.stringify(listed && { name: listed.name, isArchived: listed.isArchived })}`);
|
|
36
|
+
if (product.prices.length !== 2) failures.push(`the product holds ${product.prices.length} prices, not its fixed and metered two`);
|
|
37
|
+
if (product.trialInterval !== 'day' || product.trialIntervalCount !== 14) failures.push(`the product's trial is ${String(product.trialIntervalCount)} ${String(product.trialInterval)}`);
|
|
38
|
+
return failures;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'unknown-customer-state-not-found',
|
|
43
|
+
asks: "an organization with no customer reads as the SDK's ResourceNotFound (404), which the billing app takes as Free",
|
|
44
|
+
source: `${STATE} "Customer not found."`,
|
|
45
|
+
async run(c) {
|
|
46
|
+
const polar = await client(c);
|
|
47
|
+
const { ResourceNotFound } = await import('@polar-sh/sdk/models/errors/resourcenotfound.js');
|
|
48
|
+
try {
|
|
49
|
+
await polar.customers.getStateExternal({ externalId: 'org_never_a_customer' });
|
|
50
|
+
return ['an unknown external id read as a customer'];
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return error instanceof ResourceNotFound ? [] : [`the refusal is not ResourceNotFound: ${String(error).slice(0, 160)}`];
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'ingest-deduplicates-by-external-id',
|
|
58
|
+
asks: 'an event sent again with the same external_id (a tick retried after a timeout) is skipped as a duplicate, not counted twice',
|
|
59
|
+
source: `${INGEST} "Number of duplicate events skipped."`,
|
|
60
|
+
async run(c) {
|
|
61
|
+
const polar = await client(c);
|
|
62
|
+
const customer = await polar.customers.create({ email: 'ops@case-ingest.example', externalId: 'org_case_ingest' });
|
|
63
|
+
const event = { name: 'world.hour', externalCustomerId: 'org_case_ingest', externalId: 'tick-2026-01-14T10', metadata: { units: 1 } };
|
|
64
|
+
const first = await polar.events.ingest({ events: [event] });
|
|
65
|
+
const again = await polar.events.ingest({ events: [event] });
|
|
66
|
+
const failures: string[] = [];
|
|
67
|
+
if (first.inserted !== 1 || first.duplicates !== 0) failures.push(`the first send answered ${JSON.stringify(first)}`);
|
|
68
|
+
if (again.inserted !== 0 || again.duplicates !== 1) failures.push(`the retried send answered ${JSON.stringify(again)}, not one duplicate skipped`);
|
|
69
|
+
if (!customer.id) failures.push('the customer was made with no id');
|
|
70
|
+
return failures;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|