@parity/product-lab 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +27 -0
- package/adapters/browser/playwright/README.md +2 -0
- package/adapters/browser/playwright/src/page-frame-capture.js +10 -2
- package/adapters/chain/papi/README.md +3 -1
- package/adapters/environment/engine/README.md +22 -4
- package/adapters/environment/engine/src/engine/engine-config.d.ts +1 -0
- package/adapters/environment/engine/src/engine/fork-bundle.d.ts +1 -1
- package/adapters/environment/engine/src/engine/fork-bundle.js +2 -2
- package/adapters/environment/engine/src/engine/fork-refresh.d.ts +4 -7
- package/adapters/environment/engine/src/engine/fork-refresh.js +52 -23
- package/adapters/environment/engine/src/engine/published-bundle.d.ts +19 -15
- package/adapters/environment/engine/src/engine/published-bundle.js +44 -51
- package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.d.ts +1 -1
- package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.js +5 -2
- package/adapters/environment/engine/src/saved-fork/saved-fork.js +1 -1
- package/adapters/environment/engine/src/setup/engine-build.d.ts +4 -4
- package/adapters/environment/engine/src/setup/engine-build.js +14 -4
- package/adapters/environment/engine/src/setup/patch-catalogue.d.ts +6 -0
- package/adapters/environment/engine/src/setup/patch-catalogue.js +5 -0
- package/adapters/environment/engine/src/setup/prepare-engine.d.ts +6 -5
- package/adapters/environment/engine/src/setup/prepare-engine.js +27 -10
- package/adapters/features/cucumber/README.md +4 -0
- package/adapters/features/cucumber/src/steps/register-feature-steps.js +15 -1
- package/adapters/system/node/README.md +3 -3
- package/adapters/video/ffmpeg/README.md +4 -0
- package/adapters/video/ffmpeg/src/ffmpeg-video-editor.d.ts +1 -1
- package/adapters/video/ffmpeg/src/ffmpeg-video-editor.js +19 -6
- package/core/application/README.md +30 -1
- package/core/application/src/features/direct-feature-steps.d.ts +23 -0
- package/core/application/src/features/direct-feature-steps.js +25 -0
- package/core/application/src/features/feature-recording.d.ts +6 -0
- package/core/application/src/features/feature-recording.js +3 -0
- package/core/application/src/features/feature-scenario.d.ts +10 -8
- package/core/application/src/features/feature-scenario.js +11 -15
- package/core/application/src/features/feature-step-times.d.ts +11 -1
- package/core/application/src/features/feature-step-times.js +20 -6
- package/core/application/src/features/lab-feature-session.d.ts +3 -1
- package/core/application/src/features/lab-feature-session.js +5 -2
- package/core/application/src/features/run-feature-steps.d.ts +3 -1
- package/core/application/src/features/run-feature-steps.js +4 -2
- package/core/application/src/lab-runtime-use-cases.js +5 -1
- package/core/application/src/recordings/demo-markup.d.ts +8 -0
- package/core/application/src/recordings/demo-markup.js +11 -1
- package/core/application/src/recordings/demo-recording.d.ts +0 -2
- package/core/application/src/recordings/demo-recording.js +40 -23
- package/core/application/src/recordings/frame-sources.d.ts +20 -1
- package/core/application/src/recordings/frame-sources.js +89 -0
- package/core/application/src/recordings/record-runs.d.ts +7 -1
- package/core/application/src/recordings/record-runs.js +66 -11
- package/core/application/src/recordings/recording-draft.d.ts +4 -0
- package/core/application/src/recordings/recording-draft.js +13 -4
- package/core/domain/README.md +14 -0
- package/core/domain/src/recordings/demo-cut.js +6 -4
- package/core/domain/src/recordings/demo-holds.d.ts +12 -0
- package/core/domain/src/recordings/demo-holds.js +31 -0
- package/core/domain/src/recordings/index.d.ts +1 -0
- package/core/domain/src/recordings/index.js +1 -0
- package/core/domain/src/recordings/recording-data.d.ts +9 -1
- package/core/domain/src/recordings/recording-settings.d.ts +5 -1
- package/core/domain/src/recordings/recording-settings.js +7 -0
- package/core/domain/src/recordings/run-recording.d.ts +2 -0
- package/core/domain/src/recordings/run-recording.js +8 -1
- package/core/ports/README.md +6 -0
- package/core/ports/src/driven/recording/demo-ports.d.ts +2 -0
- package/core/ports/src/driven/recording/frame-capture.d.ts +4 -0
- package/core/ports/src/driving/demo.d.ts +2 -0
- package/core/ports/src/driving/feature-session.d.ts +7 -0
- package/entrypoints/cli/README.md +6 -3
- package/entrypoints/cli/src/demo-command.d.ts +1 -1
- package/entrypoints/cli/src/demo-command.js +10 -5
- package/entrypoints/mcp/README.md +3 -0
- package/entrypoints/panel/README.md +6 -3
- package/entrypoints/panel/src/visible/detached-identities.js +5 -1
- package/entrypoints/panel/src/visible/window-frames.js +14 -1
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Copyright 2026 Parity Technologies (UK) Ltd.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
3
5
|
import { join } from "node:path";
|
|
4
|
-
import { createGunzip, constants as zlib } from "node:zlib";
|
|
5
6
|
import { forkBundleManifestPath, forkSnapshotFrom } from "./fork-snapshot.js";
|
|
6
7
|
import { BUNDLE_MARKER, markForkBundle, verifyForkBundle } from "./fork-bundle.js";
|
|
7
8
|
export const MANIFEST_SEARCH_BYTES = 48 * 1024 * 1024;
|
|
@@ -92,65 +93,23 @@ export async function publishedBundle(input) {
|
|
|
92
93
|
if (!response.ok)
|
|
93
94
|
throw new Error(`GitHub answered ${response.status} for ${repository}@${tag}`);
|
|
94
95
|
const release = (await response.json());
|
|
96
|
+
if (!release || typeof release !== "object")
|
|
97
|
+
throw new Error(`${repository}@${tag} release response is not an object`);
|
|
95
98
|
const found = release.assets?.find((entry) => entry.name === asset);
|
|
96
99
|
if (!found)
|
|
97
100
|
throw new Error(`${repository}@${tag} publishes no ${asset}`);
|
|
98
|
-
|
|
101
|
+
if (typeof found.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(found.digest))
|
|
102
|
+
throw new Error(`${repository}@${tag} ${asset} requires a sha256: digest with 64 lowercase hex digits from GitHub`);
|
|
99
103
|
return {
|
|
100
104
|
repository,
|
|
101
105
|
tag,
|
|
102
106
|
asset,
|
|
103
107
|
url: found.browser_download_url,
|
|
104
108
|
size: found.size,
|
|
105
|
-
|
|
109
|
+
digest: found.digest,
|
|
106
110
|
updatedAt: found.updated_at,
|
|
107
|
-
snapshot: forkSnapshotFrom(manifest),
|
|
108
111
|
};
|
|
109
112
|
}
|
|
110
|
-
async function manifestFromStream(url, fetchImpl, signal) {
|
|
111
|
-
const stop = new AbortController();
|
|
112
|
-
const response = await fetchImpl(url, {
|
|
113
|
-
headers: { range: `bytes=0-${MANIFEST_SEARCH_BYTES - 1}`, "user-agent": "product-lab" },
|
|
114
|
-
signal: signal ? AbortSignal.any([signal, stop.signal]) : stop.signal,
|
|
115
|
-
});
|
|
116
|
-
if (!response.ok || !response.body)
|
|
117
|
-
throw new Error(`the bundle download answered ${response.status}`);
|
|
118
|
-
const gunzip = createGunzip({ finishFlush: zlib.Z_SYNC_FLUSH });
|
|
119
|
-
const scanner = new TarManifestScanner();
|
|
120
|
-
let found;
|
|
121
|
-
let failure;
|
|
122
|
-
gunzip.on("data", (chunk) => {
|
|
123
|
-
if (found !== undefined || failure !== undefined)
|
|
124
|
-
return;
|
|
125
|
-
try {
|
|
126
|
-
found = scanner.push(chunk);
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
failure = error;
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
const reader = response.body.getReader();
|
|
133
|
-
try {
|
|
134
|
-
while (found === undefined && failure === undefined) {
|
|
135
|
-
const { done, value } = await reader.read();
|
|
136
|
-
if (value)
|
|
137
|
-
await new Promise((resolve, reject) => gunzip.write(value, (error) => (error ? reject(error) : resolve())));
|
|
138
|
-
await new Promise((resolve) => gunzip.flush(zlib.Z_SYNC_FLUSH, () => resolve()));
|
|
139
|
-
if (done)
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
finally {
|
|
144
|
-
stop.abort();
|
|
145
|
-
gunzip.destroy();
|
|
146
|
-
await reader.cancel().catch(() => undefined);
|
|
147
|
-
}
|
|
148
|
-
if (failure !== undefined)
|
|
149
|
-
throw failure;
|
|
150
|
-
if (found === undefined)
|
|
151
|
-
throw new Error(`no manifest.json in the bundle's first ${MANIFEST_SEARCH_BYTES / 1024 / 1024} MB`);
|
|
152
|
-
return found;
|
|
153
|
-
}
|
|
154
113
|
export function bundleLocations(workspace, network) {
|
|
155
114
|
const current = join(workspace, `fork-bundle-${network}`);
|
|
156
115
|
return { current, next: `${current}.next`, partial: `${current}.next.partial` };
|
|
@@ -174,6 +133,17 @@ export function currentBundle(files, workspace, network) {
|
|
|
174
133
|
const directory = bundleLocations(workspace, network).current;
|
|
175
134
|
return usableBundle(files, directory) ? forkSnapshotFrom(files.readJson(forkBundleManifestPath(workspace, network)), forkBundleManifestPath(workspace, network)) : undefined;
|
|
176
135
|
}
|
|
136
|
+
export function bundleAssetDigest(files, directory) {
|
|
137
|
+
try {
|
|
138
|
+
const marker = files.readJson(join(directory, BUNDLE_MARKER));
|
|
139
|
+
return typeof marker?.assetDigest === "string" && /^sha256:[0-9a-f]{64}$/.test(marker.assetDigest) ? marker.assetDigest : undefined;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof SyntaxError)
|
|
143
|
+
return undefined;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
177
147
|
export function bittenAfter(a, b) {
|
|
178
148
|
if (!a || !b)
|
|
179
149
|
return false;
|
|
@@ -200,14 +170,29 @@ export function startPrefetch(input) {
|
|
|
200
170
|
env: { ...input.environment.all(), PPN_HOME: input.engine.workspace, PPN_NETWORK: input.network, PATH: path },
|
|
201
171
|
});
|
|
202
172
|
}
|
|
203
|
-
export async function finishPrefetch(files, workspace, network, signal) {
|
|
204
|
-
const { partial } = bundleLocations(workspace, network);
|
|
173
|
+
export async function finishPrefetch(files, workspace, network, signal, assetDigest) {
|
|
174
|
+
const { partial, next, current } = bundleLocations(workspace, network);
|
|
205
175
|
const check = await verifyForkBundle(partial, signal);
|
|
206
176
|
if (!check.complete) {
|
|
207
177
|
files.remove(partial);
|
|
208
178
|
throw new Error(`the download in ${partial} is not a complete bundle (${check.problem}); it was removed`);
|
|
209
179
|
}
|
|
210
|
-
markForkBundle(partial, check.snapshots, "download");
|
|
180
|
+
markForkBundle(partial, check.snapshots, "download", assetDigest);
|
|
181
|
+
if (assetDigest) {
|
|
182
|
+
const downloaded = files.readJson(join(partial, BUNDLE_MARKER));
|
|
183
|
+
for (const directory of [next, current]) {
|
|
184
|
+
if (!usableBundle(files, directory))
|
|
185
|
+
continue;
|
|
186
|
+
if (bundleAssetDigest(files, directory) === assetDigest)
|
|
187
|
+
continue;
|
|
188
|
+
const manifestSha256 = createHash("sha256").update(readFileSync(join(directory, "manifest.json"))).digest("hex");
|
|
189
|
+
if (manifestSha256 !== downloaded.manifestSha256)
|
|
190
|
+
continue;
|
|
191
|
+
const local = await verifyForkBundle(directory, signal);
|
|
192
|
+
if (local.complete)
|
|
193
|
+
markForkBundle(directory, local.snapshots, "verified in place", assetDigest);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
211
196
|
return completePrefetch(files, workspace, network);
|
|
212
197
|
}
|
|
213
198
|
export function completePrefetch(files, workspace, network) {
|
|
@@ -215,6 +200,14 @@ export function completePrefetch(files, workspace, network) {
|
|
|
215
200
|
if (!verifiedBundle(files, partial))
|
|
216
201
|
throw new Error(`the download in ${partial} is not a verified bundle (no manifest.json, no snapshots or not verified complete)`);
|
|
217
202
|
const snapshot = forkSnapshotFrom(files.readJson(join(partial, "manifest.json")));
|
|
203
|
+
const staged = stagedBundle(files, workspace, network);
|
|
204
|
+
const installed = currentBundle(files, workspace, network);
|
|
205
|
+
const newest = [staged, installed].filter((entry) => !!entry)
|
|
206
|
+
.sort((a, b) => Date.parse(b.bittenAt) - Date.parse(a.bittenAt))[0];
|
|
207
|
+
if (newest && !bittenAfter(snapshot, newest)) {
|
|
208
|
+
files.remove(partial);
|
|
209
|
+
return newest;
|
|
210
|
+
}
|
|
218
211
|
files.remove(next);
|
|
219
212
|
if (!files.rename(partial, next))
|
|
220
213
|
throw new Error(`the completed bundle disappeared from ${partial}`);
|
|
@@ -9,7 +9,7 @@ export declare const savedForkEngineState: (cacheDir: string) => string;
|
|
|
9
9
|
export declare const savedForkSetupCommand: (fork: SavedFork, settings: Pick<SavedForkSettings, "setupCommand">) => string;
|
|
10
10
|
/**
|
|
11
11
|
* The prepared Engine a saved fork starts from, or `EngineNotPrepared` naming the command that prepares it. A start
|
|
12
|
-
* takes the recorded stock build (`as-prepared`), including an explicitly selected ref. Retired
|
|
12
|
+
* takes the recorded stock or node-workspace build (`as-prepared`), including an explicitly selected ref. Retired owned-processes records
|
|
13
13
|
* are refused. `ref`: when supplied, also check that commit.
|
|
14
14
|
*/
|
|
15
15
|
export declare function savedForkEngine(fork: SavedFork, settings: EngineSettings, commands: SyncCommandRunner, patch?: PreparedEnginePatch, ref?: string): PreparedEngine;
|
|
@@ -13,9 +13,12 @@ export function engineBuildWords(engine) {
|
|
|
13
13
|
const ref = build.resolvedRef.slice(0, 12);
|
|
14
14
|
const from = build.repository && build.repository !== defaultEngineRepository ? ` from ${build.repository}` : "";
|
|
15
15
|
const tested = build.resolvedRef === testedEngineRef ? "" : ` (not the tested ${testedEngineRef.slice(0, 12)}: \`product-lab fetch\` without --engine-ref prepares it)`;
|
|
16
|
-
|
|
16
|
+
const patch = recordedPatch(build);
|
|
17
|
+
return patch === "none"
|
|
17
18
|
? `stock Engine ${ref}${from}${tested}, ${engine.checkout}`
|
|
18
|
-
:
|
|
19
|
+
: patch === "node-workspace"
|
|
20
|
+
? `Engine ${ref}${from} with node-workspace patch, ${engine.checkout}`
|
|
21
|
+
: `retired patched Engine ${ref}${from}; prepare stock Engine again, ${engine.checkout}`;
|
|
19
22
|
}
|
|
20
23
|
function syncCommands(system) {
|
|
21
24
|
if (!system.syncCommands)
|
|
@@ -231,7 +231,7 @@ export function savedForkEnvironment(fork, settings) {
|
|
|
231
231
|
dashboard: `http://127.0.0.1:${ports.DASHBOARD_PORT}`, extraPath: engine.extraPath,
|
|
232
232
|
startupTimeoutMs: SAVED_FORK_STARTUP_MS, ports, nodePorts: { from: block.base + 900, to: block.base + 949 },
|
|
233
233
|
template: true, isolate: true, expose: false, ...(fork.topology ? { forkTopology: fork.topology } : {}),
|
|
234
|
-
|
|
234
|
+
enginePatch: recordedPatch(prepared.build),
|
|
235
235
|
...(settings.txpoolLog ? { txpoolLog: settings.txpoolLog } : {}), ...(settings.bulletinAuthoring ? { bulletinAuthoring: settings.bulletinAuthoring } : {}),
|
|
236
236
|
seed: networkSeedDirectory(settings.seedsDir, { network: NETWORK, checkout: engine.checkout, ...(fork.topology ? { topology: fork.topology } : {}) }),
|
|
237
237
|
});
|
|
@@ -3,11 +3,11 @@ import type { SyncCommandRunner } from "@parity/product-lab/ports/system";
|
|
|
3
3
|
export declare const NODE_VERIFIER_OPTION = "--node-verifier";
|
|
4
4
|
export type NodeVerifier = "metric" | "none";
|
|
5
5
|
export declare function nodeVerifierArgs(verifier: NodeVerifier | undefined): string[];
|
|
6
|
-
/**
|
|
7
|
-
export declare const ENGINE_PATCHES: readonly ["owned-processes", "none"];
|
|
6
|
+
/** Stock and the optional repository fix; owned-processes is recognized only to refuse stale configurations. */
|
|
7
|
+
export declare const ENGINE_PATCHES: readonly ["owned-processes", "none", "node-workspace"];
|
|
8
8
|
export type EnginePatch = (typeof ENGINE_PATCHES)[number];
|
|
9
9
|
export declare const ENGINE_PATCH_RETIRED = "the owned-processes Engine patch is retired; prepare stock Engine v0.11.0 instead";
|
|
10
10
|
/** The build must exist. The prepared checkout's pin and record are checked by preparedEngine. */
|
|
11
|
-
export declare function engineBuildIssue(checkout: string,
|
|
12
|
-
/** Before every start, refuse retired
|
|
11
|
+
export declare function engineBuildIssue(checkout: string, commands: SyncCommandRunner, patch?: EnginePatch): string | undefined;
|
|
12
|
+
/** Before every start, verify the optional patch and refuse retired configurations and incomplete builds. */
|
|
13
13
|
export declare function requireEngineBuild(checkout: string, commands: SyncCommandRunner, patch?: EnginePatch): void;
|
|
@@ -2,19 +2,29 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { resolvePatch } from "./patches.js";
|
|
6
|
+
import { NODE_WORKSPACE_PATCH } from "./patch-catalogue.js";
|
|
5
7
|
import { engineSetupCommand } from "./setup-command.js";
|
|
6
8
|
export const NODE_VERIFIER_OPTION = "--node-verifier";
|
|
7
9
|
export function nodeVerifierArgs(verifier) {
|
|
8
10
|
return verifier ? [NODE_VERIFIER_OPTION, verifier] : [];
|
|
9
11
|
}
|
|
10
|
-
export const ENGINE_PATCHES = ["owned-processes", "none"];
|
|
12
|
+
export const ENGINE_PATCHES = ["owned-processes", "none", "node-workspace"];
|
|
11
13
|
export const ENGINE_PATCH_RETIRED = "the owned-processes Engine patch is retired; prepare stock Engine v0.11.0 instead";
|
|
12
|
-
export function engineBuildIssue(checkout,
|
|
13
|
-
if (patch !== "none")
|
|
14
|
+
export function engineBuildIssue(checkout, commands, patch = "none") {
|
|
15
|
+
if (patch !== "none" && patch !== "node-workspace")
|
|
14
16
|
return ENGINE_PATCH_RETIRED;
|
|
17
|
+
if (patch === "node-workspace") {
|
|
18
|
+
const resolved = resolvePatch("previewnet-engine", patch);
|
|
19
|
+
if (resolved.sha256 !== NODE_WORKSPACE_PATCH.sha256 || resolved.base !== NODE_WORKSPACE_PATCH.base)
|
|
20
|
+
return "node-workspace patch differs from its tested catalogue";
|
|
21
|
+
const check = commands.run({ command: "git", args: ["apply", "--reverse", "--check", resolved.path], cwd: checkout, timeoutMs: 30_000 });
|
|
22
|
+
if (check.code !== 0)
|
|
23
|
+
return "node-workspace patch is missing or changed in the Engine checkout";
|
|
24
|
+
}
|
|
15
25
|
for (const file of ["packages/cli/dist/commands/start.js", "packages/cli/dist/cli.js"])
|
|
16
26
|
if (!existsSync(join(checkout, file)))
|
|
17
|
-
return `the stock Engine checkout at ${checkout} is not built (no ${file})`;
|
|
27
|
+
return `the ${patch === "none" ? "stock" : "patched"} Engine checkout at ${checkout} is not built (no ${file})`;
|
|
18
28
|
return undefined;
|
|
19
29
|
}
|
|
20
30
|
export function requireEngineBuild(checkout, commands, patch = "none") {
|
|
@@ -18,3 +18,9 @@ export declare const LIGHT_TOPOLOGY_OVERLAY: {
|
|
|
18
18
|
};
|
|
19
19
|
/** The descriptor overlays a fork layout names (`ForkLayout.descriptors`), by manifest entry. */
|
|
20
20
|
export declare const DESCRIPTOR_OVERLAYS: Readonly<Record<string, unknown>>;
|
|
21
|
+
/** Optional repository-only fix; stock preparation remains the default. */
|
|
22
|
+
export declare const NODE_WORKSPACE_PATCH: {
|
|
23
|
+
readonly name: "node-workspace";
|
|
24
|
+
readonly base: "e11792b234ca0c39a5fc1d47af491f82fc8e2670";
|
|
25
|
+
readonly sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757";
|
|
26
|
+
};
|
|
@@ -13,3 +13,8 @@ export const OWNED_PROCESSES_PATCH = {
|
|
|
13
13
|
};
|
|
14
14
|
export const LIGHT_TOPOLOGY_OVERLAY = { relay: { validators: 4 } };
|
|
15
15
|
export const DESCRIPTOR_OVERLAYS = { "light-topology": LIGHT_TOPOLOGY_OVERLAY };
|
|
16
|
+
export const NODE_WORKSPACE_PATCH = {
|
|
17
|
+
name: "node-workspace",
|
|
18
|
+
base: ENGINE_RELEASE.base,
|
|
19
|
+
sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757",
|
|
20
|
+
};
|
|
@@ -29,6 +29,7 @@ export interface EngineSource {
|
|
|
29
29
|
/**
|
|
30
30
|
* `none`: stock Engine at the ref, without the owned-processes patch (an Engine that carries the fixes upstream),
|
|
31
31
|
* recorded as such (engine-build.json `patch: "none"`, an empty `patchSha256`; engine.json `enginePatch: "none"`).
|
|
32
|
+
* `node-workspace`: repository-only native-node workspace fix at the tested base.
|
|
32
33
|
* Default `none`. The retired `owned-processes` value is refused.
|
|
33
34
|
*/
|
|
34
35
|
readonly patch?: EnginePatch;
|
|
@@ -47,7 +48,7 @@ export interface EngineBuildRecord {
|
|
|
47
48
|
repository?: string;
|
|
48
49
|
/** `none`: stock Engine, without the owned-processes patch (absent in an older record: the patch). */
|
|
49
50
|
patch?: EnginePatch;
|
|
50
|
-
/** The
|
|
51
|
+
/** The selected patch's digest; empty for a stock build. */
|
|
51
52
|
patchSha256: string;
|
|
52
53
|
preparedAt: string;
|
|
53
54
|
}
|
|
@@ -68,15 +69,15 @@ export interface PreparedEngine {
|
|
|
68
69
|
export declare class EngineNotPrepared extends Error {
|
|
69
70
|
readonly reason: string;
|
|
70
71
|
readonly fix: string;
|
|
71
|
-
/** The requested
|
|
72
|
+
/** The requested build or a retired legacy choice. */
|
|
72
73
|
readonly patch: EnginePatch;
|
|
73
74
|
constructor(reason: string, fix: string,
|
|
74
|
-
/** The requested
|
|
75
|
+
/** The requested build or a retired legacy choice. */
|
|
75
76
|
patch?: EnginePatch);
|
|
76
77
|
}
|
|
77
|
-
/** `as-prepared` accepts
|
|
78
|
+
/** `as-prepared` accepts stock or the current repository fix; legacy owned-processes records are refused. */
|
|
78
79
|
export type PreparedEnginePatch = EnginePatch | "as-prepared";
|
|
79
|
-
/** Check the record against the pin (or explicit ref), checkout HEAD,
|
|
80
|
+
/** Check the record against the pin (or explicit ref), checkout HEAD, selected digest, build and dot CLI. */
|
|
80
81
|
export declare function preparedEngine(root: string, options: {
|
|
81
82
|
stateDirectory?: string;
|
|
82
83
|
fix: string;
|
|
@@ -4,7 +4,8 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { checkedSync } from "./sync-command.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolvePatch } from "./patches.js";
|
|
8
|
+
import { ENGINE_RELEASE, NODE_WORKSPACE_PATCH } from "./patch-catalogue.js";
|
|
8
9
|
import { ENGINE_PATCH_RETIRED, engineBuildIssue } from "./engine-build.js";
|
|
9
10
|
import { applyTurnRelayChoice, turnRelayAvailable } from "../engine/turn-relay.js";
|
|
10
11
|
export const defaultStateDirectory = ".product-lab";
|
|
@@ -41,8 +42,12 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
|
|
|
41
42
|
throw new Error("Network must be previewnet or paseo-next-v2");
|
|
42
43
|
setupPaths(root, ref, "", stateDirectory);
|
|
43
44
|
const repository = engineRepository(source);
|
|
44
|
-
if (source.patch && source.patch !== "none")
|
|
45
|
+
if (source.patch && source.patch !== "none" && source.patch !== "node-workspace")
|
|
45
46
|
throw new Error(ENGINE_PATCH_RETIRED);
|
|
47
|
+
const patch = source.patch ?? "none";
|
|
48
|
+
const patchFile = patch === "node-workspace" ? resolvePatch("previewnet-engine", patch) : undefined;
|
|
49
|
+
if (patchFile && (patchFile.sha256 !== NODE_WORKSPACE_PATCH.sha256 || patchFile.base !== NODE_WORKSPACE_PATCH.base))
|
|
50
|
+
throw new Error("node-workspace patch differs from its tested catalogue");
|
|
46
51
|
const output = source.output ?? "inherit";
|
|
47
52
|
const revisions = /^[0-9a-f]{40}$/i.test(ref)
|
|
48
53
|
? [[ref, ref]]
|
|
@@ -65,7 +70,9 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
|
|
|
65
70
|
revisions[0])?.[0];
|
|
66
71
|
if (!resolvedRef || !/^[0-9a-f]{40}$/i.test(resolvedRef))
|
|
67
72
|
throw new Error("Could not resolve an immutable Engine revision");
|
|
68
|
-
|
|
73
|
+
if (patchFile && resolvedRef !== patchFile.base)
|
|
74
|
+
throw new Error("node-workspace patch requires its tested Engine base");
|
|
75
|
+
const paths = setupPaths(root, resolvedRef, patchFile?.sha256 ?? "", stateDirectory);
|
|
69
76
|
mkdirSync(paths.base, { recursive: true });
|
|
70
77
|
const run = (command, args, cwd = paths.checkout) => checkedSync(commands, { command, args, cwd, output, timeoutMs: 1_800_000 });
|
|
71
78
|
if (!existsSync(join(paths.checkout, ".git"))) {
|
|
@@ -83,6 +90,13 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
|
|
|
83
90
|
}
|
|
84
91
|
else if (head !== resolvedRef)
|
|
85
92
|
throw new Error("Prepared Engine checkout changed; refusing to overwrite it");
|
|
93
|
+
if (patchFile) {
|
|
94
|
+
const applied = commands.run({ command: "git", args: ["apply", "--reverse", "--check", patchFile.path], cwd: paths.checkout, timeoutMs: 30_000 });
|
|
95
|
+
if (applied.code !== 0) {
|
|
96
|
+
run("git", ["apply", "--check", patchFile.path]);
|
|
97
|
+
run("git", ["apply", patchFile.path]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
86
100
|
const manifest = JSON.parse(readFileSync(join(paths.checkout, "package.json"), "utf8"));
|
|
87
101
|
if (manifest.packageManager !== "pnpm@10.33.0")
|
|
88
102
|
throw new Error("Engine package manager changed; review setup before updating its toolchain");
|
|
@@ -113,7 +127,7 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
|
|
|
113
127
|
dashboard: "http://127.0.0.1:8090",
|
|
114
128
|
extraPath: [dotBin],
|
|
115
129
|
startupTimeoutMs: 1_800_000,
|
|
116
|
-
enginePatch:
|
|
130
|
+
enginePatch: patch,
|
|
117
131
|
};
|
|
118
132
|
const configFile = resolve(root, stateDirectory, "engine.json");
|
|
119
133
|
writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
|
|
@@ -121,8 +135,8 @@ export function prepareEngine(root, ref, network, commands, stateDirectory = def
|
|
|
121
135
|
requestedRef: ref,
|
|
122
136
|
resolvedRef,
|
|
123
137
|
repository,
|
|
124
|
-
patch
|
|
125
|
-
patchSha256: "",
|
|
138
|
+
patch,
|
|
139
|
+
patchSha256: patchFile?.sha256 ?? "",
|
|
126
140
|
preparedAt: new Date().toISOString(),
|
|
127
141
|
}, null, 2) + "\n");
|
|
128
142
|
return configFile;
|
|
@@ -163,16 +177,19 @@ export function preparedEngine(root, options) {
|
|
|
163
177
|
throw refuse(prepared === "none"
|
|
164
178
|
? `${stateDirectory} holds stock Engine ${String(build.resolvedRef).slice(0, 12)} (prepared without the owned-processes patch), not the patched one`
|
|
165
179
|
: `${stateDirectory} holds the patched Engine ${String(build.resolvedRef).slice(0, 12)}, not stock Engine`, wanted);
|
|
166
|
-
if (wanted !== "none")
|
|
180
|
+
if (wanted !== "none" && wanted !== "node-workspace")
|
|
167
181
|
throw refuse(ENGINE_PATCH_RETIRED, wanted);
|
|
168
182
|
const ref = options.ref ?? (options.patch === "as-prepared" ? build.resolvedRef : testedEngineRef);
|
|
169
183
|
if (!/^[0-9a-f]{40}$/i.test(build.resolvedRef))
|
|
170
184
|
throw refuse(`${recordFile} has no immutable Engine revision`, wanted);
|
|
171
185
|
if (/^[0-9a-f]{40}$/i.test(ref) && build.resolvedRef !== ref)
|
|
172
186
|
throw refuse(`${stateDirectory} holds Engine ${String(build.resolvedRef).slice(0, 12)}, this checkout of product-lab tests ${ref.slice(0, 12)}`, wanted);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
187
|
+
const digest = wanted === "node-workspace" ? NODE_WORKSPACE_PATCH.sha256 : "";
|
|
188
|
+
if (wanted === "node-workspace" && build.resolvedRef !== NODE_WORKSPACE_PATCH.base)
|
|
189
|
+
throw refuse("node-workspace patch requires its tested Engine base", wanted);
|
|
190
|
+
if (build.patchSha256 !== digest)
|
|
191
|
+
throw refuse(wanted === "none" ? `${recordFile} records a patch digest for stock Engine` : `${recordFile} records another node-workspace patch digest`, wanted);
|
|
192
|
+
const paths = setupPaths(stateDirectory, build.resolvedRef, digest, ".");
|
|
176
193
|
const issue = existsSync(join(paths.checkout, ".git")) ? engineBuildIssue(paths.checkout, options.commands, wanted) : `there is no checkout at ${paths.checkout}`;
|
|
177
194
|
if (issue)
|
|
178
195
|
throw refuse(issue, wanted);
|
|
@@ -83,3 +83,7 @@ and the run report. `error` remains the original failure text, so explanation ru
|
|
|
83
83
|
text-judged controls cannot match diagnostic words. Status and failure reason remain those of the original result. A normal
|
|
84
84
|
settled step gets no extra attachment. `entrypoints/cli/tests/abort-diagnostics.test.ts` exercises
|
|
85
85
|
this through a timed-out generated step.
|
|
86
|
+
|
|
87
|
+
## Held feature demos
|
|
88
|
+
|
|
89
|
+
registerFeatureSteps calls the optional startStep/finishStep observers for direct/custom definitions. Generated steps time themselves. The recording AfterStep hook is registered after the pause hook because Cucumber runs AfterStep hooks in reverse order, so video recording's bounded after-state capture finishes before a pause. Default frames-only capture stays asynchronous. In-process tests cover this order and custom definitions.
|
|
@@ -156,8 +156,9 @@ export function registerFeatureSteps(session, options = {}) {
|
|
|
156
156
|
});
|
|
157
157
|
api.BeforeStep(function ({ pickleStep }) {
|
|
158
158
|
const state = scenarios.get(this);
|
|
159
|
-
if (state)
|
|
159
|
+
if (state) {
|
|
160
160
|
state.current = state.ids.indexOf(pickleStep.id);
|
|
161
|
+
}
|
|
161
162
|
});
|
|
162
163
|
if (options.beforeStep) {
|
|
163
164
|
const gate = options.beforeStep;
|
|
@@ -166,6 +167,10 @@ export function registerFeatureSteps(session, options = {}) {
|
|
|
166
167
|
await gated(gate({ scenario: pickle.name, index: state ? state.ids.indexOf(pickleStep.id) : -1, text: pickleStep.text, status: "pending" }));
|
|
167
168
|
});
|
|
168
169
|
}
|
|
170
|
+
api.BeforeStep(function () {
|
|
171
|
+
const state = scenarios.get(this);
|
|
172
|
+
state?.scenario.startStep?.(state.current);
|
|
173
|
+
});
|
|
169
174
|
for (const view of sentences) {
|
|
170
175
|
const body = async function () {
|
|
171
176
|
const state = scenarios.get(this);
|
|
@@ -199,6 +204,15 @@ export function registerFeatureSteps(session, options = {}) {
|
|
|
199
204
|
await gated(gate({ scenario: pickle.name, index: state ? state.ids.indexOf(pickleStep.id) : -1, text: pickleStep.text, status: String(result.status).toLowerCase() }));
|
|
200
205
|
});
|
|
201
206
|
}
|
|
207
|
+
api.AfterStep({ timeout: hookTimeout }, async function ({ result }) {
|
|
208
|
+
const state = scenarios.get(this);
|
|
209
|
+
if (!state)
|
|
210
|
+
return;
|
|
211
|
+
const status = String(result.status).toUpperCase();
|
|
212
|
+
if (status === "SKIPPED")
|
|
213
|
+
return;
|
|
214
|
+
await state.scenario.finishStep?.(state.current, { status: status === "PASSED" ? "passed" : "failed", ...(result.message ? { error: result.message } : {}) });
|
|
215
|
+
});
|
|
202
216
|
api.After({ name: "product-lab: end the scenario", timeout: hookTimeout }, async function ({ pickle, result }) {
|
|
203
217
|
const state = scenarios.get(this);
|
|
204
218
|
scenarios.delete(this);
|
|
@@ -9,7 +9,7 @@ Node implementations of the system ports in [`@product-lab/ports/system`](../../
|
|
|
9
9
|
| `GitChangeSource` | `ChangeSource` | Phase 7: files changed between the merge base of a base revision and HEAD, plus uncommitted and untracked ones, relative to the lab's directory (`../` outside it); read-only git, no fetch; an unknown base or an option-like revision is refused; every git command runs through a `CommandRunner` (default `NodeCommandRunner`) bounded by `timeoutMs` (default 30 000 ms), a non-zero exit rejects with git's first stderr line, and an uncommitted file that cannot be read rejects `revision` rather than counting as deleted (`tests/git-change-source.test.ts`, a real temporary repository). Backlog 37: `commits` (first-parent commits that changed the lab's directory between two, oldest first; refused when the first is not an ancestor), `checkout` (`git worktree add --detach` into a new directory, with the lockfile at its top and its sha256; the lab's checkout untouched) and `removeCheckout` (forced remove, the directory deleted, `git worktree prune`) |
|
|
10
10
|
| `NodeSourceWatcher` | `SourceWatcher` | Hot reload of app code (backlog item 5): `fs.watch`, recursive on a watched directory (FSEvents on macOS, inotify on Linux), a watched file or a path not created yet through its nearest existing parent directory; reports changed paths relative to the lab with `/` separators (a watch of `.`, the lab's directory, reports every change under it, an editor's safe write by rename included), not debounced (the application's `AppReloads` debounces). `digest`: sha256 over each regular file's relative path and content in path order, an ignored directory skipped whole, symbolic links not followed. `tests/node-source-watcher.test.ts` (real temporary directory) |
|
|
11
11
|
| `NodeConditionedLinks` | `ConditionedLinks` (backlog 31) | One identity's conditioned link to one loopback endpoint (`node-conditioned-links.ts`): a TCP proxy on a port the caller reserved, carrying every byte as sent through the domain's `ConditionedWire` (one per direction of each connection: in order, a burst at its speed, half the round trip each way while throttled); offline refuses new connections and cuts the open ones; half-closes travel through the wire, so an answer sent before its server closes still arrives. A plain `http:` or `ws:` endpoint is named as the request's `Host` in the first request head of each connection (`request-head.ts`: a local Substrate node answers "Provided Host header is not whitelisted" to any other), and a plain request is made its connection's only one (`Connection: close`); a WebSocket upgrade keeps its connection, and bytes that do not start a request pass as sent. A public endpoint is refused, never proxied. Backpressure is not modelled. `tests/node-conditioned-links.test.ts` (real loopback sockets) |
|
|
12
|
-
| `NodeChainGates` | `ChainGates` (backlog 92) | Origin gates in front of local chain nodes (`node-chain-gates.ts`): one loopback TCP relay per plain `ws:` loopback chain route that accepts connections (a closed port, TLS and a public node are kept as they are). It holds each connection's first request head, decides it once, then pipes bytes both ways (backpressure, no per-message parsing). A request with neither `Origin` nor `Sec-Fetch-Site` (a native host, Node's fetch and WebSocket) passes; a browser's passes only from an admitted origin (`admit`), otherwise the connection is reset unanswered (a 403 would still resolve a no-cors fetch) and recorded (`refused()`: chain, gate URL, kind, origin, time). The node sees its own address as `Host` and a plain request is its connection's only one (`request-head.ts`), so no later request on a kept-alive connection skips the gate. A half-close travels on for a grace (5 s, `halfCloseGraceMs`), then both sides are cut; either side closing or failing destroys both; `close()` cuts everything. Substrate's own `--rpc-cors` cannot do this: its RPC server adds CORS headers but refuses no WebSocket upgrade. `tests/node-chain-gates.test.ts` (real loopback sockets: a closed upstream, a half-closed client, keep-alive, close) |
|
|
12
|
+
| `NodeChainGates` | `ChainGates` (backlog 92) | Origin gates in front of local chain nodes (`node-chain-gates.ts`): one loopback TCP relay per plain `ws:` loopback chain route that accepts connections (a closed port, TLS and a public node are kept as they are). It holds each connection's first request head, decides it once, then pipes bytes both ways (backpressure, no per-message parsing). A request with neither `Origin` nor `Sec-Fetch-Site` (a native host, Node's fetch and WebSocket) passes; a browser's passes only from an admitted origin (`admit`), otherwise the connection is reset unanswered (a 403 would still resolve a no-cors fetch) and recorded (`refused()`: chain, gate URL, kind, origin, time). The node sees its own address as `Host` and a plain request is its connection's only one (`request-head.ts`), so no later request on a kept-alive connection skips the gate. A half-close travels on for a grace (5 s, `halfCloseGraceMs`), then both sides are cut; either side closing or failing destroys both; `close()` cuts everything. Substrate's own `--rpc-cors` cannot do this: its RPC server adds CORS headers but refuses no WebSocket upgrade. `tests/node-chain-gates.test.ts` (real loopback sockets: a closed upstream, a half-closed client, keep-alive, close; the fake node does not record a plain `GET` with no browser mark, which is what a local port probe sends, and the test sends one to both ports) |
|
|
13
13
|
| `NodeAppProxies` | `AppProxies` | The lab's proxy in front of an app's dev server (backlog items 64 to 66; `src/app-proxy/`): an HTTP reverse proxy on a loopback port that passes every request and answer as sent, except that an HTML document the browser loads gets the host's bridge script (the domain's `injectScriptTag`; then `no-store`, no validators), bodies are asked for uncompressed and documents unconditionally, a redirect to exactly the dev server's origin names the proxy, and the connection to the dev server is the proxy's own (one per request, since dev servers close idle kept-alive sockets). It reads the document's framing headers and never changes them (`framing()`). WebSockets are tunnelled byte for byte (`web-socket-tunnel.ts`); an update socket it understands (`VITE_UPDATES` in `vite-updates.ts`, Vite's `vite-hmr` subprotocol, checked against Vite 7.3.7) is read message by message: each announced update is put to the policy and forwarded or dropped, the same announcement on several pages' sockets is one update, and the page's side stays open when the dev server's closes (`lost`). A socket or an event stream it does not understand passes untouched and is reported once (`unknown-socket`), never for a declared dev server. `tests/node-app-proxy.test.ts` (real loopback servers, Node's WebSocket client; controls: an asset, a fragment, a conditional request, a dead dev server) |
|
|
14
14
|
| `RandomIdGenerator` | `IdGenerator` | random UUIDs |
|
|
15
15
|
| `ConsoleLogger` | `Logger` | level filter; details as JSON; with `redactor` (a `Redactor`'s `text`), every line scrubbed before it is written (backlog 89: the composition root passes the runtime's, so a provider logging a secret of the lab never prints it) |
|
|
@@ -70,7 +70,7 @@ The app-proxy fixture registers listener cleanup before proxy startup; an occupi
|
|
|
70
70
|
|
|
71
71
|
The process-tree readiness controls count the anchor, command and child separately; the gated child must appear before readiness passes.
|
|
72
72
|
|
|
73
|
-
`bunx nx run system-node:test`: real process trees for the runner (registry in memory; `tests/owned-group-anchor.test.ts`: a command exiting 0 or non-zero before any refresh with a descendant in its group, an escaped `setsid` descendant pinned as not covered, a stop during a start, a failed recording, a spawn error, a crashed controller (`tests/anchor-controller.ts`, registry on disk through `tests/disk-files.ts`) while the command runs and after it exits, reused pids, the anchor's own bounds, the command's default and named `cwd`, and a start never acknowledged: `tests/unacknowledging-anchor.mjs` as `anchor.path`, silent (stopped) or dying (refused, record kept)), real one-shot commands for `NodeCommandRunner` (exit code, cwd, env overlay and removal, stdin; timeout, abort, pre-aborted, a missing command and a zero timeout rejected; descendants: one holding the output after the command exits (settles within `drainMs`, group killed), a timeout with one in the group (both killed, on time), one that left the group (not killed, still settles by timeout + drain), and controls: a descendant with closed output neither delays nor fails a run, and 4 MiB of output is read in full with a 50 ms drain), fakes for the process table (EPERM, survivors, group capture), locks (liveness, clock), JSON-RPC transports and the connection probe (lsof output, process trees, exit 1 with a partial listing, a bounded `stop()`, and real `ps`/`lsof` with a watched process that exits), real loopback sockets for ports (a block found free at the start of the run from an OS-assigned port, or `SYSTEM_NODE_TEST_PORT_BASE`). `tests/node-process-sandbox.test.ts`: platform selection, construction and profile/plan-wrapping unit tests plus live smoke tests (gated to where `sandbox-exec` is supported) that spawn a wrapped process and assert a public connect gets `EPERM` while loopback still works, and record that the machine's own address counts as `localhost`. `tests/linux-namespace-sandbox.test.ts`: the probe and the wrap against fakes everywhere; on Linux where the probe passes, live smoke tests (this machine's own non-loopback address reachable unsandboxed and `ENETUNREACH` sandboxed, a public address refused at once, a declared loopback port reached through the forwarder and an undeclared one refused although it listens, a declared listen port reached from outside, exit codes and SIGTERM passed through) and a leaky control (a plan left unwrapped fails the same deny check, "reached … outside loopback"). `tools/linux` runs them on Linux in a container. On disk and across a crashed owner process: `tools/verify/tests/system-adapters.test.ts`.
|
|
73
|
+
`bunx nx run system-node:test`: real process trees for the runner (registry in memory; `tests/owned-group-anchor.test.ts`: a command exiting 0 or non-zero before any refresh with a descendant in its group, an escaped `setsid` descendant pinned as not covered, a stop during a start, a failed recording, a spawn error, a crashed controller (`tests/anchor-controller.ts`, registry on disk through `tests/disk-files.ts`) while the command runs and after it exits, reused pids, the anchor's own bounds, the command's default and named `cwd`, and a start never acknowledged: `tests/unacknowledging-anchor.mjs` as `anchor.path`, silent (stopped) or dying (refused, record kept)), real one-shot commands for `NodeCommandRunner` (exit code, cwd, env overlay and removal, stdin; timeout, abort, pre-aborted, a missing command and a zero timeout rejected; descendants: one holding the output after the command exits (settles within `drainMs`, group killed), a timeout with one in the group (both killed, on time), one that left the group (not killed, still settles by timeout + drain), and controls: a descendant with closed output neither delays nor fails a run, and 4 MiB of output is read in full with a 50 ms drain), fakes for the process table (EPERM, survivors, group capture), locks (liveness, clock), JSON-RPC transports and the connection probe (lsof output, process trees, exit 1 with a partial listing, a bounded `stop()`, and real `ps`/`lsof` with a watched process that exits), real loopback sockets for ports (a block found free at the start of the run from an OS-assigned port, or `SYSTEM_NODE_TEST_PORT_BASE`). `tests/node-process-sandbox.test.ts`: platform selection, construction and profile/plan-wrapping unit tests plus live smoke tests (gated to where `sandbox-exec` is supported) that spawn a wrapped process and assert a public connect gets `EPERM` while loopback still works, and record that the machine's own address counts as `localhost`. Their greeter cuts every connection still open when it closes, since it never reads one; the machine-address test sends a local port probe itself and is bounded (30 s), so a close that never ends fails it instead of hanging the file. `tests/linux-namespace-sandbox.test.ts`: the probe and the wrap against fakes everywhere; on Linux where the probe passes, live smoke tests (this machine's own non-loopback address reachable unsandboxed and `ENETUNREACH` sandboxed, a public address refused at once, a declared loopback port reached through the forwarder and an undeclared one refused although it listens, a declared listen port reached from outside, exit codes and SIGTERM passed through) and a leaky control (a plan left unwrapped fails the same deny check, "reached … outside loopback"). `tools/linux` runs them on Linux in a container. On disk and across a crashed owner process: `tools/verify/tests/system-adapters.test.ts`.
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
## Source revisions for compatibility
|
|
@@ -107,7 +107,7 @@ API-name correction: `bridge()` is a method on the `AppProxy` returned by `NodeA
|
|
|
107
107
|
|
|
108
108
|
One 3000 ms deadline covers the exact application-path HEAD collision probe and native GET. Native headers are capped at 16 KiB and the script at 2 MiB. Redirects are never followed; caller cookies and conditionals are never sent to the host. A successful non-HTML application asset at the generated path is refused with 409. HTML SPA fallback is accepted, but an intentional HTML route is indistinguishable from it. Unsupported HEAD replies refuse with explicit 502; this check does not atomically protect against later application route changes. The whole namespace is reserved, including missing/expired URLs; consumers must keep their routes outside it. The native socket remains at its original endpoint, so `script-src 'self'` still needs a suitable `connect-src` allowance. Nonce/hash-only scripts and strict-dynamic are unqualified.
|
|
109
109
|
|
|
110
|
-
Focused checks: `node --import tsx --test adapters/system/node/tests/node-app-bridge.test.ts adapters/system/node/tests/node-app-proxy.test.ts`. `APP_BRIDGE_TEST_PORT_BASE` selects the new fixture block (default 45300; one distinct ten-port slice per case); `APP_PROXY_TEST_PORT_BASE` optionally selects distinct ports for the older proxy tests, whose default remains independent OS allocation. All fixture listeners close with their tests.
|
|
110
|
+
Focused checks: `node --import tsx --test adapters/system/node/tests/node-app-bridge.test.ts adapters/system/node/tests/node-app-proxy.test.ts`. `APP_BRIDGE_TEST_PORT_BASE` selects the new fixture block (default 45300; one distinct ten-port slice per case); `APP_PROXY_TEST_PORT_BASE` optionally selects distinct ports for the older proxy tests, whose default remains independent OS allocation. All fixture listeners close with their tests. The bridge tests count, and wait for, only reads of a native server's bound path, so a local port probe's `GET /` is not taken for a read.
|
|
111
111
|
|
|
112
112
|
## Explicit Next App Router profile (1 October 2026)
|
|
113
113
|
|
|
@@ -37,3 +37,7 @@ generated video cut into an MP4 and a WebM of the planned length with an overlay
|
|
|
37
37
|
where ffmpeg is not installed).
|
|
38
38
|
|
|
39
39
|
Private, workspace-only package. Consumers will install the single umbrella package `@parity/product-lab` once it is published.
|
|
40
|
+
|
|
41
|
+
## Held feature demos
|
|
42
|
+
|
|
43
|
+
VideoEditRequest.stills supplies exactly one PNG per segment; the adapter loops each at 25 fps for its planned duration, concatenates them, and encodes the result. No source-video timestamp is substituted for a missing still. The existing trim/speed/overlay path is retained when stills are absent. An offline integration test checks encoded midpoint pixels, duration, caption placement and failure visibility.
|
|
@@ -6,14 +6,19 @@ import { join } from "node:path";
|
|
|
6
6
|
const FRAME_RATE = 25;
|
|
7
7
|
const INSTALL = "macOS: brew install ffmpeg; Debian/Ubuntu: apt install ffmpeg; or set PRODUCT_LAB_FFMPEG to its folder";
|
|
8
8
|
const seconds = (ms) => (ms / 1000).toFixed(3);
|
|
9
|
-
export function demoFilterGraph(plan, overlays, size) {
|
|
9
|
+
export function demoFilterGraph(plan, overlays, size, stills = false) {
|
|
10
10
|
if (!plan.segments.length)
|
|
11
11
|
throw new Error("The demo plan has no segment to cut");
|
|
12
12
|
const { width, height } = size;
|
|
13
13
|
const frame = 1000 / FRAME_RATE;
|
|
14
14
|
const parts = plan.segments.map((segment, index) => {
|
|
15
15
|
const shown = segment.outputEnd - segment.outputStart;
|
|
16
|
-
const filters = [
|
|
16
|
+
const filters = stills ? [
|
|
17
|
+
"setpts=PTS-STARTPTS", `fps=${FRAME_RATE}`,
|
|
18
|
+
`scale=${width}:${height}:force_original_aspect_ratio=decrease`,
|
|
19
|
+
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=0xf6f6f4`, "setsar=1",
|
|
20
|
+
`trim=duration=${seconds(shown)}`,
|
|
21
|
+
] : [
|
|
17
22
|
`trim=start=${seconds(segment.sourceStart)}:end=${seconds(Math.max(segment.sourceEnd, segment.sourceStart + frame + 1))}`,
|
|
18
23
|
`setpts=(PTS-STARTPTS)/${segment.speed.toFixed(4)}`,
|
|
19
24
|
`fps=${FRAME_RATE}`,
|
|
@@ -24,10 +29,10 @@ export function demoFilterGraph(plan, overlays, size) {
|
|
|
24
29
|
`trim=duration=${seconds(shown)}`,
|
|
25
30
|
"setpts=PTS-STARTPTS",
|
|
26
31
|
];
|
|
27
|
-
return `[0:v]${filters.join(",")}[s${index}]`;
|
|
32
|
+
return `[${stills ? index : 0}:v]${filters.join(",")}[s${index}]`;
|
|
28
33
|
});
|
|
29
34
|
const concat = `${plan.segments.map((_, index) => `[s${index}]`).join("")}concat=n=${plan.segments.length}:v=1:a=0[cut0]`;
|
|
30
|
-
const layers = overlays.map((overlay, index) => `[cut${index}][${index + 1}:v]overlay=0:0:enable='between(t,${seconds(overlay.startMs)},${seconds(Math.max(overlay.endMs - 1, overlay.startMs))})'[cut${index + 1}]`);
|
|
35
|
+
const layers = overlays.map((overlay, index) => `[cut${index}][${index + (stills ? plan.segments.length : 1)}:v]overlay=0:0:enable='between(t,${seconds(overlay.startMs)},${seconds(Math.max(overlay.endMs - 1, overlay.startMs))})'[cut${index + 1}]`);
|
|
31
36
|
return { graph: [...parts, concat, ...layers].join(";"), output: `cut${overlays.length}` };
|
|
32
37
|
}
|
|
33
38
|
export class FfmpegVideoEditor {
|
|
@@ -86,15 +91,23 @@ export class FfmpegVideoEditor {
|
|
|
86
91
|
async render(request) {
|
|
87
92
|
const work = mkdtempSync(join(tmpdir(), "product-lab-demo-"));
|
|
88
93
|
try {
|
|
94
|
+
if (request.stills && request.stills.length !== request.plan.segments.length)
|
|
95
|
+
throw new Error("A demo needs exactly one still per segment");
|
|
96
|
+
const sources = request.stills ? request.stills.flatMap((png, index) => {
|
|
97
|
+
const file = join(work, `step-${index}.png`);
|
|
98
|
+
writeFileSync(file, png);
|
|
99
|
+
const segment = request.plan.segments[index];
|
|
100
|
+
return ["-loop", "1", "-framerate", String(FRAME_RATE), "-t", seconds(segment.outputEnd - segment.outputStart), "-i", file];
|
|
101
|
+
}) : ["-i", request.source];
|
|
89
102
|
const inputs = request.overlays.flatMap((overlay, index) => {
|
|
90
103
|
const file = join(work, `overlay-${index}.png`);
|
|
91
104
|
writeFileSync(file, overlay.png);
|
|
92
105
|
return ["-i", file];
|
|
93
106
|
});
|
|
94
|
-
const { graph, output } = demoFilterGraph(request.plan, request.overlays, request.size);
|
|
107
|
+
const { graph, output } = demoFilterGraph(request.plan, request.overlays, request.size, !!request.stills);
|
|
95
108
|
const encode = request.format === "mp4" ? ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-movflags", "+faststart"] : ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "34", "-row-mt", "1"];
|
|
96
109
|
const partial = `${request.out}.partial.${request.format}`;
|
|
97
|
-
await this.run(this.ffmpeg, ["-v", "error", "-y",
|
|
110
|
+
await this.run(this.ffmpeg, ["-v", "error", "-y", ...sources, ...inputs, "-filter_complex", graph, "-map", `[${output}]`, "-r", String(FRAME_RATE), ...encode, partial], { timeoutMs: this.timeoutMs, ...(request.signal ? { signal: request.signal } : {}) });
|
|
98
111
|
renameSync(partial, request.out);
|
|
99
112
|
return { file: request.out, bytes: statSync(request.out).size, durationMs: await this.durationMs(request.out) };
|
|
100
113
|
}
|