@volter/twin-standard 1.0.158 → 1.0.159
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 +3 -2
- package/client-sdks/package.json +1 -0
- package/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/client-sdks/package.json +1 -0
- package/dist/client-sdks/pnpm-lock.yaml +210 -0
- package/dist/src/assessment.d.ts +13 -3
- package/dist/src/assessment.js +10 -6
- package/dist/src/browser-assessment.js +16 -13
- package/dist/src/clients/harness.d.ts +25 -4
- package/dist/src/clients/harness.js +184 -22
- package/dist/src/clients/jira.js +11 -2
- package/dist/src/clients/run.js +19 -14
- package/dist/src/conformance.d.ts +2 -0
- package/dist/src/conformance.js +392 -184
- package/dist/src/consumer.d.ts +22 -1
- package/dist/src/consumer.js +91 -26
- package/dist/src/credential-issuer-import.d.ts +3 -0
- package/dist/src/credential-issuer-import.js +42 -0
- package/dist/src/derive.js +3 -0
- package/dist/src/gate.js +4 -0
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +7 -0
- package/dist/src/protocol-3.js +8 -3
- package/dist/src/spec-documents.d.ts +4 -0
- package/dist/src/spec-documents.js +110 -3
- package/dist/src/tools/life-coverage-walk.js +38 -26
- package/dist/src/tools/score-pack.js +6 -5
- package/dist/src/tools/vendor-examples.js +91 -80
- package/dist/src/walk/behavior-journey.d.ts +22 -14
- package/dist/src/walk/behavior-journey.js +182 -133
- package/dist/src/walk/journey-kit.d.ts +3 -2
- package/dist/src/walk/journey-kit.js +6 -2
- package/dist/src/walk/replay.d.ts +23 -0
- package/dist/src/walk/replay.js +118 -0
- package/dist/src/walk/serve-pack.d.ts +2 -2
- package/dist/src/walk/serve-pack.js +38 -2
- package/dist/src/walk/shape.d.ts +25 -4
- package/dist/src/walk/shape.js +166 -56
- package/dist/src/walk/vendor-fetch.d.ts +7 -2
- package/dist/src/walk/vendor-fetch.js +13 -2
- package/dist/src/walk/walk-database.d.ts +9 -1
- package/dist/src/walk/walk-database.js +35 -13
- package/dist/src/walk/xml-view.d.ts +3 -2
- package/dist/src/walk/xml-view.js +5 -4
- package/package.json +3 -3
- package/src/assessment.ts +12 -8
- package/src/browser-assessment.ts +12 -5
- package/src/clients/harness.ts +142 -19
- package/src/clients/jira.ts +4 -2
- package/src/clients/run.ts +17 -15
- package/src/conformance.ts +313 -128
- package/src/consumer.ts +83 -21
- package/src/credential-issuer-import.ts +38 -0
- package/src/derive.ts +3 -1
- package/src/gate.ts +3 -0
- package/src/lanes.ts +7 -0
- package/src/protocol-3.ts +7 -3
- package/src/spec-documents.ts +92 -3
- package/src/tools/life-coverage-walk.ts +32 -22
- package/src/tools/score-pack.ts +7 -6
- package/src/tools/vendor-examples.ts +88 -75
- package/src/walk/behavior-journey.ts +145 -109
- package/src/walk/journey-kit.ts +7 -5
- package/src/walk/replay.ts +103 -0
- package/src/walk/serve-pack.ts +25 -5
- package/src/walk/shape.ts +156 -54
- package/src/walk/vendor-fetch.ts +16 -4
- package/src/walk/walk-database.ts +41 -15
- package/src/walk/xml-view.ts +5 -4
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
+
import { worldDoorMatcher } from '@volter/world-core/runtime';
|
|
6
7
|
let loaded;
|
|
7
8
|
const worldRuntime = () => loaded ??= (async () => {
|
|
8
9
|
try {
|
|
@@ -19,7 +20,7 @@ export async function startTwinDatabase(pack, descriptor) {
|
|
|
19
20
|
const definition = descriptor.managedDatabase, door = descriptor.credentialDoor;
|
|
20
21
|
if (!definition || !door || !definition.credentialField)
|
|
21
22
|
throw new Error('The selected twin must declare its SQL credential door field');
|
|
22
|
-
const { runWorld, worldConfigDocument } = await worldRuntime();
|
|
23
|
+
const { runWorld, worldConfigDocument, verifyPrivateTwinDatabaseConnection } = await worldRuntime();
|
|
23
24
|
// Keep the disposable World on the pack's own volume and package-resolution ancestry.
|
|
24
25
|
const root = mkdtempSync(join(dirname(pack), '.walk-database-'));
|
|
25
26
|
const manifest = JSON.parse(readFileSync(join(pack, 'package.json'), 'utf8'));
|
|
@@ -30,10 +31,10 @@ export async function startTwinDatabase(pack, descriptor) {
|
|
|
30
31
|
const readyFile = join(root, 'ready.json'), releaseFile = join(root, 'release');
|
|
31
32
|
// Ask the selected twin's actual product door. The runtime alone allocates and owns its engine.
|
|
32
33
|
const hold = `
|
|
33
|
-
import { existsSync, readFileSync, renameSync,
|
|
34
|
-
import {
|
|
34
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { join } from 'node:path';
|
|
35
36
|
const [ready, release, vendor, path, body, field] = process.argv.slice(1);
|
|
36
|
-
if (!process.env.VOLTER_WORLD_INSTANCE) throw new Error('Missing held World instance');
|
|
37
|
+
if (!process.env.VOLTER_WORLD_INSTANCE || !process.env.VOLTER_WORLD_DATA) throw new Error('Missing held World instance');
|
|
37
38
|
const instance = JSON.parse(readFileSync(process.env.VOLTER_WORLD_INSTANCE, 'utf8'));
|
|
38
39
|
const service = instance.services[vendor];
|
|
39
40
|
if (!service?.url) throw new Error('Selected twin has no product endpoint');
|
|
@@ -42,8 +43,8 @@ export async function startTwinDatabase(pack, descriptor) {
|
|
|
42
43
|
const result = await response.json();
|
|
43
44
|
if (typeof result[field] !== 'string' || !result[field]) throw new Error('SQL credential door omitted its declared field');
|
|
44
45
|
const done = () => { if (existsSync(release)) process.exit(0); };
|
|
45
|
-
|
|
46
|
-
writeFileSync(ready + '.tmp', JSON.stringify({ url: result[field] }), {mode:0o600});
|
|
46
|
+
setInterval(done, 100);
|
|
47
|
+
writeFileSync(ready + '.tmp', JSON.stringify({ url: result[field], databaseRoot: join(process.env.VOLTER_WORLD_DATA, vendor) }), {mode:0o600});
|
|
47
48
|
renameSync(ready + '.tmp', ready);
|
|
48
49
|
done();`;
|
|
49
50
|
let finished;
|
|
@@ -65,12 +66,33 @@ export async function startTwinDatabase(pack, descriptor) {
|
|
|
65
66
|
}
|
|
66
67
|
const ready = JSON.parse(readFileSync(readyFile, 'utf8'));
|
|
67
68
|
let stopping;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
const stop = () => stopping ??= (async () => {
|
|
70
|
+
writeFileSync(releaseFile, 'released\n');
|
|
71
|
+
const result = await task;
|
|
72
|
+
if (result.exitCode !== 0)
|
|
73
|
+
throw new Error(`Twin SQL World consumer exited ${result.exitCode}; evidence: ${root}`);
|
|
74
|
+
})();
|
|
75
|
+
const verify = (value) => verifyPrivateTwinDatabaseConnection(descriptor.vendor, ready.databaseRoot, value);
|
|
76
|
+
try {
|
|
77
|
+
await verify(ready.url);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
try {
|
|
81
|
+
await stop();
|
|
82
|
+
}
|
|
83
|
+
catch (cleanup) {
|
|
84
|
+
throw new AggregateError([error, cleanup], 'Native connection verification and World retirement failed');
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
const matches = worldDoorMatcher([{ method: 'POST', path: door.path }]);
|
|
89
|
+
return { url: ready.url, stop,
|
|
90
|
+
connection: async (method, path, status, body) => {
|
|
91
|
+
if (status < 200 || status >= 300 || !matches(method, path))
|
|
92
|
+
return undefined;
|
|
93
|
+
const value = body && typeof body === 'object' ? body[definition.credentialField] : undefined;
|
|
94
|
+
await verify(value);
|
|
95
|
+
return { kind: definition.kind, field: definition.credentialField, authenticated: true };
|
|
96
|
+
},
|
|
75
97
|
};
|
|
76
98
|
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
/** The root element's members, or undefined when the text is not one XML document.
|
|
2
|
-
|
|
1
|
+
/** The root element's members, or undefined when the text is not one XML document.
|
|
2
|
+
* A schema distinguishes an empty structure from an empty scalar member such as LocationConstraint. */
|
|
3
|
+
export declare function xmlMembers(text: string, rootFields?: ReadonlyArray<string>): Record<string, unknown> | undefined;
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// text. What a restXml output structure's members are on the wire, so a journey asserts and captures them (`Contents.0.Key`)
|
|
4
4
|
// and SHAPE checks them as it checks a JSON answer's. Attributes and namespaces are not members and are dropped. XML
|
|
5
5
|
// cannot say a lone element is a list: one `<Contents>` reads as an object, two as a list. A root with no children is
|
|
6
|
-
// its one member, by its name.
|
|
6
|
+
// its one member, by its name. When a resource schema is supplied, an empty root is a member only if the schema names it.
|
|
7
7
|
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
8
8
|
const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => (e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))) : ENTITIES[e] ?? m));
|
|
9
|
-
/** The root element's members, or undefined when the text is not one XML document.
|
|
10
|
-
|
|
9
|
+
/** The root element's members, or undefined when the text is not one XML document.
|
|
10
|
+
* A schema distinguishes an empty structure from an empty scalar member such as LocationConstraint. */
|
|
11
|
+
export function xmlMembers(text, rootFields) {
|
|
11
12
|
const src = text.replace(/<\?[\s\S]*?\?>/g, '').replace(/<!--[\s\S]*?-->/g, '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, c) => c.replace(/&/g, '&').replace(/</g, '<'));
|
|
12
13
|
const stack = [{ name: '', children: [], text: '' }];
|
|
13
14
|
const tag = /<(\/?)([\w:.-]+)[^>]*?(\/?)>|([^<]+)/g;
|
|
@@ -50,6 +51,6 @@ export function xmlMembers(text) {
|
|
|
50
51
|
};
|
|
51
52
|
// a document whose root is a leaf (S3's `<LocationConstraint>auto</LocationConstraint>`) is that one member
|
|
52
53
|
if (!root.children.length)
|
|
53
|
-
return { [root.name]: value(root) };
|
|
54
|
+
return !root.text.trim() && rootFields && !rootFields.includes(root.name) ? {} : { [root.name]: value(root) };
|
|
54
55
|
return value(root);
|
|
55
56
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/twin-standard",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.159",
|
|
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",
|
|
@@ -77,8 +77,8 @@
|
|
|
77
77
|
"yaml": "2.9.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
|
-
"@volter/world-core": "3.0.
|
|
81
|
-
"@volter/world-runtime": "
|
|
80
|
+
"@volter/world-core": "3.0.164",
|
|
81
|
+
"@volter/world-runtime": "^3.0.188",
|
|
82
82
|
"playwright": "^1.63.0"
|
|
83
83
|
},
|
|
84
84
|
"peerDependenciesMeta": {
|
package/src/assessment.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { conformance } from './conformance.ts';
|
|
|
8
8
|
import { inMemoryWorld, walk, withPinnedWorldDraws, usePacks, type ServedTwin } from './walk/behavior-journey.ts';
|
|
9
9
|
import { compilePackFacts } from './pack-facts.ts';
|
|
10
10
|
import { expandFills, type Journey } from './walk/journey-kit.ts';
|
|
11
|
-
import {
|
|
11
|
+
import { compareReplays, type ReplayStep } from './walk/replay.ts';
|
|
12
|
+
import { runConsumerWorkflow, type ConsumerOptions, type ConsumerResult } from './consumer.ts';
|
|
12
13
|
|
|
13
14
|
const canonical = (value: unknown): unknown => value && typeof value === 'object' ? Array.isArray(value) ? value.map(canonical) : Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value;
|
|
14
15
|
const json = (value: unknown) => JSON.stringify(canonical(value));
|
|
@@ -56,7 +57,8 @@ export async function assessPack(packDir: string, options: AssessmentOptions = {
|
|
|
56
57
|
const coverageOf = (result: { exercised: string[]; ambiguous: boolean }) => !instrumented || result.ambiguous ? null : { total: declared.length, exercised: result.exercised.length, percent: 100 * result.exercised.length / declared.length, operations: result.exercised, missing: declared.filter((id) => !result.exercised.includes(id)) };
|
|
57
58
|
async function replay(served?: ServedTwin) {
|
|
58
59
|
const world = inMemoryWorld('catalog-replay');
|
|
59
|
-
const failures: Array<{ step: string; beat: string; detail: string }> = []
|
|
60
|
+
const failures: Array<{ step: string; beat: string; detail: string }> = [];
|
|
61
|
+
const trace: ReplayStep[] = [];
|
|
60
62
|
const exercised = new Set<string>();
|
|
61
63
|
const signatures = new WeakMap<object, string>();
|
|
62
64
|
let ambiguous = false;
|
|
@@ -70,14 +72,15 @@ export async function assessPack(packDir: string, options: AssessmentOptions = {
|
|
|
70
72
|
exercised.add(id);
|
|
71
73
|
});
|
|
72
74
|
try {
|
|
73
|
-
await withPinnedWorldDraws(() => walk(structuredClone(life), world.root(), failures, { beats: true, ...(served ? { served } : {}), hooks: { after: (step, path, answer) => trace.push({ name: step.name ?? '', path, status: answer.status, body: answer.body }) } }));
|
|
75
|
+
await withPinnedWorldDraws(() => walk(structuredClone(life), world.root(), failures, { beats: true, ...(served ? { served } : {}), hooks: { after: (step, path, answer) => trace.push({ name: step.name ?? '', path, status: answer.status, body: answer.body, ...(answer.sql ? { sql: answer.sql } : {}), ...(answer.connection ? { connection: answer.connection } : {}) }) } }));
|
|
74
76
|
return { failures, trace, exercised: [...exercised].sort(), ambiguous };
|
|
75
77
|
} finally { stop(); world.restore(); }
|
|
76
78
|
}
|
|
77
79
|
const first = await replay(), second = await replay();
|
|
78
|
-
const
|
|
80
|
+
const comparison = compareReplays(first.trace, second.trace);
|
|
81
|
+
const replayEqual = comparison.equal;
|
|
79
82
|
const coverage = coverageOf(first);
|
|
80
|
-
const quick = { scope: 'packaged-customer-journey; in-process', steps: life.steps.length, answered: first.trace.length, failures: [...first.failures, ...second.failures], replay: { equal: replayEqual, runs: 2, firstSha256: hash(first.trace), secondSha256: hash(second.trace) }, operationCoverage: coverage, servedOperations, surface: owners ? { total: declared.length, served: servedOperations.length, gap: declared.length - servedOperations.length } : null, codeCoverage: null, stateTransitionCoverage: null };
|
|
83
|
+
const quick = { scope: 'packaged-customer-journey; in-process', steps: life.steps.length, answered: first.trace.length, failures: [...first.failures, ...second.failures], replay: { equal: replayEqual, diagnostics: comparison.diagnostics, runs: 2, firstSha256: hash(first.trace), secondSha256: hash(second.trace) }, operationCoverage: coverage, servedOperations, surface: owners ? { total: declared.length, served: servedOperations.length, gap: declared.length - servedOperations.length } : null, codeCoverage: null, stateTransitionCoverage: null };
|
|
81
84
|
let browser: Record<string, unknown> = { measured: false, reason: options.browser ? 'Journey has a wire Chromium fetch cannot exercise' : 'Browser target not requested' };
|
|
82
85
|
const httpOnly = life.steps.every((step) => 'method' in step || 'wait' in step);
|
|
83
86
|
if (options.browser && httpOnly) {
|
|
@@ -85,13 +88,14 @@ export async function assessPack(packDir: string, options: AssessmentOptions = {
|
|
|
85
88
|
const transport = await chromiumServed(packDir, life.host ?? 'twin.local', 'http-journey');
|
|
86
89
|
try {
|
|
87
90
|
const a = await replay(transport.served), b = await replay(transport.served);
|
|
88
|
-
|
|
91
|
+
const compared = compareReplays(a.trace, b.trace);
|
|
92
|
+
browser = { measured: true, target: 'HTTP customer journey replay through Chromium at each request origin; authored HTTP headers and observed wire responses', responseObservation: 'Transport observes status, headers and body, including redirects and Set-Cookie; these are not JavaScript-visible response claims', applicationOriginCorsCoverage: null, cookieJarCoverage: null, version: transport.version, answered: a.trace.length, failures: [...a.failures, ...b.failures], replay: { equal: compared.equal, diagnostics: compared.diagnostics, firstSha256: hash(a.trace), secondSha256: hash(b.trace), runs: 2 }, operationCoverage: coverageOf(a), domCoverage: null, clock: transport.clock };
|
|
89
93
|
} finally { await transport.stop(); }
|
|
90
94
|
}
|
|
91
|
-
|
|
92
|
-
let consumer;
|
|
95
|
+
let consumer: ConsumerResult | { measured: false; passed: false; failures: string[] };
|
|
93
96
|
try { consumer = options.consumer ? await runConsumerWorkflow(packDir, options.consumer) : { measured: false, passed: false, failures: ['Installed customer workflow was not prepared'] }; }
|
|
94
97
|
catch (error) { consumer = { measured: false, passed: false, failures: [(error as Error).message] }; }
|
|
98
|
+
const conform = options.conformance === false ? null : await conformance(packDir, consumer.measured ? { consumer } : {});
|
|
95
99
|
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
100
|
// the artifact assessed, by the integrity its prepared consumer verified the installed candidate against (consumer.ts)
|
|
97
101
|
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 };
|
|
@@ -4,10 +4,10 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { worldNow } from '@volter/world-core/runtime';
|
|
6
6
|
import type { ServedTwin } from './walk/behavior-journey.ts';
|
|
7
|
+
import { packFetch } from './walk/vendor-fetch.ts';
|
|
7
8
|
export async function chromiumServed(packDir: string, host: string, mode: 'browser-fetch' | 'http-journey' = 'browser-fetch') {
|
|
8
9
|
const { chromium } = await import('playwright');
|
|
9
|
-
const
|
|
10
|
-
const factory = Object.entries(mod).find(([name, value]) => /^create\w+Fetch$/.test(name) && !/TwinFetch$/.test(name) && typeof value === 'function')?.[1] as ((options: import('@volter/world-core').PackFetchOptions) => (request: Request) => Promise<Response>) | undefined;
|
|
10
|
+
const factory = await packFetch(packDir);
|
|
11
11
|
if (!factory) throw new Error('Browser assessment needs the pack HTTP fetch factory');
|
|
12
12
|
const library = ['/usr/lib/x86_64-linux-gnu/faketime/libfaketimeMT.so.1', '/usr/lib/aarch64-linux-gnu/faketime/libfaketimeMT.so.1'].find(existsSync);
|
|
13
13
|
if (process.platform !== 'linux' || !library) throw new Error('Deterministic browser assessment needs Linux libfaketime installed for native cookie time');
|
|
@@ -27,7 +27,7 @@ export async function chromiumServed(packDir: string, host: string, mode: 'brows
|
|
|
27
27
|
catch (error) { rmSync(clockDir, { recursive: true, force: true }); throw error; }
|
|
28
28
|
const served: ServedTwin = async (root, options) => {
|
|
29
29
|
syncClock();
|
|
30
|
-
const fetchPack = factory(
|
|
30
|
+
const fetchPack = factory(root, options);
|
|
31
31
|
const context = await browser.newContext({ serviceWorkers: 'block', locale: 'en-US', timezoneId: 'UTC' });
|
|
32
32
|
type Page = Awaited<ReturnType<typeof context.newPage>>;
|
|
33
33
|
const pages: Array<{ page: Page; origin: string; busy: boolean }> = [];
|
|
@@ -65,7 +65,7 @@ export async function chromiumServed(packDir: string, host: string, mode: 'brows
|
|
|
65
65
|
};
|
|
66
66
|
return {
|
|
67
67
|
scheme: 'https',
|
|
68
|
-
fetch: async (request: Request, options?: { credentials?: RequestCredentials }) => {
|
|
68
|
+
fetch: Object.assign(async (request: Request, options?: { credentials?: RequestCredentials }) => {
|
|
69
69
|
const entry = await acquire(mode === 'http-journey' ? new URL(request.url).origin : `https://${host}`);
|
|
70
70
|
const { page } = entry;
|
|
71
71
|
const observation = { request } as { request: Request; response?: Response; error?: unknown };
|
|
@@ -89,8 +89,15 @@ export async function chromiumServed(packDir: string, host: string, mode: 'brows
|
|
|
89
89
|
if (!result.status) throw new Error('Chromium returned an opaque response; this journey needs browser-specific navigation assertions');
|
|
90
90
|
return new Response([101, 204, 205, 304].includes(result.status) || request.method === 'HEAD' ? null : Buffer.from(result.body, 'base64'), { status: result.status, headers: result.headers });
|
|
91
91
|
} finally { pending.delete(page); entry.busy = false; }
|
|
92
|
+
}, {
|
|
93
|
+
managedDatabaseUrl: fetchPack.managedDatabaseUrl,
|
|
94
|
+
managedDatabaseClock: fetchPack.managedDatabaseClock,
|
|
95
|
+
managedDatabaseConnection: fetchPack.managedDatabaseConnection,
|
|
96
|
+
}),
|
|
97
|
+
stop: async () => {
|
|
98
|
+
try { await context.close(); }
|
|
99
|
+
finally { await fetchPack.stopDatabase?.(); }
|
|
92
100
|
},
|
|
93
|
-
stop: () => context.close(),
|
|
94
101
|
};
|
|
95
102
|
};
|
|
96
103
|
return { served, version: browser.version(), clock: { native: 'World time through libfaketime', nativePrecisionMs: 1000, javascriptPrecisionMs: 1, monotonic: 'elapsed machine time' }, stop: async () => { try { await browser.close(); } finally { rmSync(clockDir, { recursive: true, force: true }); } } };
|
package/src/clients/harness.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// served port as a World's interception routes the vendor's hosts, the vendor's URL and Host unchanged (an HTTP request
|
|
5
5
|
// over node:http with its Host kept, a socket's TCP connection made to the served port). A case's expectations are the
|
|
6
6
|
// vendor's documented semantics, each cited, never the pack author's life.
|
|
7
|
-
import { createServer, request as httpRequest } from 'node:http';
|
|
7
|
+
import { createServer, request as httpRequest, type ClientRequest } from 'node:http';
|
|
8
8
|
import { Agent as HttpsAgent } from 'node:https';
|
|
9
9
|
import { connect, type AddressInfo, type Socket } from 'node:net';
|
|
10
10
|
import { writeFileSync } from 'node:fs';
|
|
@@ -41,16 +41,33 @@ export type ClientCase = {
|
|
|
41
41
|
/** The World's proxy in front of a pack served on `served`: it ends the caller's TLS (here, none was begun) and forwards
|
|
42
42
|
* each request and socket upgrade to the pack with `x-forwarded-proto: https`, the vendor's Host unchanged, as a World's
|
|
43
43
|
* proxy forwards a caller of the vendor's https host (twin-fetch.ts, twinPublicBase: the links and socket URLs the pack
|
|
44
|
-
* answers are then the vendor's secure ones).
|
|
45
|
-
export async function worldProxy(served: number
|
|
44
|
+
* answers are then the vendor's secure ones). Its stop settles every connection it opened. */
|
|
45
|
+
export async function worldProxy(served: number, observe?: (request: { url: string; headers: Headers }, answer: { status: number }) => void) {
|
|
46
|
+
const sockets = new Set<Socket>();
|
|
47
|
+
const requests = new Set<ClientRequest>();
|
|
48
|
+
const track = (socket: Socket) => { if (sockets.has(socket)) return; sockets.add(socket); socket.once('close', () => sockets.delete(socket)); };
|
|
46
49
|
const server = createServer((req, res) => {
|
|
47
50
|
const forward = httpRequest({ host: '127.0.0.1', port: served, method: req.method, path: req.url, headers: { ...req.headers, 'x-forwarded-proto': 'https' } }, (answer) => {
|
|
51
|
+
if (observe) {
|
|
52
|
+
answer.on('end', () => {
|
|
53
|
+
const headers = new Headers();
|
|
54
|
+
for (const [name, value] of Object.entries(req.headers)) if (value !== undefined) for (const one of Array.isArray(value) ? value : [value]) headers.append(name, one);
|
|
55
|
+
observe({ url: `https://${headers.get('x-volter-twin-original-host') ?? headers.get('host')}${req.url}`, headers }, { status: answer.statusCode ?? 502 });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
answer.on('error', () => res.destroy());
|
|
48
59
|
res.writeHead(answer.statusCode ?? 502, answer.headers);
|
|
49
60
|
answer.pipe(res);
|
|
50
61
|
});
|
|
51
|
-
|
|
62
|
+
requests.add(forward);
|
|
63
|
+
forward.once('close', () => requests.delete(forward));
|
|
64
|
+
forward.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
|
65
|
+
forward.on('socket', track);
|
|
66
|
+
req.on('aborted', () => forward.destroy());
|
|
67
|
+
res.on('close', () => forward.destroy());
|
|
52
68
|
req.pipe(forward);
|
|
53
69
|
});
|
|
70
|
+
server.on('connection', track);
|
|
54
71
|
server.on('upgrade', (req, socket, head) => {
|
|
55
72
|
const upstream = connect(served, '127.0.0.1', () => {
|
|
56
73
|
const lines = [`${req.method} ${req.url} HTTP/1.1`, ...Object.entries({ ...req.headers, 'x-forwarded-proto': 'https' }).flatMap(([k, v]) => (Array.isArray(v) ? v : [v]).map((x) => `${k}: ${x}`)), '', ''];
|
|
@@ -59,16 +76,103 @@ export async function worldProxy(served: number): Promise<number> {
|
|
|
59
76
|
upstream.pipe(socket);
|
|
60
77
|
socket.pipe(upstream);
|
|
61
78
|
});
|
|
79
|
+
track(upstream);
|
|
62
80
|
upstream.on('error', () => socket.destroy());
|
|
63
81
|
socket.on('error', () => upstream.destroy());
|
|
82
|
+
socket.on('close', () => upstream.destroy());
|
|
83
|
+
});
|
|
84
|
+
await new Promise<void>((done, reject) => {
|
|
85
|
+
server.once('error', reject);
|
|
86
|
+
server.listen(0, '127.0.0.1', () => { server.off('error', reject); done(); });
|
|
64
87
|
});
|
|
65
|
-
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()));
|
|
66
88
|
server.unref();
|
|
67
|
-
|
|
89
|
+
let stopping: Promise<void> | undefined;
|
|
90
|
+
return {
|
|
91
|
+
port: (server.address() as AddressInfo).port,
|
|
92
|
+
stop: () => stopping ??= (async () => {
|
|
93
|
+
const closed = new Promise<void>((done, reject) => server.close((error) => error ? reject(error) : done()));
|
|
94
|
+
const pending = [...requests].map((request) => new Promise<void>((done) => { request.once('close', () => done()); request.destroy(); }));
|
|
95
|
+
const connections = [...sockets].map((socket) => new Promise<void>((done) => { socket.once('close', () => done()); socket.destroy(); }));
|
|
96
|
+
await Promise.all([closed, ...pending, ...connections]);
|
|
97
|
+
})(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A served socket either opens within its five-second handshake or closes under the same owner. */
|
|
102
|
+
export function openWebSocket(socket: WebSocket): Promise<boolean> {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
let settled = false;
|
|
105
|
+
const finish = (opened: boolean) => {
|
|
106
|
+
if (settled) return;
|
|
107
|
+
settled = true;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
socket.removeEventListener('open', openedSocket);
|
|
110
|
+
socket.removeEventListener('error', failed);
|
|
111
|
+
socket.removeEventListener('close', failed);
|
|
112
|
+
try { if (!opened) socket.close(); }
|
|
113
|
+
catch (error) { reject(error); return; }
|
|
114
|
+
resolve(opened);
|
|
115
|
+
};
|
|
116
|
+
const openedSocket = () => finish(true);
|
|
117
|
+
const failed = () => finish(false);
|
|
118
|
+
const timer = setTimeout(failed, 5000);
|
|
119
|
+
socket.addEventListener('open', openedSocket, { once: true });
|
|
120
|
+
socket.addEventListener('error', failed, { once: true });
|
|
121
|
+
socket.addEventListener('close', failed, { once: true });
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One request and its complete body share the check's ten-second lifetime, including caller cancellation. */
|
|
126
|
+
export async function readHttpResponse(fetcher: (request: Request) => Promise<Response>, request: Request): Promise<{
|
|
127
|
+
status: number; headers: Record<string, string>; text: string; bodyBytes: Uint8Array;
|
|
128
|
+
bodyError?: { message: string; cancelled: boolean };
|
|
129
|
+
}> {
|
|
130
|
+
request.signal.throwIfAborted();
|
|
131
|
+
const abort = new AbortController();
|
|
132
|
+
const signal = AbortSignal.any([request.signal, abort.signal]);
|
|
133
|
+
const timer = setTimeout(() => abort.abort(new DOMException('request exceeded 10 s', 'TimeoutError')), 10_000);
|
|
134
|
+
let cancel: (() => void) | undefined;
|
|
135
|
+
const interrupted = new Promise<never>((_, reject) => {
|
|
136
|
+
cancel = () => reject(signal.reason);
|
|
137
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
138
|
+
if (signal.aborted) cancel();
|
|
139
|
+
});
|
|
140
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
141
|
+
try {
|
|
142
|
+
const arriving = fetcher(new Request(request, { signal })).then((response) => {
|
|
143
|
+
if (signal.aborted) void response.body?.cancel(signal.reason).catch(() => undefined);
|
|
144
|
+
return response;
|
|
145
|
+
});
|
|
146
|
+
const response = await Promise.race([arriving, interrupted]);
|
|
147
|
+
reader = response.body?.getReader();
|
|
148
|
+
const chunks: Uint8Array[] = [];
|
|
149
|
+
let size = 0;
|
|
150
|
+
let bodyError: { message: string; cancelled: boolean } | undefined;
|
|
151
|
+
try {
|
|
152
|
+
if (reader) for (;;) {
|
|
153
|
+
const chunk = await Promise.race([reader.read(), interrupted]);
|
|
154
|
+
if (chunk.done) break;
|
|
155
|
+
chunks.push(chunk.value); size += chunk.value.byteLength;
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
bodyError = { message: String(error), cancelled: signal.aborted || error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name) };
|
|
159
|
+
void reader?.cancel(error).catch(() => undefined);
|
|
160
|
+
}
|
|
161
|
+
const bodyBytes = new Uint8Array(size);
|
|
162
|
+
let offset = 0;
|
|
163
|
+
for (const chunk of chunks) { bodyBytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
164
|
+
const cookies = response.headers.getSetCookie();
|
|
165
|
+
return { status: response.status, headers: { ...Object.fromEntries(response.headers), ...(cookies.length ? { 'set-cookie': cookies.join(', ') } : {}) },
|
|
166
|
+
text: new TextDecoder().decode(bodyBytes), bodyBytes, ...(bodyError ? { bodyError } : {}) };
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
if (cancel) signal.removeEventListener('abort', cancel);
|
|
170
|
+
reader?.releaseLock();
|
|
171
|
+
}
|
|
68
172
|
}
|
|
69
173
|
|
|
70
|
-
/** HTTP to a served pack, with the vendor Host and manual redirects.
|
|
71
|
-
* its caller's abort signal
|
|
174
|
+
/** HTTP to a served pack, with the vendor Host and manual redirects. Headers resolve before the streamed body;
|
|
175
|
+
* its caller's abort signal owns the fresh connection until the body ends or is cancelled. */
|
|
72
176
|
export function servedHttpFetch(port: number): typeof fetch {
|
|
73
177
|
return (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
74
178
|
const request = new Request(input, init);
|
|
@@ -79,21 +183,40 @@ export function servedHttpFetch(port: number): typeof fetch {
|
|
|
79
183
|
const signal = init?.signal ?? (input instanceof Request ? input.signal : request.signal);
|
|
80
184
|
return new Promise<Response>((resolve, reject) => {
|
|
81
185
|
const cleanup = () => signal.removeEventListener('abort', abort);
|
|
82
|
-
|
|
186
|
+
let responseFailed: ((error: Error) => void) | undefined;
|
|
187
|
+
const failed = (error: Error) => { cleanup(); if (responseFailed) responseFailed(error); else reject(error); };
|
|
83
188
|
const sent = httpRequest({ host: '127.0.0.1', port, method: request.method, path: `${url.pathname}${url.search}`, headers, agent: false }, (res) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
res.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
189
|
+
res.pause();
|
|
190
|
+
const answer = new Headers();
|
|
191
|
+
for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) for (const one of Array.isArray(v) ? v : [v]) answer.append(k, one);
|
|
192
|
+
const chunks: Uint8Array[] = [];
|
|
193
|
+
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
194
|
+
let ended = false, cancelled = false, bodyError: Error | undefined;
|
|
195
|
+
const pump = () => {
|
|
196
|
+
if (cancelled) return;
|
|
197
|
+
while (chunks.length && (controller.desiredSize ?? 0) > 0) controller.enqueue(chunks.shift()!);
|
|
198
|
+
if (chunks.length || (controller.desiredSize ?? 0) <= 0) { res.pause(); return; }
|
|
199
|
+
// Do not error until the reader has taken queued bytes: an errored web stream discards its queue.
|
|
200
|
+
if (bodyError) { cancelled = true; controller.error(bodyError); }
|
|
201
|
+
else if (ended) { cancelled = true; controller.close(); }
|
|
202
|
+
else res.resume();
|
|
203
|
+
};
|
|
204
|
+
const body = new ReadableStream<Uint8Array>({
|
|
205
|
+
start(value) { controller = value; },
|
|
206
|
+
pull() { pump(); },
|
|
207
|
+
cancel() { cancelled = true; chunks.length = 0; cleanup(); res.destroy(); sent.destroy(); },
|
|
92
208
|
});
|
|
93
|
-
|
|
209
|
+
responseFailed = (error) => { bodyError ??= error; cleanup(); pump(); };
|
|
210
|
+
res.on('data', (chunk: Buffer) => { chunks.push(chunk); pump(); });
|
|
211
|
+
res.on('end', () => { ended = true; cleanup(); pump(); });
|
|
212
|
+
res.on('error', responseFailed);
|
|
213
|
+
res.on('close', () => { if (!res.complete && !cancelled) responseFailed!(new Error('HTTP response closed before completion')); });
|
|
214
|
+
const noBody = request.method === 'HEAD' || res.statusCode === 204 || res.statusCode === 205 || res.statusCode === 304;
|
|
215
|
+
try { resolve(new Response(noBody ? null : body, { status: res.statusCode ?? 0, headers: answer })); if (noBody) res.resume(); }
|
|
216
|
+
catch (error) { cancelled = true; cleanup(); res.destroy(); reject(error); }
|
|
94
217
|
});
|
|
95
218
|
const abort = () => {
|
|
96
|
-
const error = new Error('served HTTP request aborted');
|
|
219
|
+
const error = signal.reason instanceof Error ? signal.reason : new Error('served HTTP request aborted');
|
|
97
220
|
sent.destroy(error);
|
|
98
221
|
failed(error);
|
|
99
222
|
};
|
package/src/clients/jira.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
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
4
|
// person makes them at Atlassian's sign-up and account console.
|
|
5
5
|
import type { ClientCase, ClientContext } from './harness.ts';
|
|
6
|
+
import { standardPackageFile } from '../package-file.ts';
|
|
7
|
+
|
|
8
|
+
const { importSdk } = await import(new URL('import.mjs', standardPackageFile('client-sdks/')).href);
|
|
9
|
+
const { Version3Client, HttpException }: typeof import('jira.js') = await importSdk('jira.js');
|
|
6
10
|
|
|
7
11
|
const README = 'https://www.npmjs.com/package/jira.js/v/5.4.0';
|
|
8
12
|
const CREATE = 'https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post';
|
|
@@ -18,7 +22,6 @@ async function site(c: ClientContext, slug: string, apiToken?: string) {
|
|
|
18
22
|
const email = `admin@${slug}.example`;
|
|
19
23
|
const made = await door('/_twin/sites', { name: slug, admin: { name: 'Admin', email, password: 'sdk-case-2026' } });
|
|
20
24
|
const token = apiToken ?? (await door('/_twin/api-token', { site: slug, email, password: 'sdk-case-2026' })).apiToken!;
|
|
21
|
-
const { Version3Client } = await import('jira.js');
|
|
22
25
|
const jira = new Version3Client({ host: `https://${slug}.atlassian.net`, authentication: { basic: { email, apiToken: token } }, baseRequestConfig: { httpsAgent: c.agent } });
|
|
23
26
|
return { jira, admin: made.admin! };
|
|
24
27
|
}
|
|
@@ -59,7 +62,6 @@ export const cases: ClientCase[] = [
|
|
|
59
62
|
asks: 'a request with an API token the account never made is refused, and the client throws its HttpException carrying the status',
|
|
60
63
|
source: `${README} "\`HttpException\`: Server responded with an error (includes parsed error details)"`,
|
|
61
64
|
async run(c) {
|
|
62
|
-
const { HttpException } = await import('jira.js');
|
|
63
65
|
const { jira } = await site(c, 'sdk-refused', 'ATATT-not-a-token-this-account-made');
|
|
64
66
|
try {
|
|
65
67
|
await jira.projects.searchProjects({ maxResults: 50 });
|
package/src/clients/run.ts
CHANGED
|
@@ -12,19 +12,21 @@ const { cases } = (await import(`./${job.vendor}${extname(fileURLToPath(import.m
|
|
|
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
|
|
14
14
|
const proxy = await worldProxy(job.port);
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
require(
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
15
|
+
try {
|
|
16
|
+
// Bind only this artifact's declared vendor hosts to the assessed server. The enclosing World's
|
|
17
|
+
// injector stays active, and untwinned destinations keep its refusal policy.
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
require('@volter/world-core/pack-facts').extendPackFacts([job.packFacts]);
|
|
20
|
+
const facts = JSON.parse(readFileSync(job.packFacts, 'utf8')).packs[job.vendor];
|
|
21
|
+
const keys: string[] = [...new Set<string>((facts.hosts ?? []).map((rule: { key?: string }) => rule.key ?? job.vendor))];
|
|
22
|
+
require('@volter/world-core/inject').install(Object.fromEntries(keys.map((key) => [key, `http://127.0.0.1:${proxy.port}`])));
|
|
23
|
+
const context = clientContext(proxy.port, job.host, job.env, job.scenarioPath, job.raw ? job.port : undefined);
|
|
24
|
+
for (const c of cases) {
|
|
25
|
+
const failures = await Promise.race([
|
|
26
|
+
c.run(context).catch((e: Error) => [`the case threw: ${e.message}`]),
|
|
27
|
+
new Promise<string[]>((r) => setTimeout(() => r(['no outcome in 30 s']), 30_000)),
|
|
28
|
+
]);
|
|
29
|
+
process.stdout.write(`${JSON.stringify({ id: c.id, asks: c.asks, failures })}\n`);
|
|
30
|
+
}
|
|
31
|
+
} finally { await proxy.stop(); }
|
|
30
32
|
process.exit(0);
|