@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/src/clients/resend.ts
CHANGED
|
@@ -56,4 +56,36 @@ export const cases: ClientCase[] = [
|
|
|
56
56
|
return answer.data === null && answer.error?.statusCode === 422 && answer.error?.name === 'missing_required_field' ? [] : [`missing sender returned ${JSON.stringify(answer)}`];
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
id: 'unverified-domain-validation-error',
|
|
61
|
+
asks: 'a send from a domain the team never verified comes back as the SDK error validation_error (403), with no data',
|
|
62
|
+
source: `${ERRORS} "To send emails to other recipients, please verify a domain at resend.com/domains, and change the \`from\` address to an email using this domain."`,
|
|
63
|
+
async run(c) {
|
|
64
|
+
const resend = client(c);
|
|
65
|
+
const sent = await resend.emails.send({ from: 'Someone <someone@never-verified.example>', to: ['client@example.org'], subject: 'Not from a verified domain', text: 'Refused.' });
|
|
66
|
+
const failures: string[] = [];
|
|
67
|
+
if (sent.data !== null) failures.push(`the send answered data ${JSON.stringify(sent.data)}`);
|
|
68
|
+
const error = sent.error as { name?: string; statusCode?: number | null } | null;
|
|
69
|
+
if (error?.name !== 'validation_error') failures.push(`the error is ${JSON.stringify(error)}, not validation_error`);
|
|
70
|
+
else if (error.statusCode !== 403) failures.push(`the validation_error's status is ${String(error.statusCode)}, not 403`);
|
|
71
|
+
return failures;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'scheduled-send-waits',
|
|
76
|
+
asks: "a send scheduled for later (the SDK's scheduledAt, sent as scheduled_at) waits: it reads back scheduled, at its time",
|
|
77
|
+
source: `${SEND} "Schedule email to be sent later."`,
|
|
78
|
+
async run(c) {
|
|
79
|
+
const resend = client(c);
|
|
80
|
+
const at = '2030-01-01T08:00:00.000Z';
|
|
81
|
+
const sent = await resend.emails.send({ from: 'Acme <onboarding@resend.dev>', to: ['owner@world.test'], subject: 'Later', text: 'In good time.', scheduledAt: at });
|
|
82
|
+
if (sent.error || !sent.data?.id) return [`the send answered ${JSON.stringify(sent).slice(0, 200)}`];
|
|
83
|
+
const read = await resend.emails.get(sent.data.id);
|
|
84
|
+
const email = read.data as { last_event?: string; scheduled_at?: string | null } | null;
|
|
85
|
+
const failures: string[] = [];
|
|
86
|
+
if (email?.last_event !== 'scheduled') failures.push(`the scheduled email's last_event is ${String(email?.last_event)}`);
|
|
87
|
+
if (email?.scheduled_at === undefined || email.scheduled_at === null || Date.parse(email.scheduled_at) !== Date.parse(at)) failures.push(`it reads back scheduled_at ${String(email?.scheduled_at)}`);
|
|
88
|
+
return failures;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
59
91
|
];
|
package/src/clients/run.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { extname } from 'node:path';
|
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { clientContext, worldProxy, type ClientCase } from './harness.ts';
|
|
9
9
|
|
|
10
|
-
const job = JSON.parse(readFileSync(process.argv[2]!, 'utf8')) as { vendor: string; port: number; host: string; env: Record<string, string>; scenarioPath: string; packFacts: string };
|
|
10
|
+
const job = JSON.parse(readFileSync(process.argv[2]!, 'utf8')) as { vendor: string; port: number; host: string; env: Record<string, string>; scenarioPath: string; packFacts: string; raw?: boolean };
|
|
11
11
|
const { cases } = (await import(`./${job.vendor}${extname(fileURLToPath(import.meta.url))}`)) as { cases: ClientCase[] };
|
|
12
12
|
if (!Array.isArray(cases) || cases.length === 0) throw new Error(`${job.vendor} client module declares no cases; there is no SDK execution evidence`);
|
|
13
13
|
// every connection a case makes goes through the World's proxy, as an application's does in a World
|
|
@@ -19,7 +19,7 @@ require('@volter/world-core/pack-facts').extendPackFacts([job.packFacts]);
|
|
|
19
19
|
const facts = JSON.parse(readFileSync(job.packFacts, 'utf8')).packs[job.vendor];
|
|
20
20
|
const keys: string[] = [...new Set<string>((facts.hosts ?? []).map((rule: { key?: string }) => rule.key ?? job.vendor))];
|
|
21
21
|
require('@volter/world-core/inject').install(Object.fromEntries(keys.map((key) => [key, `http://127.0.0.1:${proxy}`])));
|
|
22
|
-
const context = clientContext(proxy, job.host, job.env, job.scenarioPath);
|
|
22
|
+
const context = clientContext(proxy, job.host, job.env, job.scenarioPath, job.raw ? job.port : undefined);
|
|
23
23
|
for (const c of cases) {
|
|
24
24
|
const failures = await Promise.race([
|
|
25
25
|
c.run(context).catch((e: Error) => [`the case threw: ${e.message}`]),
|
package/src/clients/sentry.ts
CHANGED
|
@@ -22,7 +22,6 @@ async function sdkOf(name: string): Promise<any> {
|
|
|
22
22
|
const pins = [
|
|
23
23
|
['rallly', '@sentry/node', '10.38.0'],
|
|
24
24
|
['twenty', 'sentry-node-twenty', '10.73.0'],
|
|
25
|
-
['postiz', 'sentry-node-postiz', '10.56.0'],
|
|
26
25
|
['cal-api', 'sentry-node-cal', '9.46.0'],
|
|
27
26
|
['cal-web', 'sentry-node-cal-web', '10.33.0'],
|
|
28
27
|
] as const;
|
|
@@ -67,7 +66,7 @@ export const cases: ClientCase[] = pins.map(([application, name, version]) => ({
|
|
|
67
66
|
},
|
|
68
67
|
}));
|
|
69
68
|
|
|
70
|
-
for (const [name, version] of [['@sentry/browser', '10.38.0'], ['sentry-browser-twenty', '10.59.0']
|
|
69
|
+
for (const [name, version] of [['@sentry/browser', '10.38.0'], ['sentry-browser-twenty', '10.59.0']] as const) cases.push({
|
|
71
70
|
id: 'browser-' + version + '-dsn-query-auth-and-session-envelope',
|
|
72
71
|
asks: 'the browser fetch transport uses DSN query authentication and flushes event and session envelopes',
|
|
73
72
|
source: `https://unpkg.com/@sentry/core@${version}/build/esm/api.js "sentry_key: publicKey"`,
|
|
@@ -133,26 +132,3 @@ cases.push({
|
|
|
133
132
|
});
|
|
134
133
|
|
|
135
134
|
|
|
136
|
-
cases.push({
|
|
137
|
-
id: 'postiz-feedback-attribution',
|
|
138
|
-
asks: 'captureFeedback preserves the widget message, explicit name/email, associated event and scoped user',
|
|
139
|
-
source: 'https://unpkg.com/@sentry/core@10.56.0/build/esm/feedback.js "associated_event_id: associatedEventId"',
|
|
140
|
-
async run(c) {
|
|
141
|
-
const sdk = await sdkOf('sentry-browser-postiz');
|
|
142
|
-
const client = new sdk.BrowserClient({ dsn: c.env.SENTRY_DSN, integrations: [], stackParser: sdk.defaultStackParser,
|
|
143
|
-
transport: (options: any) => sdk.createTransport(options, async (request: { body: string | Uint8Array }) => {
|
|
144
|
-
const answer = await c.fetch(options.url, { method: 'POST', body: request.body as BodyInit });
|
|
145
|
-
return { statusCode: answer.status, headers: {} };
|
|
146
|
-
}) });
|
|
147
|
-
let id = '';
|
|
148
|
-
sdk.withScope((scope: any) => {
|
|
149
|
-
scope.setClient(client); scope.setUser({ id: 'feedback-author' });
|
|
150
|
-
id = sdk.captureFeedback({ message: 'The scheduling button is hard to find', name: 'Priya', email: 'priya@harbourline.test', associatedEventId: '00000000000000000000000000000002' }, {}, scope);
|
|
151
|
-
});
|
|
152
|
-
const drained = await client.flush(10_000);
|
|
153
|
-
const event = await read(c, id, await token(c));
|
|
154
|
-
await client.close(10_000);
|
|
155
|
-
const feedback = event.contexts?.feedback;
|
|
156
|
-
return drained && event.type === 'feedback' && event.user?.id === 'feedback-author' && feedback?.name === 'Priya' && feedback?.contact_email === 'priya@harbourline.test' && feedback?.associated_event_id === '00000000000000000000000000000002' ? [] : ['feedback attribution or associated event was lost'];
|
|
157
|
-
},
|
|
158
|
-
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Nodemailer 10, unmodified, as Dub, Rallly, Cal.com, Twenty and LibreChat send their mail: its SMTP transport, plain
|
|
2
|
+
// on the relay's port, given the World's TCP connection through its getSocket hook (SMTP is a raw protocol: the case
|
|
3
|
+
// names a stand-in relay host the connection never resolves). The relay's policy at boot asks no authentication.
|
|
4
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
5
|
+
|
|
6
|
+
const RFC5321 = 'https://www.rfc-editor.org/rfc/rfc5321';
|
|
7
|
+
|
|
8
|
+
async function transport(c: ClientContext) {
|
|
9
|
+
const nodemailer = (await import('nodemailer')).default;
|
|
10
|
+
return nodemailer.createTransport({
|
|
11
|
+
host: c.host, port: 25, secure: false, ignoreTLS: true,
|
|
12
|
+
getSocket: (_options: unknown, callback: (error: Error | null, socket?: { connection: unknown }) => void) => callback(null, { connection: c.connect() }),
|
|
13
|
+
} as never);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const cases: ClientCase[] = [
|
|
17
|
+
{
|
|
18
|
+
id: 'verify-greets',
|
|
19
|
+
asks: "nodemailer's verify opens the session, greets with EHLO and quits: the relay answers it as an SMTP server",
|
|
20
|
+
source: `${RFC5321} "These commands are used to identify the SMTP client to the SMTP server."`,
|
|
21
|
+
async run(c) {
|
|
22
|
+
const t = await transport(c);
|
|
23
|
+
try {
|
|
24
|
+
return (await t.verify()) === true ? [] : ['verify resolved without true'];
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return [`verify failed: ${String(error).slice(0, 200)}`];
|
|
27
|
+
} finally { t.close(); }
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'transaction-to-two-recipients',
|
|
32
|
+
asks: 'a message with text and HTML to two recipients runs MAIL, a RCPT for each, then DATA: both accepted, none rejected, the data answered 250',
|
|
33
|
+
source: `${RFC5321} "Once started, a mail transaction consists of a transaction beginning command, one or more RCPT commands, and a DATA command, in that order."`,
|
|
34
|
+
async run(c) {
|
|
35
|
+
const t = await transport(c);
|
|
36
|
+
try {
|
|
37
|
+
const info = await t.sendMail({ from: 'Polls <polls@case.example>', to: ['an@case.example', 'bo@case.example'], subject: 'Pick a date', text: 'Please pick a date.', html: '<p>Please pick a date.</p>' }) as { accepted: string[]; rejected: string[]; response: string; messageId: string };
|
|
38
|
+
const failures: string[] = [];
|
|
39
|
+
if (JSON.stringify([...info.accepted].sort()) !== JSON.stringify(['an@case.example', 'bo@case.example'])) failures.push(`accepted ${JSON.stringify(info.accepted)}`);
|
|
40
|
+
if (info.rejected.length) failures.push(`rejected ${JSON.stringify(info.rejected)}`);
|
|
41
|
+
if (!info.response.startsWith('250')) failures.push(`the data was answered ${info.response}`);
|
|
42
|
+
if (!info.messageId) failures.push('no message id');
|
|
43
|
+
return failures;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return [`the send failed: ${String(error).slice(0, 200)}`];
|
|
46
|
+
} finally { t.close(); }
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
];
|
package/src/clients/stripe.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Stripe's own client (stripe-node), driving the stripe pack as its applications do (Cal.com, Dub,
|
|
1
|
+
// Stripe's own client (stripe-node), driving the stripe pack as its applications do (Cal.com, Dub, Rallly and
|
|
2
2
|
// Twenty call it): an endpoint registered through the API and the event Stripe sends it verified by constructEvent, as an
|
|
3
3
|
// application's webhook handler verifies it; a customer list read page by page by the client's auto-pagination; a retry
|
|
4
4
|
// sent with its idempotency key; the error a missing object raises; and the customer portal a billing page opens.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// @volter/identity 0.4.1, as Volter Harness uses it; discovery and token verification are also the APIs Open Autonomy
|
|
2
|
-
// uses in 0.1.0. These cases are owned by conformance and are not run during pack authoring.
|
|
2
|
+
// uses in 0.1.0. These cases are owned by conformance and are not run during pack authoring. They import nothing of the
|
|
3
|
+
// kernel: the runner is Node's own type stripping, which the kernel's parameter properties do not pass.
|
|
3
4
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
7
8
|
import { resolveIdentity, verifyIdentityToken, tokenFor, readSharedSignIn, SHARED_CLIENTS } from '@volter/identity';
|
|
8
9
|
import type { ClientCase, ClientContext } from './harness.ts';
|
|
9
10
|
|
|
@@ -36,7 +37,7 @@ async function setup(c: ClientContext, name: string, shared = false): Promise<{
|
|
|
36
37
|
await send(c, `/admin/clients/${registered.client_id}/resources`, { resource: 'https://teams.sdk.example' }, operator);
|
|
37
38
|
}
|
|
38
39
|
const verifier = 'official-sdk-case-verifier-with-enough-entropy-for-s256';
|
|
39
|
-
const query = new URLSearchParams({ client_id: registered.client_id, redirect_uri: callback, response_type: 'code', scope: 'openid profile email offline_access', code_challenge:
|
|
40
|
+
const query = new URLSearchParams({ client_id: registered.client_id, redirect_uri: callback, response_type: 'code', scope: 'openid profile email offline_access', code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' });
|
|
40
41
|
const authorized = await send(c, `/api/auth/oauth2/authorize?${query}`, undefined, { cookie });
|
|
41
42
|
const code = new URL(authorized.headers.get('location')!).searchParams.get('code')!;
|
|
42
43
|
const form = new URLSearchParams({ client_id: registered.client_id, redirect_uri: callback, grant_type: 'authorization_code', code, code_verifier: verifier, ...(registered.client_secret ? { client_secret: registered.client_secret } : {}) });
|
package/src/clients/x.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// twitter-api-v2 1.29, unmodified (one of the SDKs the x descriptor's adoption names),
|
|
2
|
+
// with an OAuth 2.0 user access token made at the pack's doors for an account made there, as a person's sign-in at
|
|
3
|
+
// x.com and an App's authorize leg make them. The client sends through node's https with the agent it is given, so the
|
|
4
|
+
// World's routed agent is its httpAgent.
|
|
5
|
+
import type { ClientCase, ClientContext } from './harness.ts';
|
|
6
|
+
|
|
7
|
+
const CREATE = 'https://docs.x.com/x-api/posts/create-post';
|
|
8
|
+
const ME = 'https://docs.x.com/x-api/users/get-my-user';
|
|
9
|
+
const README = 'https://www.npmjs.com/package/twitter-api-v2/v/1.29.1';
|
|
10
|
+
|
|
11
|
+
/** An account named `username` and an access token for it with `scopes`; the client on that token. */
|
|
12
|
+
async function signedIn(c: ClientContext, username: string, scopes: string[], token = `case-${username}-token`) {
|
|
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 >= 300) throw new Error(`${path} answered ${answer.status}`);
|
|
16
|
+
return ((await answer.json()) as { data: Record<string, string> }).data;
|
|
17
|
+
};
|
|
18
|
+
const account = await door('/_twin/accounts', { username, name: 'Case Account' });
|
|
19
|
+
await door('/_twin/tokens', { token, account_id: account.id, scopes });
|
|
20
|
+
const { TwitterApi } = await import('twitter-api-v2');
|
|
21
|
+
return { client: new TwitterApi(token, { httpAgent: c.agent }), account };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const cases: ClientCase[] = [
|
|
25
|
+
{
|
|
26
|
+
id: 'post-and-read-back',
|
|
27
|
+
asks: "a post made through the SDK answers its id and text, and reads back by id with that text, its author the signed-in account",
|
|
28
|
+
source: `${CREATE} "Create Posts"`,
|
|
29
|
+
async run(c) {
|
|
30
|
+
const { client, account } = await signedIn(c, 'case_poster', ['tweet.read', 'tweet.write', 'users.read']);
|
|
31
|
+
const made = await client.v2.tweet('Shipping the case today');
|
|
32
|
+
const failures: string[] = [];
|
|
33
|
+
if (!made.data?.id || made.data.text !== 'Shipping the case today') failures.push(`tweet answered ${JSON.stringify(made)}`);
|
|
34
|
+
const read = await client.v2.singleTweet(made.data.id, { 'tweet.fields': ['author_id'] });
|
|
35
|
+
if (read.data?.text !== 'Shipping the case today') failures.push(`the post reads back text ${JSON.stringify(read.data?.text)}`);
|
|
36
|
+
if (read.data?.author_id !== account.id) failures.push(`the post reads back author ${String(read.data?.author_id)}, not ${account.id}`);
|
|
37
|
+
return failures;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'me-is-the-signed-in-account',
|
|
42
|
+
asks: "v2.me answers the account the token was issued to, by its id and username (an app's account check after sign-in)",
|
|
43
|
+
source: `${ME} "Get Users Me"`,
|
|
44
|
+
async run(c) {
|
|
45
|
+
const { client, account } = await signedIn(c, 'case_me', ['tweet.read', 'users.read']);
|
|
46
|
+
const me = await client.v2.me();
|
|
47
|
+
return me.data?.id === account.id && me.data.username === 'case_me' ? [] : [`me answered ${JSON.stringify(me)}`];
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'unknown-token-api-response-error',
|
|
52
|
+
asks: 'a token X never issued is refused, and the client throws its ApiResponseError carrying the 401',
|
|
53
|
+
source: `${README} "Typed errors, meaningful error messages, error enumerations for both v1.1 and v2"`,
|
|
54
|
+
async run(c) {
|
|
55
|
+
const { TwitterApi, ApiResponseError } = await import('twitter-api-v2');
|
|
56
|
+
const client = new TwitterApi('case-never-issued-token', { httpAgent: c.agent });
|
|
57
|
+
try {
|
|
58
|
+
await client.v2.me();
|
|
59
|
+
return ['the never-issued token was accepted'];
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof ApiResponseError)) return [`the refusal is not an ApiResponseError: ${String(error).slice(0, 160)}`];
|
|
62
|
+
return error.code === 401 ? [] : [`the refusal's code is ${error.code}, not 401`];
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
];
|
package/src/conformance.ts
CHANGED
|
@@ -65,6 +65,38 @@ export function unpublishedUnitFiles(unit: string): string[] {
|
|
|
65
65
|
|
|
66
66
|
/** Each of UNIT_FILES a unit lacks where conformance reads it: the manifest of every unit, the life at its vendor's root
|
|
67
67
|
* (lifeServed), the spec's record and the decision table of a unit with a spec (decided()). */
|
|
68
|
+
const trackedJourneySources = new Map<string, string[]>();
|
|
69
|
+
/** Table ownership follows one index snapshot per checkout/run. */
|
|
70
|
+
function hasJourneySource(unit: string): boolean {
|
|
71
|
+
const dir = join(unit, 'journeys');
|
|
72
|
+
let checkout = resolve(unit);
|
|
73
|
+
while (!existsSync(join(checkout, '.git'))) {
|
|
74
|
+
const parent = dirname(checkout);
|
|
75
|
+
if (parent === checkout) {
|
|
76
|
+
// An unpacked published package has no Git index; its delivered files
|
|
77
|
+
// are its source. Markers and empty directories are not table inputs.
|
|
78
|
+
if (!existsSync(dir)) return false;
|
|
79
|
+
const holdsFile = (at: string): boolean => readdirSync(at, { withFileTypes: true }).some(entry => entry.name !== '.metadata_never_index'
|
|
80
|
+
&& (entry.isFile() || (entry.isDirectory() && holdsFile(join(at, entry.name)))));
|
|
81
|
+
return holdsFile(dir);
|
|
82
|
+
}
|
|
83
|
+
checkout = parent;
|
|
84
|
+
}
|
|
85
|
+
const env = { ...process.env };
|
|
86
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_PREFIX', 'GIT_INDEX_FILE']) delete env[key];
|
|
87
|
+
if (process.env.VOLTER_PACK_HOOK_INDEX) env.GIT_INDEX_FILE = process.env.VOLTER_PACK_HOOK_INDEX;
|
|
88
|
+
const key = `${checkout}\0${env.GIT_INDEX_FILE ?? ''}`;
|
|
89
|
+
let source = trackedJourneySources.get(key);
|
|
90
|
+
if (!source) {
|
|
91
|
+
const files = spawnSync('git', ['-C', checkout, 'ls-files', '-z', '--cached', '--', ':(glob)**/journeys/**'], { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 });
|
|
92
|
+
if (files.status !== 0 || files.error) throw new Error(`cannot read tracked journey source: ${String(files.stderr || files.error?.message)}`);
|
|
93
|
+
source = files.stdout.split('\0').filter(path => path && !path.split('/').includes('.metadata_never_index'));
|
|
94
|
+
trackedJourneySources.set(key, source);
|
|
95
|
+
}
|
|
96
|
+
const prefix = relative(checkout, dir).replaceAll('\\', '/') + '/';
|
|
97
|
+
return source.some(path => path.startsWith(prefix));
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
export function unitFilesMissing(unit: string): string[] {
|
|
69
101
|
const vendor = existsSync(join(unit, 'package.json')) ? unit : dirname(unit);
|
|
70
102
|
const spec = hasOwnSurface(unit);
|
|
@@ -72,7 +104,7 @@ export function unitFilesMissing(unit: string): string[] {
|
|
|
72
104
|
'src/manifest.ts': unit,
|
|
73
105
|
'spec/SOURCE.md': spec ? unit : undefined,
|
|
74
106
|
'journeys/customer-life.json': vendor,
|
|
75
|
-
'journeys/decisions.json': spec ? (
|
|
107
|
+
'journeys/decisions.json': spec ? (hasJourneySource(unit) ? unit : vendor) : undefined,
|
|
76
108
|
};
|
|
77
109
|
return UNIT_FILES.filter((f) => at[f] !== undefined && !existsSync(join(at[f]!, f))).map((f) => `${relative(unit, join(at[f]!, f))} is missing (conformance reads it)`);
|
|
78
110
|
}
|
|
@@ -125,7 +157,7 @@ async function decided(pack: string): Promise<string[]> {
|
|
|
125
157
|
for (const unit of unitsIn(pack).filter(hasOwnSurface)) {
|
|
126
158
|
const name = nameOf(pack, unit);
|
|
127
159
|
const key = unit === pack ? '' : basename(unit);
|
|
128
|
-
const journeys =
|
|
160
|
+
const journeys = hasJourneySource(unit) ? join(unit, 'journeys') : join(unit, '..', 'journeys');
|
|
129
161
|
const owners = byUnit ? byUnit[key] ?? {} : (await makeOf(unit))?.().owners?.();
|
|
130
162
|
if (!owners) { out.push(`${name}: its fetch (${fetchName(unit)}) names no owners() to check the decision table against`); continue; }
|
|
131
163
|
const table = readJson(join(journeys, 'decisions.json')) as { decisions?: Decision[] } | undefined;
|
|
@@ -285,6 +317,131 @@ async function standalone(pack: string): Promise<string[]> {
|
|
|
285
317
|
return out;
|
|
286
318
|
}
|
|
287
319
|
|
|
320
|
+
type SweptOperation = {
|
|
321
|
+
id: string; method: string; path: string; basePath?: string; pathParams?: string[];
|
|
322
|
+
query?: Array<{ name: string; type?: string; required?: boolean }>; body?: Array<{ name: string; required?: boolean }>;
|
|
323
|
+
bodyEncoding?: string; headers?: Record<string, string | null>; discriminator?: Record<string, string | null>;
|
|
324
|
+
responseMedia?: Record<string, string[]>; answers?: unknown;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** The request an application's first call of an operation sends when it knows nothing yet: every path parameter, every
|
|
328
|
+
* required query parameter and every header the operation is routed by holding a placeholder, its discriminator's
|
|
329
|
+
* values, and an empty body in the operation's encoding. What it measures is the twin's answer to a call it did not
|
|
330
|
+
* script: a refusal in the vendor's form, or a result in the vendor's shape, and never a crash, a hang or a row. */
|
|
331
|
+
function sweepRequest(op: SweptOperation, basePath: string, host: string, credential: Record<string, string>): Request {
|
|
332
|
+
const fill = (name: string, type?: string): string => (type === 'integer' || type === 'number' ? '1' : type === 'boolean' ? 'true' : `conformance-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`);
|
|
333
|
+
const path = `${op.basePath ?? basePath}${op.path}`.replace(/\{([^}+]+)\+?\}/g, (_m, name: string) => fill(name));
|
|
334
|
+
const url = new URL(`https://${host}${path}`);
|
|
335
|
+
for (const q of op.query ?? []) if (q.required) url.searchParams.set(q.name, fill(q.name, q.type));
|
|
336
|
+
for (const [name, value] of Object.entries(op.discriminator ?? {})) url.searchParams.set(name, value ?? fill(name));
|
|
337
|
+
const headers = new Headers(credential);
|
|
338
|
+
for (const [name, value] of Object.entries(op.headers ?? {})) headers.set(name, value ?? fill(name));
|
|
339
|
+
const method = op.method.toUpperCase();
|
|
340
|
+
let body: string | undefined;
|
|
341
|
+
if (op.bodyEncoding === 'form') { headers.set('content-type', 'application/x-www-form-urlencoded'); body = ''; }
|
|
342
|
+
else if (op.bodyEncoding === 'xml') { headers.set('content-type', 'application/xml'); body = ''; }
|
|
343
|
+
else if (op.bodyEncoding === 'json' || op.bodyEncoding === 'multipart') { headers.set('content-type', 'application/json'); body = '{}'; }
|
|
344
|
+
return new Request(url, { method, headers, ...(body !== undefined && !['GET', 'HEAD'].includes(method) ? { body } : {}) });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Every operation the pack serves, called over HTTP as an application calls it, against its server.ts served as a World
|
|
348
|
+
* runs it: once plainly and once with a scenario loaded (an empty one, as a World that scripts nothing loads it). Each
|
|
349
|
+
* answers within 10 s, never with a 500 the spec does not declare, never with a field of the twin's own (`_`
|
|
350
|
+
* bookkeeping, the kernel's `updatedAt`) and, answered successfully, in the shape the spec gives the operation; and every
|
|
351
|
+
* refresh list a fresh account can read (one with no parameter in its path) answers it successfully, so a World rooted
|
|
352
|
+
* at this twin reads its resources back. Escaped before: the scenario hang (a served pack given a scenario answered
|
|
353
|
+
* nothing), leaked kernel fields in answers no life step read, and refresh scopes on operations the pack answered with
|
|
354
|
+
* the gap. The life walks what a customer does; this calls what the pack declares it serves. */
|
|
355
|
+
async function surfaceSwept(pack: string, scratch: string): Promise<{ failures: string[]; note: string }> {
|
|
356
|
+
const out: string[] = [];
|
|
357
|
+
const descriptor = ((await manifestOf(pack)) as { descriptor?: { hosts?: Array<{ host?: string }>; credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> } } }).descriptor;
|
|
358
|
+
const fetchOf = ((await import(join(pack, 'src', 'fetch.ts'))) as Record<string, unknown>)[fetchName(pack)] as (() => { owners?: () => Owners; ownersByUnit?: () => Record<string, Owners> }) | undefined;
|
|
359
|
+
const root = fetchOf?.();
|
|
360
|
+
const byUnit = root?.ownersByUnit?.() ?? { '': root?.owners?.() ?? {} };
|
|
361
|
+
const { shapeChecker } = await import('./walk/shape.ts');
|
|
362
|
+
const { leaked } = await import('./walk/behavior-journey.ts');
|
|
363
|
+
const shape = await shapeChecker(pack);
|
|
364
|
+
type Unit = { name: string; host: string; basePath: string; ops: SweptOperation[]; refreshLists: Array<{ resource: string; id: string }>; auth?: { header: string; scheme: string } };
|
|
365
|
+
const units: Unit[] = [];
|
|
366
|
+
const vendorFields = new Set<string>();
|
|
367
|
+
for (const unit of unitsIn(pack).filter(hasOwnSurface)) {
|
|
368
|
+
const surface = readJson(join(unit, 'src', 'generated', 'surface.gen.json')) as { format?: string; basePath?: string; operations?: SweptOperation[]; resources?: Array<{ fields: Array<{ name: string }> }> } | undefined;
|
|
369
|
+
for (const r of surface?.resources ?? []) for (const f of r.fields) vendorFields.add(f.name);
|
|
370
|
+
// a line protocol and a command table ride in no path of their own: the life's line steps and the examples read them
|
|
371
|
+
if (!surface?.operations || ['command-reply', 'command-table'].includes(String(surface.format))) continue;
|
|
372
|
+
const m = (await manifestOf(unit)) as Manifest & { hosts?: Array<{ host?: string }>; auth?: { header: string; scheme: string } };
|
|
373
|
+
const host = m.hosts?.find((h) => h.host)?.host ?? descriptor?.hosts?.find((h) => h.host)?.host;
|
|
374
|
+
if (!host) { out.push(`${nameOf(pack, unit)}: no host is named (its manifest's hosts or the descriptor's) for an application to call it at`); continue; }
|
|
375
|
+
const owners = byUnit[unit === pack ? '' : basename(unit)] ?? {};
|
|
376
|
+
const refreshLists = Object.entries(m.resources ?? {}).flatMap(([resource, decl]) => (decl.refresh?.list ? [{ resource, id: decl.refresh.list }] : []));
|
|
377
|
+
units.push({ name: nameOf(pack, unit), host, basePath: surface.basePath ?? '', ops: surface.operations.filter((o) => owners[o.id] !== undefined && owners[o.id] !== 'gap'), refreshLists, ...(m.auth ? { auth: m.auth } : {}) });
|
|
378
|
+
}
|
|
379
|
+
if (!units.length) return { failures: out, note: 'no unit serves an operation on a path of its own' };
|
|
380
|
+
const tally = { calls: 0, ok: 0, refused: 0 };
|
|
381
|
+
const env = (globalThis as { process: { env: Record<string, string | undefined> } }).process.env;
|
|
382
|
+
const scenarioPath = join(scratch, 'surface-scenario.json');
|
|
383
|
+
writeFileSync(scenarioPath, '{"handlers":[]}');
|
|
384
|
+
for (const mode of ['plain', 'scenario'] as const) {
|
|
385
|
+
const before = env.VOLTER_WORLD_BOOT_ID;
|
|
386
|
+
env.VOLTER_WORLD_BOOT_ID = 'conformance';
|
|
387
|
+
let server: Served;
|
|
388
|
+
try { server = await serve(pack, { root: join(scratch, `surface-${mode}`), ...(mode === 'scenario' ? { scenarioPath } : {}) }); } finally { if (before === undefined) delete env.VOLTER_WORLD_BOOT_ID; else env.VOLTER_WORLD_BOOT_ID = before; }
|
|
389
|
+
const http = servedHttpFetch(server.inspectPort ?? server.port);
|
|
390
|
+
const send = async (request: Request): Promise<Response | 'timeout' | Error> => {
|
|
391
|
+
await server.managedDatabaseUrl?.();
|
|
392
|
+
const abort = new AbortController();
|
|
393
|
+
try { return await within(10_000, http(request, { signal: abort.signal })); } catch (error) { return error as Error; } finally { abort.abort(); }
|
|
394
|
+
};
|
|
395
|
+
try {
|
|
396
|
+
// the account an application holds: the key the credential door issues, sent as the vendor reads its credential
|
|
397
|
+
let issued: string | undefined;
|
|
398
|
+
if (descriptor?.credentialDoor) {
|
|
399
|
+
const door = descriptor.credentialDoor;
|
|
400
|
+
const answer = await send(new Request(`https://${descriptor.hosts?.find((h) => h.host)?.host ?? units[0]!.host}${door.path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(door.body) }));
|
|
401
|
+
const fields = answer instanceof Response ? ((await answer.json().catch(() => ({}))) as Record<string, unknown>) : {};
|
|
402
|
+
issued = Object.values(door.fill).map((f) => fields[f]).find((v): v is string => typeof v === 'string');
|
|
403
|
+
}
|
|
404
|
+
const said = mode === 'scenario' ? ' with a scenario loaded' : '';
|
|
405
|
+
for (const u of units) {
|
|
406
|
+
// a unit whose manifest declares no `auth` checks its key in its own front: the key goes as most vendors read
|
|
407
|
+
// one, a bearer token, and a refusal of it is no finding of the refresh below
|
|
408
|
+
const auth = u.auth ?? { header: 'authorization', scheme: 'Bearer' };
|
|
409
|
+
const credential: Record<string, string> = issued ? { [auth.header]: auth.scheme ? `${auth.scheme} ${issued}` : issued } : {};
|
|
410
|
+
const answered = new Map<string, number>();
|
|
411
|
+
for (const op of u.ops) {
|
|
412
|
+
const request = sweepRequest(op, u.basePath, u.host, credential);
|
|
413
|
+
const where = `${u.name}: ${op.id} (${request.method} ${new URL(request.url).pathname})`;
|
|
414
|
+
const res = await send(request);
|
|
415
|
+
if (res === 'timeout') { out.push(`${where} answered nothing in 10 s${said}`); continue; }
|
|
416
|
+
if (res instanceof Error) { out.push(`${where}: the connection failed${said} (${res.message})`); continue; }
|
|
417
|
+
answered.set(op.id, res.status);
|
|
418
|
+
if (mode === 'plain') { tally.calls++; if (res.status < 300) tally.ok++; else tally.refused++; }
|
|
419
|
+
const text = await res.text();
|
|
420
|
+
if (res.status === 500 && !op.responseMedia?.['500'] && !op.responseMedia?.['5XX']) { out.push(`${where} answered 500${said}, which the spec does not declare: ${text.slice(0, 160)}`); continue; }
|
|
421
|
+
if (mode === 'scenario') continue;
|
|
422
|
+
const type = res.headers.get('content-type') ?? '';
|
|
423
|
+
let body: unknown;
|
|
424
|
+
if (/json/.test(type)) { try { body = JSON.parse(text); } catch { body = undefined; } }
|
|
425
|
+
for (const key of leaked(body, vendorFields, '', !op.answers || res.status >= 300)) out.push(`${where} answers the twin's own field ${key}`);
|
|
426
|
+
if (res.status >= 200 && res.status < 300 && shape) {
|
|
427
|
+
const hit = shape.operationOf(request.method, request.url, Object.fromEntries(request.headers));
|
|
428
|
+
if (hit) for (const detail of shape.failures(hit.op, body, type, request.headers.get('accept') ?? undefined, res.status, { method: request.method, responseText: text })) out.push(`${where}: ${detail}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (mode === 'plain') for (const r of u.refreshLists) {
|
|
432
|
+
const op = u.ops.find((o) => o.id === r.id);
|
|
433
|
+
if (!op || op.path.includes('{') || (op.basePath ?? '').includes('{')) continue;
|
|
434
|
+
const status = answered.get(r.id);
|
|
435
|
+
if (status !== undefined && (status === 401 || status === 403) && (!u.auth || !issued)) continue;
|
|
436
|
+
if (status !== undefined && (status < 200 || status >= 300)) out.push(`${u.name}: ${r.resource}'s refresh lists by ${r.id}, which answered a fresh account ${status}: a World rooted at this twin reads nothing back`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} finally { await server.stop(); }
|
|
440
|
+
}
|
|
441
|
+
// one defect repeated over a surface reads as one: the first 40 findings, and how many more
|
|
442
|
+
return { failures: out.length > 40 ? [...out.slice(0, 40), `… ${out.length - 40} more`] : out, note: `${tally.calls} served operations called, twice each: ${tally.ok} answered successfully, ${tally.refused} refused, unscripted` };
|
|
443
|
+
}
|
|
444
|
+
|
|
288
445
|
/** A line protocol's connection over TCP, in the walk's stream shape. A line drew its replies when bytes arrived and then
|
|
289
446
|
* 50 ms passed with no more (a multi-line reply arrives in pieces), or when 500 ms passed with none (a line that draws no
|
|
290
447
|
* reply: a message's body before its end); 10 s bounds a server that keeps writing. The windows are timing, not protocol:
|
|
@@ -344,7 +501,9 @@ async function lifeServed(pack: string): Promise<string[]> {
|
|
|
344
501
|
socket: (url: string, headers: Record<string, string>) => new WebSocket(`ws://127.0.0.1:${port}${url}`, { headers } as unknown as string[]),
|
|
345
502
|
// a line connection opened as an application's client opens it: TCP to the protocol listener (the primary port)
|
|
346
503
|
stream: lineConnection(server.port),
|
|
347
|
-
|
|
504
|
+
// bounded: Bun's server.stop(true) never settles once a WebSocket peer has closed under it (discord's life, Bun
|
|
505
|
+
// 1.3.11: the walk ended and the stop spun at full CPU for 45 minutes), so the check waits 5 s and goes on
|
|
506
|
+
stop: async () => { await within(5000, Promise.resolve(server.stop())); },
|
|
348
507
|
};
|
|
349
508
|
};
|
|
350
509
|
const world = inMemoryWorld(`conform-${basename(pack)}`);
|
|
@@ -374,10 +533,12 @@ async function clientCases(pack: string, scratch: string): Promise<{ failures: s
|
|
|
374
533
|
? { failures: [`${vendor} names npm clients (${sdks.join(', ')}) and has no client cases: add packages/twin-standard/src/clients/${vendor}.ts, the SDK driving the pack as an application does, or the descriptor's clientCasesNone saying why none is owed`] }
|
|
375
534
|
: { failures: [], note: `${vendor} declares no SDK of its own` };
|
|
376
535
|
}
|
|
377
|
-
const descriptor = ((await manifestOf(pack)) as { descriptor?: { hosts?: Array<{ host?: string; suffix?: string }>; credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> } } }).descriptor;
|
|
536
|
+
const descriptor = ((await manifestOf(pack)) as { descriptor?: { hosts?: Array<{ host?: string; suffix?: string }>; hostsNone?: string; credentialDoor?: { path: string; body: Record<string, unknown>; fill: Record<string, string> } } }).descriptor;
|
|
378
537
|
// the first host that names one: a descriptor may lead with a suffix (a region's hosts under one domain), which no
|
|
379
538
|
// request addresses; the case's client reaches whatever host its issued credentials name
|
|
380
|
-
|
|
539
|
+
// a raw protocol's pack (hostsNone: SMTP, whose relay is whatever host the operator names) is reached by its TCP
|
|
540
|
+
// connection alone; its client names a stand-in host the connection never resolves
|
|
541
|
+
const host = descriptor?.hosts?.find((h) => h.host)?.host ?? (descriptor?.hostsNone ? 'relay.world.test' : undefined);
|
|
381
542
|
if (!host) return { failures: ['the descriptor names no host for its client to address'] };
|
|
382
543
|
const scenarioPath = join(scratch, 'client-scenario.json');
|
|
383
544
|
writeFileSync(scenarioPath, '{"handlers":[]}');
|
|
@@ -391,11 +552,27 @@ async function clientCases(pack: string, scratch: string): Promise<{ failures: s
|
|
|
391
552
|
const issued: Record<string, string> = {};
|
|
392
553
|
const door = descriptor?.credentialDoor;
|
|
393
554
|
if (door) {
|
|
394
|
-
|
|
555
|
+
// a door that answers nothing is the pack's failure, never conform's hang (a pack served with a scenario that answered
|
|
556
|
+
// nothing once held this request open for good)
|
|
557
|
+
const abort = new AbortController();
|
|
558
|
+
const asked = await within(10_000, fetch(`http://127.0.0.1:${server.port}${door.path}`, { method: 'POST', headers: { host, 'content-type': 'application/json' }, body: JSON.stringify(door.body), signal: abort.signal }).then((r) => r.json().catch(() => ({}))));
|
|
559
|
+
abort.abort();
|
|
560
|
+
if (asked === 'timeout') return { failures: [`the credential door (POST ${door.path}) answered nothing in 10 s, served with a scenario loaded`] };
|
|
561
|
+
const answer = asked as Record<string, unknown>;
|
|
395
562
|
for (const [name, field] of Object.entries(door.fill)) if (answer[field] !== undefined) issued[name] = String(answer[field]);
|
|
396
563
|
}
|
|
397
564
|
const job = join(scratch, 'client-job.json');
|
|
398
|
-
|
|
565
|
+
// a release carries its facts (generated/pack-facts.json, `twin-standard facts`); a source checkout does not, so its
|
|
566
|
+
// facts are compiled here, as that command compiles them, for the client the case runs
|
|
567
|
+
let packFacts = join(pack, 'generated', 'pack-facts.json');
|
|
568
|
+
if (!existsSync(packFacts)) {
|
|
569
|
+
const { compilePackFacts, renderPackFacts } = await import('./pack-facts.ts');
|
|
570
|
+
const compiled = await compilePackFacts([{ vendor, dir: pack }]);
|
|
571
|
+
if (compiled.undeclared.length) throw new Error('Pack descriptor is missing');
|
|
572
|
+
packFacts = join(scratch, 'pack-facts.json');
|
|
573
|
+
writeFileSync(packFacts, renderPackFacts(compiled));
|
|
574
|
+
}
|
|
575
|
+
writeFileSync(job, JSON.stringify({ vendor, port: server.port, host, env: issued, scenarioPath, packFacts, ...(descriptor?.hostsNone ? { raw: true } : {}) }));
|
|
399
576
|
// Node, as an application runs its SDK (asynchronous: this process serves the pack meanwhile)
|
|
400
577
|
const out = await new Promise<string>((resolve) => {
|
|
401
578
|
const child = spawn('node', ['--experimental-strip-types', '--no-warnings', join(clients, `run${extension}`), job], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -412,8 +589,42 @@ async function clientCases(pack: string, scratch: string): Promise<{ failures: s
|
|
|
412
589
|
} finally { await server.stop(); }
|
|
413
590
|
}
|
|
414
591
|
|
|
592
|
+
/** The gate over every unit of the pack: the form section of the grade, which holds the rules an author's code keeps
|
|
593
|
+
* (p3-rules.ts: ids from ctx.mint, the twin's URL from ctx.publicBase, the kernel's crypto, bytes in ctx.blobs, secrets
|
|
594
|
+
* drawn from ctx.secret) and the pack layout, handler, kernel and manifest contracts. A contributor's conform is the
|
|
595
|
+
* whole of what a machine decides of the pack, as the catalog's admission reads it. */
|
|
596
|
+
async function formOf(pack: string): Promise<string[]> {
|
|
597
|
+
const { p3Gate } = await import('./gate.ts');
|
|
598
|
+
return (await p3Gate(unitsIn(pack))).flatMap((r) => r.failures.map((f) => `${nameOf(pack, r.unit)}: ${f}`));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The pack's records hold what a reviewer reads them for: each demand row cites the application's line it was read at
|
|
602
|
+
* (`<path>:<line>`, or `<package>@<version>:<path>:<line>` for a call a dependency makes), the spec's record names a
|
|
603
|
+
* vendored file that is there, the life names the session that wrote it (what makes a judge's verdict independent),
|
|
604
|
+
* and the package says what it serves in a README. Rules every builder brief carried by hand, decided here. */
|
|
605
|
+
function records(pack: string): string[] {
|
|
606
|
+
const out: string[] = [];
|
|
607
|
+
if (!existsSync(join(pack, 'README.md'))) out.push('README.md is missing: the package says what it serves, for whom, its doors and what it leaves out');
|
|
608
|
+
const life = readJson(join(pack, 'journeys', 'customer-life.json')) as { author?: unknown } | undefined;
|
|
609
|
+
if (life && (typeof life.author !== 'string' || !life.author.trim())) out.push('journeys/customer-life.json names no author (the session that wrote it), so no verdict on it can be shown independent');
|
|
610
|
+
for (const unit of unitsIn(pack)) {
|
|
611
|
+
const name = nameOf(pack, unit);
|
|
612
|
+
const journeys = hasJourneySource(unit) ? join(unit, 'journeys') : join(unit, '..', 'journeys');
|
|
613
|
+
const demand = unit === pack || hasJourneySource(unit) ? readJson(join(journeys, 'demand.json')) as { applications?: Array<{ name?: string; backend?: Array<{ operation?: string; evidence?: unknown }> }> } | undefined : undefined;
|
|
614
|
+
for (const app of demand?.applications ?? []) for (const row of app.backend ?? []) {
|
|
615
|
+
if (typeof row.evidence !== 'string' || !/:\d+/.test(row.evidence)) out.push(`${name}: ${app.name ?? 'an application'}'s call of ${row.operation ?? '(no operation)'} cites no line it was read at (evidence "<path>:<line>")`);
|
|
616
|
+
}
|
|
617
|
+
if (!hasOwnSurface(unit)) continue;
|
|
618
|
+
const source = existsSync(join(unit, 'spec', 'SOURCE.md')) ? readFileSync(join(unit, 'spec', 'SOURCE.md'), 'utf8') : '';
|
|
619
|
+
const file = /\*\*File:\*\*[^`\n]*`([^`]+)`/.exec(source)?.[1];
|
|
620
|
+
if (file && !existsSync(join(unit, 'spec', file))) out.push(`${name}: spec/SOURCE.md names ${file} as the vendored spec, and spec/ holds no such file`);
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
415
625
|
/** Every check over the pack at `dir`, in order. */
|
|
416
|
-
|
|
626
|
+
/** `onCheck` hears each check's id as it starts, so a caller can show where a long run stands. */
|
|
627
|
+
export async function conformance(dir: string, options: { onCheck?: (id: string) => void } = {}): Promise<ConformanceResult[]> {
|
|
417
628
|
const pack = resolve(dir);
|
|
418
629
|
if (!existsSync(join(pack, 'package.json'))) throw new Error(`${dir} is no pack (no package.json): conformance takes a pack's directory`);
|
|
419
630
|
// the kernel the pack itself resolves: one registry, one serve seam, the pack's own
|
|
@@ -421,8 +632,9 @@ export async function conformance(dir: string): Promise<ConformanceResult[]> {
|
|
|
421
632
|
const scratch = mkdtempSync(join(tmpdir(), 'conformance-'));
|
|
422
633
|
const here = process.cwd();
|
|
423
634
|
process.chdir(scratch);
|
|
424
|
-
const run = async (id: string, asks: string, check: () => Promise<string[]> | string[]): Promise<ConformanceResult> => {
|
|
425
|
-
|
|
635
|
+
const run = async (id: string, asks: string, check: () => Promise<string[] | { failures: string[]; note: string }> | string[]): Promise<ConformanceResult> => {
|
|
636
|
+
options.onCheck?.(id);
|
|
637
|
+
try { const r = await check(); return Array.isArray(r) ? { id, asks, failures: r } : { id, asks, failures: r.failures, note: r.note }; } catch (error) { return { id, asks, failures: [`the check could not run: ${(error as Error).message.split('\n')[0]}`] }; }
|
|
426
638
|
};
|
|
427
639
|
try {
|
|
428
640
|
return [
|
|
@@ -433,11 +645,15 @@ export async function conformance(dir: string): Promise<ConformanceResult[]> {
|
|
|
433
645
|
await run('registered', 'a vendor-backed unit, lane or not, reaches the registered pack\'s state system', () => registered(pack, runtime)),
|
|
434
646
|
await run('allowance', 'a rate budget above the fallback rests on the vendor\'s documented allowance, cited in its manifest', () => allowance(pack, runtime)),
|
|
435
647
|
await (async (): Promise<ConformanceResult> => {
|
|
648
|
+
options.onCheck?.('client');
|
|
436
649
|
const asks = "driven by the vendor's own client (its official SDK), served as a World runs it, the vendor's documented behaviour holds";
|
|
437
650
|
try { const r = await clientCases(pack, scratch); return { id: 'client', asks, failures: r.failures, ...(r.note ? { note: r.note } : {}) }; } catch (error) { return { id: 'client', asks, failures: [`the check could not run: ${(error as Error).message.split('\n')[0]}`] }; }
|
|
438
651
|
})(),
|
|
439
652
|
await run('life', 'the life walked over HTTP against the pack served as a World runs it, with its scenario: every check held', () => lifeServed(pack)),
|
|
440
653
|
await run('standalone', 'served by its server.ts as a World runs it, the pack answers HTTP and the boot probe, and each declared socket upgrades', () => standalone(pack)),
|
|
654
|
+
await run('surface', 'every served operation called over HTTP as an application calls it, plainly and with a scenario loaded: answered within 10 s, no undeclared 500, no field of the twin\'s own, a success in the spec\'s shape, and every refresh list a fresh account reads answering', () => surfaceSwept(pack, scratch)),
|
|
655
|
+
await run('records', 'each demand row cites the line it was read at, the spec\'s record names a vendored file that is there, the life names its author, the package has its README', () => records(pack)),
|
|
656
|
+
await run('form', 'every unit passes the form section of the grade (the gate): the layout, the handler and kernel contracts, the manifest as data, the rules an author\'s code holds', () => formOf(pack)),
|
|
441
657
|
];
|
|
442
658
|
} finally {
|
|
443
659
|
process.chdir(here);
|
package/src/consumer.ts
CHANGED
|
@@ -181,7 +181,11 @@ export async function runConsumerWorkflow(packDir: string, options: ConsumerOpti
|
|
|
181
181
|
if (JSON.stringify(before) !== JSON.stringify(resumed)) throw new Error('Resume changed recorded application state before readback');
|
|
182
182
|
await run('retained-readback', ['run', '--', ...recipe.retention.readback]);
|
|
183
183
|
const after = JSON.parse(await run('log-after-readback', ['log', '--json']));
|
|
184
|
-
|
|
184
|
+
// the readback adds no write of the application's: what it adds may only be the vendor's own moves (a key's
|
|
185
|
+
// last-used time, recorded by the vendor as a request uses it: actor `system`, ctx.asVendor), never an API call's
|
|
186
|
+
const added = (after as Array<{ id?: string; actor?: { kind?: string } }>).filter((entry) => !(before as Array<{ id?: string }>).some((prior) => prior.id === entry.id));
|
|
187
|
+
const kept = (before as Array<{ id?: string }>).every((prior) => (after as Array<{ id?: string }>).some((entry) => JSON.stringify(entry) === JSON.stringify(prior)));
|
|
188
|
+
if (!kept || added.some((entry) => entry.actor?.kind !== 'system')) throw new Error('Retained readback added mutations; it must only inspect existing state');
|
|
185
189
|
retainedReadback = true;
|
|
186
190
|
}
|
|
187
191
|
} catch (error) { failures.push((error as Error).message); }
|