mirror of
https://github.com/samuelncui/yatm.git
synced 2026-11-08 04:18:03 +00:00
Speed up development and release candidate verification
This commit is contained in:
1 parent
18bdddbd34
commit
fa821facf4
45 files changed
+2091
-238
No files matched your search
@@ -63,6 +63,8 @@ jobs:
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_COMMIT: ${{ github.sha }}
|
||||
VITE_YATM_VERSION: ${{ inputs.version }}
|
||||
VITE_YATM_COMMIT: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -87,7 +89,7 @@ jobs:
|
||||
- name: Install the Preview fixture generator
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential pkg-config libx264-dev libx265-dev zlib1g-dev
|
||||
sudo apt-get install -y build-essential pkg-config ccache libx264-dev libx265-dev zlib1g-dev
|
||||
bash build/release/build-test-ffmpeg.sh "$RUNNER_TEMP/preview-test-tools"
|
||||
echo "$RUNNER_TEMP/preview-test-tools/bin" >> "$GITHUB_PATH"
|
||||
- name: Install the pinned protobuf generators
|
||||
@@ -102,6 +104,13 @@ jobs:
|
||||
echo "$RUNNER_TEMP/protoc/bin" >> "$GITHUB_PATH"
|
||||
- name: Run the release source preflight
|
||||
run: make release-check
|
||||
- name: Collect the checked frontend and its notices
|
||||
run: bash build/release/build-frontend.sh "$RUNNER_TEMP/shared-frontend" frontend/dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: shared-frontend
|
||||
path: ${{ runner.temp }}/shared-frontend
|
||||
if-no-files-found: error
|
||||
|
||||
build:
|
||||
needs: [prepare, source-check]
|
||||
@@ -152,6 +161,7 @@ jobs:
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
OSARCH_TESTED: ${{ matrix.tested }}
|
||||
FRONTEND_ARTIFACT_DIRECTORY: ${{ runner.temp }}/shared-frontend
|
||||
CGO_ENABLED: ${{ matrix.goos == 'linux' && '1' || '0' }}
|
||||
|
||||
steps:
|
||||
@@ -188,9 +198,10 @@ jobs:
|
||||
tar -xJf "$archive" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/zig-x86_64-linux-0.15.2" >> "$GITHUB_PATH"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
name: shared-frontend
|
||||
path: ${{ runner.temp }}/shared-frontend
|
||||
|
||||
- name: Build
|
||||
run: make release
|
||||
@@ -219,6 +230,7 @@ jobs:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
CCACHE_DIR: ${{ runner.temp }}/preview-ccache
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -235,12 +247,23 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential pkg-config patchelf libx264-dev libx265-dev zlib1g-dev
|
||||
sudo apt-get install -y build-essential pkg-config patchelf ccache libx264-dev libx265-dev zlib1g-dev
|
||||
bash build/release/build-test-ffmpeg.sh "$RUNNER_TEMP/preview-test-tools"
|
||||
echo "$RUNNER_TEMP/preview-test-tools/bin" >> "$GITHUB_PATH"
|
||||
- name: Install macOS native build and fixture tools
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install pkgconf ffmpeg
|
||||
run: brew install pkgconf ffmpeg ccache
|
||||
- id: compiler-cache
|
||||
name: Identify native compiler cache inputs
|
||||
env:
|
||||
PREVIEW_TARGET: ${{ matrix.target }}
|
||||
run: echo "key=$(bash previewworker/build/compiler-cache.sh key)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.CCACHE_DIR }}
|
||||
key: preview-native-${{ matrix.target }}-${{ steps.compiler-cache.outputs.key }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
preview-native-${{ matrix.target }}-${{ steps.compiler-cache.outputs.key }}-
|
||||
- name: Verify the Preview fixture generator
|
||||
run: node build/release/check-preview-fixtures.mjs
|
||||
- name: Prepare verified CC0 camera fixtures
|
||||
@@ -249,6 +272,9 @@ jobs:
|
||||
echo "YATM_TEST_RAW_DIRECTORY=$RUNNER_TEMP/raw-fixtures" >> "$GITHUB_ENV"
|
||||
- name: Build and test the optional native helper
|
||||
run: make preview
|
||||
- name: Report native compiler cache statistics
|
||||
if: always()
|
||||
run: ccache --show-stats
|
||||
- name: Test relocation from the archive
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -59,7 +59,7 @@ Follow the document that owns each area. The [architecture overview](docs/archit
|
||||
|
||||
## Verification
|
||||
|
||||
Use existing verification entrypoints first and maintain recurring checks with the implementation under the [test maintenance policy](docs/operations/testing.md#test-maintenance). Performance-sensitive changes follow [Performance Acceptance](docs/operations/testing.md#performance-acceptance).
|
||||
Use existing verification entrypoints first and maintain recurring checks with the implementation under the [test maintenance policy](docs/operations/testing.md#test-maintenance). Choose affected checks during development/review and retain unaffected evidence under [Check Scope and Reuse](docs/operations/testing.md#check-scope-and-reuse); reserve full verification for final integration/release. Performance-sensitive changes follow [Performance Acceptance](docs/operations/testing.md#performance-acceptance).
|
||||
|
||||
Before remote E2E acceptance or NAS delivery, check ignored top-level workspace directories for an `AGENTS.md` and read any applicable operator notes. Such notes provide host context only and do not authorize production actions.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help build backend backend-linux frontend release release-check preview generate demo check test-race test-e2e test-performance-tools performance-collect performance-check
|
||||
.PHONY: help build backend backend-linux frontend release release-check preview generate demo demo-dev check test-race test-e2e test-performance-tools performance-collect performance-check
|
||||
|
||||
PERF_TIER ?= fast
|
||||
|
||||
@@ -15,7 +15,8 @@ help:
|
||||
' preview Build the optional native Preview release (RELEASE_VERSION required)' \
|
||||
' generate Generate Go, TypeScript and legacy protobuf code' \
|
||||
' demo Start the local Demo (YATM_DEMO_RESET=1 resets its fixture)' \
|
||||
' check Run Go, frontend and build-tool checks' \
|
||||
' demo-dev Start the Demo backend and Vite hot reload' \
|
||||
' check Run full integration checks (use affected runners during review)' \
|
||||
' test-race Run Go race checks' \
|
||||
' test-e2e Run CLI E2E against explicitly selected candidate binaries' \
|
||||
' test-package-tools Check the local SSH package-acceptance controller' \
|
||||
@@ -46,6 +47,10 @@ generate:
|
||||
demo:
|
||||
./dev/demo.sh
|
||||
|
||||
demo-dev:
|
||||
./dev/demo.sh --dev
|
||||
|
||||
# Development/review uses affected native runners; this is the explicit full integration gate.
|
||||
check:
|
||||
node dev/check-documents.mjs
|
||||
go run ./dev/check-idl.go
|
||||
@@ -53,12 +58,12 @@ check:
|
||||
go vet ./...
|
||||
go vet -tags=e2e ./e2e
|
||||
pnpm --dir frontend check
|
||||
node --test build/backend/*.test.mjs build/release/*.test.mjs previewworker/build/*.test.mjs e2e/ltfs-file-backend/*.test.mjs
|
||||
node --test dev/demo.test.mjs build/backend/*.test.mjs build/release/*.test.mjs previewworker/build/*.test.mjs e2e/ltfs-file-backend/*.test.mjs
|
||||
$(MAKE) test-performance-tools
|
||||
$(MAKE) test-package-tools
|
||||
./dev/check-shell.sh
|
||||
|
||||
# Run once before the platform matrix; individual packagers also enforce committed inputs.
|
||||
# Candidate CI runs this once before the platform matrix; no duplicate local preflight is required.
|
||||
release-check:
|
||||
node build/release/check-source.mjs
|
||||
node build/release/check-content.mjs source . HEAD
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Collect the frontend once, optionally reusing the dist produced by source preflight.
|
||||
set -euo pipefail
|
||||
REPOSITORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
: "${1:?Usage: build-frontend.sh <new-artifact-directory> [checked-dist-directory]}"
|
||||
OUTPUT_DIRECTORY="$1"
|
||||
[[ "$OUTPUT_DIRECTORY" == /* ]] || OUTPUT_DIRECTORY="$PWD/$OUTPUT_DIRECTORY"
|
||||
DIST_DIRECTORY="${2:-}"
|
||||
[[ -z "$DIST_DIRECTORY" || "$DIST_DIRECTORY" == /* ]] || DIST_DIRECTORY="$PWD/$DIST_DIRECTORY"
|
||||
[[ ! -e "$OUTPUT_DIRECTORY" ]] || { echo 'Frontend artifact directory must be new' >&2; exit 1; }
|
||||
export OUTPUT_DIRECTORY
|
||||
cd "$REPOSITORY"
|
||||
if [[ -n "$DIST_DIRECTORY" ]]; then
|
||||
mkdir -p "$OUTPUT_DIRECTORY/frontend"
|
||||
cp -R "$DIST_DIRECTORY/." "$OUTPUT_DIRECTORY/frontend/"
|
||||
else
|
||||
bash frontend/scripts/build.sh
|
||||
fi
|
||||
node build/release/build-licenses.mjs "$OUTPUT_DIRECTORY/licenses" frontend
|
||||
node build/release/frontend-artifact.mjs create "$OUTPUT_DIRECTORY"
|
||||
@@ -8,8 +8,13 @@ const repository = path.resolve(import.meta.dirname, "../..");
|
||||
const output = path.resolve(process.argv[2] || path.join(repository, "output/licenses"));
|
||||
process.chdir(repository);
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const records = [];
|
||||
if ((process.env.GOOS || execFileSync('go', ['env', 'GOOS'], { encoding: 'utf8' }).trim()) === 'linux') {
|
||||
const mode = process.argv[3] || 'all';
|
||||
if (!['all', 'frontend', 'backend'].includes(mode)) throw new Error('Unknown notice collection mode');
|
||||
const records = mode === 'backend' ? JSON.parse(fs.readFileSync(path.join(output, 'dependencies.json'), 'utf8')) : [];
|
||||
if (mode === 'backend' && (!Array.isArray(records) || !records.length || records.some(item => item.kind !== 'node'))) {
|
||||
throw new Error('Backend notice collection requires the validated frontend notices');
|
||||
}
|
||||
if (mode !== 'frontend' && (process.env.GOOS || execFileSync('go', ['env', 'GOOS'], { encoding: 'utf8' }).trim()) === 'linux') {
|
||||
records.push(...copyMuslNotices(output));
|
||||
}
|
||||
|
||||
@@ -41,18 +46,21 @@ function copyNotices(kind, name, version, directory, license = "") {
|
||||
}
|
||||
|
||||
// Go reports precisely the modules used by the five target programs, not test-only modules.
|
||||
const modules = new Map();
|
||||
const lines = execFileSync("go", ["list", "-deps", "-f", "{{with .Module}}{{.Path}}\t{{.Version}}\t{{.Dir}}{{end}}",
|
||||
"./cmd/httpd", "./cmd/yatm-cli", "./cmd/export-library", "./cmd/lto-info", "./cmd/migrate"], { encoding: "utf8" });
|
||||
for (const line of lines.split("\n")) {
|
||||
const [name, version, directory] = line.split("\t");
|
||||
if (name && version && directory) modules.set(`${name}@${version}`, { name, version, directory });
|
||||
if (mode !== 'frontend') {
|
||||
const modules = new Map();
|
||||
const lines = execFileSync("go", ["list", "-deps", "-f", "{{with .Module}}{{.Path}}\t{{.Version}}\t{{.Dir}}{{end}}",
|
||||
"./cmd/httpd", "./cmd/yatm-cli", "./cmd/export-library", "./cmd/lto-info", "./cmd/migrate"], { encoding: "utf8" });
|
||||
for (const line of lines.split("\n")) {
|
||||
const [name, version, directory] = line.split("\t");
|
||||
if (name && version && directory) modules.set(`${name}@${version}`, { name, version, directory });
|
||||
}
|
||||
for (const item of [...modules.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
copyNotices("go", item.name, item.version, item.directory);
|
||||
}
|
||||
const root = execFileSync("go", ["env", "GOROOT"], { encoding: "utf8" }).trim();
|
||||
copyNotices("go", "stdlib", execFileSync("go", ["env", "GOVERSION"], { encoding: "utf8" }).trim(), root);
|
||||
|
||||
}
|
||||
for (const item of [...modules.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
copyNotices("go", item.name, item.version, item.directory);
|
||||
}
|
||||
const root = execFileSync("go", ["env", "GOROOT"], { encoding: "utf8" }).trim();
|
||||
copyNotices("go", "stdlib", execFileSync("go", ["env", "GOVERSION"], { encoding: "utf8" }).trim(), root);
|
||||
|
||||
// Follow the installed production graph, resolving pnpm's symlinks without its mutable store index.
|
||||
const visited = new Set();
|
||||
@@ -76,13 +84,17 @@ function dependency(name, from, optional = false) {
|
||||
}
|
||||
if (!optional) throw new Error(`Installed production dependency is missing: ${name}`);
|
||||
}
|
||||
const frontend = path.resolve("frontend");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(frontend, "package.json"), "utf8"));
|
||||
for (const name of Object.keys(pkg.dependencies).sort()) dependency(name, frontend);
|
||||
if (mode !== 'backend') {
|
||||
const frontend = path.resolve("frontend");
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(frontend, "package.json"), "utf8"));
|
||||
for (const name of Object.keys(pkg.dependencies).sort()) dependency(name, frontend);
|
||||
}
|
||||
fs.writeFileSync(path.join(output, "dependencies.json"), JSON.stringify(records, null, 2) + "\n");
|
||||
|
||||
// Keep copied-source attribution outside the module graph as an explicit maintained input.
|
||||
fs.copyFileSync("THIRD_PARTY_NOTICES", path.join(output, "THIRD_PARTY_NOTICES"));
|
||||
fs.copyFileSync("licenses/lto-info.LICENSE", path.join(output, "lto-info.LICENSE"));
|
||||
if (mode !== "frontend") {
|
||||
fs.copyFileSync("THIRD_PARTY_NOTICES", path.join(output, "THIRD_PARTY_NOTICES"));
|
||||
fs.copyFileSync("licenses/lto-info.LICENSE", path.join(output, "lto-info.LICENSE"));
|
||||
}
|
||||
fs.copyFileSync("licenses/react-dnd.LICENSE", path.join(output, "react-dnd.LICENSE"));
|
||||
console.log(`Collected notices for ${records.length} distributed dependencies.`);
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import { directoryFiles } from './committed-inputs.mjs';
|
||||
|
||||
test('split notice collection preserves the full graph without frontend dependencies on platform jobs', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'yatm-notices-split-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const write = (name, data, mode = 0o644) => {
|
||||
const file = path.join(root, name);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, data, { mode });
|
||||
};
|
||||
for (const name of ['build-licenses.mjs', 'native-notices.mjs']) write(`build/release/${name}`, fs.readFileSync(new URL(name, import.meta.url)));
|
||||
for (const name of ['THIRD_PARTY_NOTICES', 'licenses/lto-info.LICENSE', 'licenses/react-dnd.LICENSE', 'module/LICENSE', 'goroot/LICENSE', 'frontend/node_modules/example/LICENSE']) write(name, 'public attribution');
|
||||
write('frontend/package.json', JSON.stringify({ dependencies: { example: '1.0.0' } }));
|
||||
write('frontend/node_modules/example/package.json', JSON.stringify({ name: 'example', version: '1.0.0', license: 'MIT' }));
|
||||
write('bin/go', `#!/bin/sh
|
||||
echo "$*" >> "$NOTICE_TEST_CALLS"
|
||||
case "$*" in
|
||||
'list '*) printf 'example.org/module\\tv1.0.0\\t%s/module\\n' "$NOTICE_TEST_ROOT" ;;
|
||||
'env GOROOT') printf '%s/goroot\\n' "$NOTICE_TEST_ROOT" ;;
|
||||
'env GOVERSION') printf 'go1.26.8\\n' ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
`, 0o755);
|
||||
const calls = path.join(root, 'calls');
|
||||
const collect = (output, mode) => {
|
||||
fs.writeFileSync(calls, '');
|
||||
const result = spawnSync(process.execPath, [path.join(root, 'build/release/build-licenses.mjs'), path.join(root, output), mode],
|
||||
{ env: { ...process.env, GOOS: 'darwin', PATH: `${root}/bin:${process.env.PATH}`, NOTICE_TEST_ROOT: root, NOTICE_TEST_CALLS: calls }, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return fs.readFileSync(calls, 'utf8');
|
||||
};
|
||||
assert(collect('full', 'all').includes('env GOROOT'));
|
||||
assert.equal(collect('split', 'frontend'), '', 'Frontend collection invoked Go');
|
||||
const frontend = JSON.parse(fs.readFileSync(path.join(root, 'split/dependencies.json')));
|
||||
assert.deepEqual(frontend.map(item => item.kind), ['node']);
|
||||
fs.rmSync(path.join(root, 'frontend/node_modules'), { recursive: true });
|
||||
assert(collect('split', 'backend').includes('env GOROOT'));
|
||||
const full = directoryFiles(path.join(root, 'full'));
|
||||
const split = directoryFiles(path.join(root, 'split'));
|
||||
const records = bytes => JSON.parse(bytes).sort((a, b) => `${a.kind}/${a.name}`.localeCompare(`${b.kind}/${b.name}`));
|
||||
assert.deepEqual(records(full.get('dependencies.json')), records(split.get('dependencies.json')));
|
||||
full.delete('dependencies.json');
|
||||
split.delete('dependencies.json');
|
||||
assert.deepEqual(split, full);
|
||||
});
|
||||
@@ -5,6 +5,9 @@ REPOSITORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
RELEASE_DIRECTORY="${RELEASE_DIRECTORY:-$REPOSITORY/output/releases}"
|
||||
case "$RELEASE_DIRECTORY" in /*) ;; *) RELEASE_DIRECTORY="$PWD/$RELEASE_DIRECTORY" ;; esac
|
||||
export RELEASE_DIRECTORY
|
||||
if [[ -n "${FRONTEND_ARTIFACT_DIRECTORY:-}" && "$FRONTEND_ARTIFACT_DIRECTORY" != /* ]]; then
|
||||
FRONTEND_ARTIFACT_DIRECTORY="$PWD/$FRONTEND_ARTIFACT_DIRECTORY"
|
||||
fi
|
||||
cd "$REPOSITORY"
|
||||
: "${RELEASE_VERSION:?Set RELEASE_VERSION to the candidate tag}"
|
||||
: "${TARGET_NAME:?Set TARGET_NAME to the release platform name}"
|
||||
@@ -46,9 +49,13 @@ node build/release/build-documents.mjs "$OUTPUT_DIRECTORY" "$RELEASE_VERSION"
|
||||
printf '%s\n' "$RELEASE_VERSION" > "$OUTPUT_DIRECTORY/VERSION"
|
||||
printf '%s\n' "$RELEASE_COMMIT" > "$OUTPUT_DIRECTORY/COMMIT"
|
||||
|
||||
if [[ -z "${FRONTEND_ARTIFACT_DIRECTORY:-}" ]]; then
|
||||
FRONTEND_ARTIFACT_DIRECTORY="$PACKAGE_DIRECTORY/shared-frontend"
|
||||
bash build/release/build-frontend.sh "$FRONTEND_ARTIFACT_DIRECTORY"
|
||||
fi
|
||||
node build/release/frontend-artifact.mjs install "$FRONTEND_ARTIFACT_DIRECTORY" "$OUTPUT_DIRECTORY"
|
||||
./build/backend/build.sh
|
||||
./frontend/scripts/build.sh
|
||||
node build/release/build-licenses.mjs "$OUTPUT_DIRECTORY/licenses"
|
||||
node build/release/build-licenses.mjs "$OUTPUT_DIRECTORY/licenses" backend
|
||||
|
||||
# Each archive has a portable companion checksum; CI aggregates only complete target sets.
|
||||
TARGET_FILE="$RELEASE_DIRECTORY/yatm-${TARGET_NAME}-${RELEASE_VERSION}.tar.gz"
|
||||
|
||||
@@ -18,7 +18,7 @@ export const yatmSourceFiles = [
|
||||
'build/release/native-notices.mjs',
|
||||
...['README.md', 'go.mod', 'go.sum', 'main.go', 'decode.go', 'generate.go', 'generate_test.go',
|
||||
'render.go', 'raw.go', 'raw_test.go', 'native.go', 'build/build.sh', 'build/README.md',
|
||||
'build/ffmpeg-sysctl-header.patch', 'build/package-sources.mjs', 'build/native-notices.mjs',
|
||||
'build/ffmpeg-sysctl-header.patch', 'build/compiler-cache.sh', 'build/package-sources.mjs', 'build/native-notices.mjs',
|
||||
'build/fetch-raw-fixtures.mjs', 'testdata/raw-fixtures.json'].map(name => `previewworker/${name}`),
|
||||
];
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { readArchive, checkDirectories, checkMemberPath } from './archive-members.mjs';
|
||||
import { readArchive, checkDirectories } from './archive-members.mjs';
|
||||
import { dependencyFiles, frontendFiles } from './package-assets.mjs';
|
||||
|
||||
const archive = process.argv[2];
|
||||
assert(archive, 'usage: node check-release.mjs <archive.tar.gz>');
|
||||
@@ -30,37 +31,17 @@ if (path.basename(archive).startsWith('yatm-linux-')) {
|
||||
assert(record && record.version === version && notices.every(notice => record.notices?.includes(notice)), `Missing pinned ${name} runtime notices`);
|
||||
}
|
||||
}
|
||||
for (const { kind, name, version, notices } of dependencies) {
|
||||
assert(['go', 'node', 'native'].includes(kind) && typeof name === 'string' && typeof version === 'string'
|
||||
&& Array.isArray(notices) && notices.length, 'Invalid dependency notice ownership');
|
||||
if (kind === 'native') assert((name === 'musl' && version === 'zig-0.15.2') || (name === 'zig' && version === '0.15.2'), 'Unowned native runtime notice');
|
||||
const directory = `licenses/${kind}/${name}@${version}`;
|
||||
checkMemberPath(directory);
|
||||
for (const notice of notices) {
|
||||
assert(typeof notice === 'string' && /^(licen[cs]e|notice|copying|copyright|patents)([.-].*)?$/i.test(notice)
|
||||
&& !notice.includes('/'), 'Invalid dependency notice filename');
|
||||
const entry = `${directory}/${notice}`;
|
||||
allowed.add(entry);
|
||||
assert(files.has(entry), `Required dependency notice missing: ${entry}`);
|
||||
}
|
||||
if (kind === 'node') {
|
||||
allowed.add(`${directory}/package.json`);
|
||||
assert(files.has(`${directory}/package.json`), `Required dependency metadata missing: ${directory}`);
|
||||
}
|
||||
}
|
||||
for (const entry of dependencyFiles(files)) allowed.add(entry);
|
||||
const version = files.get('VERSION').toString().trim();
|
||||
const commit = files.get('COMMIT').toString().trim();
|
||||
assert.match(version, /^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/);
|
||||
assert.match(commit, /^[a-f0-9]{40}$/);
|
||||
for (const entry of frontendFiles(files, version, commit)) allowed.add(entry);
|
||||
for (const entry of files.keys()) {
|
||||
assert(!/(^|\/)(\.git|\.local|node_modules|config\.yaml)(\/|$)|(?:\.log|\.tmp|\.temp|~)$/i.test(entry), `Development/private file in archive: ${entry}`);
|
||||
if (entry.startsWith('docs/')) assert(documents.includes(entry), `Unexpected packaged documentation: ${entry}`);
|
||||
// Vite owns the flat, hashed asset output. Source maps and arbitrary nested files are not runtime assets.
|
||||
assert(allowed.has(entry) || /^frontend\/assets\/[A-Za-z0-9_-]+-[A-Za-z0-9_-]{8}\.(?:js|css|svg)$/.test(entry), `Unexpected archive entry: ${entry}`);
|
||||
assert(allowed.has(entry), `Unexpected archive entry: ${entry}`);
|
||||
}
|
||||
checkDirectories(members);
|
||||
const read = entry => files.get(entry).toString().trim();
|
||||
const version = read('VERSION');
|
||||
const commit = read('COMMIT');
|
||||
assert.match(version, /^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/);
|
||||
assert.match(commit, /^[a-f0-9]{40}$/);
|
||||
const html = read('frontend/index.html');
|
||||
assert(html.includes(`name="yatm-version" content="${version}"`), 'Frontend version does not match package');
|
||||
assert(html.includes(`name="yatm-commit" content="${commit}"`), 'Frontend commit does not match package');
|
||||
console.log(`Validated ${path.basename(archive)} (${files.size} files; ${commit}).`);
|
||||
@@ -35,13 +35,22 @@ function fixture(t) {
|
||||
...['encrypt', 'get_device', 'mkfs', 'mount', 'mount.openltfs', 'readinfo', 'umount'].map(name => `scripts/${name}`),
|
||||
...['encrypt', 'get_device', 'mkfs', 'mount', 'readinfo', 'umount', 'README.md'].map(name => `e2e/ltfs-file-backend/${name}`)]) write(root, name, 'committed fixture');
|
||||
write(root, 'e2e/ltfs-file-backend/mount.test.mjs', '// maintained local regression, not a runtime adapter');
|
||||
for (const name of ['build/release/build.sh', 'build/release/check-release.mjs']) {
|
||||
for (const name of ['build/release/build.sh', 'build/release/check-release.mjs', 'build/release/build-frontend.sh', 'build/release/frontend-artifact.mjs', 'build/release/package-assets.mjs']) {
|
||||
write(root, name, fs.readFileSync(path.join(repository, name)), fs.statSync(path.join(repository, name)).mode);
|
||||
}
|
||||
write(root, 'build/backend/build.sh', '#!/usr/bin/env bash\nset -eu\nfor item in yatm-httpd yatm-cli yatm-export-library yatm-lto-info yatm-migrate; do printf fixture > "$OUTPUT_DIRECTORY/$item"; done\n', 0o755);
|
||||
write(root, 'frontend/scripts/build.sh', '#!/usr/bin/env bash\nset -eu\nmkdir -p "$OUTPUT_DIRECTORY/frontend"\nprintf \'<meta name="yatm-version" content="%s"><meta name="yatm-commit" content="%s">\' "$RELEASE_VERSION" "$RELEASE_COMMIT" > "$OUTPUT_DIRECTORY/frontend/index.html"\n', 0o755);
|
||||
write(root, 'build/release/build-documents.mjs', '// fixture document conversion\n');
|
||||
write(root, 'build/release/build-licenses.mjs', "import fs from 'node:fs'; import path from 'node:path'; const output=process.argv[2];fs.mkdirSync(output);fs.writeFileSync(path.join(output,'dependencies.json'),'[]');fs.writeFileSync(path.join(output,'THIRD_PARTY_NOTICES'),'fixture');\n");
|
||||
write(root, 'frontend/pnpm-lock.yaml', 'fixture locked dependencies');
|
||||
write(root, 'build/release/build-licenses.mjs', `import fs from 'node:fs'; import path from 'node:path';
|
||||
const output=process.argv[2]; fs.mkdirSync(output, {recursive:true});
|
||||
if (process.argv[3] === 'frontend') {
|
||||
const dir=path.join(output,'node/fixture@1'); fs.mkdirSync(dir,{recursive:true});
|
||||
fs.writeFileSync(path.join(dir,'LICENSE'),'fixture'); fs.writeFileSync(path.join(dir,'package.json'),'{}');
|
||||
fs.writeFileSync(path.join(output,'dependencies.json'),JSON.stringify([{kind:'node',name:'fixture',version:'1',notices:['LICENSE']}]));
|
||||
fs.writeFileSync(path.join(output,'react-dnd.LICENSE'),'fixture');
|
||||
} else fs.writeFileSync(path.join(output,'THIRD_PARTY_NOTICES'),'fixture');
|
||||
`);
|
||||
git('add', '.');
|
||||
const tree = git('write-tree');
|
||||
const commit = execFileSync('git', ['-C', root, '-c', 'user.name=Packaging Test', '-c', 'user.email=packaging@example.invalid', 'commit-tree', tree],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// One immutable frontend and its production notices are reused by every release target.
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { directoryFiles } from './committed-inputs.mjs';
|
||||
import { dependencyFiles, frontendFiles } from './package-assets.mjs';
|
||||
|
||||
const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
function artifactFiles(directory, version, commit) {
|
||||
const files = directoryFiles(directory);
|
||||
files.delete('manifest.json');
|
||||
const allowed = new Set([...frontendFiles(files, version, commit), ...dependencyFiles(files, ['node']), 'licenses/react-dnd.LICENSE']);
|
||||
assert(files.has('licenses/react-dnd.LICENSE'), 'Missing copied frontend attribution');
|
||||
for (const name of files.keys()) assert(allowed.has(name), `Unexpected frontend artifact file: ${name}`);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function createFrontendArtifact(directory, version, commit, lockfile) {
|
||||
assert.match(version, /^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/);
|
||||
assert.match(commit, /^[a-f0-9]{40}$/);
|
||||
const files = artifactFiles(directory, version, commit);
|
||||
const manifest = { version, commit, lock_sha256: digest(fs.readFileSync(lockfile)), files: {} };
|
||||
for (const [name, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b))) manifest.files[name] = digest(bytes);
|
||||
fs.writeFileSync(path.join(directory, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
}
|
||||
|
||||
export function validateFrontendArtifact(directory, version, commit, lockfile) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
|
||||
assert.equal(manifest.version, version, 'Frontend artifact version mismatch');
|
||||
assert.equal(manifest.commit, commit, 'Frontend artifact commit mismatch');
|
||||
assert.equal(manifest.lock_sha256, digest(fs.readFileSync(lockfile)), 'Frontend artifact lockfile mismatch');
|
||||
const files = artifactFiles(directory, version, commit);
|
||||
assert(manifest.files && typeof manifest.files === 'object' && !Array.isArray(manifest.files), 'Invalid frontend file manifest');
|
||||
assert.deepEqual([...files.keys()].sort(), Object.keys(manifest.files).sort(), 'Frontend artifact file list mismatch');
|
||||
for (const [name, bytes] of files) assert.equal(digest(bytes), manifest.files[name], `Frontend artifact checksum mismatch: ${name}`);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function installFrontendArtifact(directory, destination, version, commit, lockfile) {
|
||||
const files = validateFrontendArtifact(directory, version, commit, lockfile);
|
||||
for (const [name, bytes] of files) {
|
||||
const target = path.join(destination, name);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const [operation, directory, destination] = process.argv.slice(2);
|
||||
const { RELEASE_VERSION: version, RELEASE_COMMIT: commit } = process.env;
|
||||
const lockfile = path.resolve(import.meta.dirname, '../../frontend/pnpm-lock.yaml');
|
||||
if (operation === 'create') createFrontendArtifact(directory, version, commit, lockfile);
|
||||
else if (operation === 'validate') validateFrontendArtifact(directory, version, commit, lockfile);
|
||||
else if (operation === 'install' && destination) installFrontendArtifact(directory, destination, version, commit, lockfile);
|
||||
else throw new Error('Usage: frontend-artifact.mjs <create|validate|install> <artifact-directory> [package-directory]');
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { createFrontendArtifact, installFrontendArtifact, validateFrontendArtifact } from './frontend-artifact.mjs';
|
||||
|
||||
const version = 'v1.0.0-alpha.2';
|
||||
const commit = 'a'.repeat(40);
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'yatm-frontend-artifact-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const artifact = path.join(root, 'artifact');
|
||||
const lockfile = path.join(root, 'pnpm-lock.yaml');
|
||||
fs.writeFileSync(lockfile, 'locked dependency graph');
|
||||
function write(name, content) {
|
||||
const target = path.join(artifact, name);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
write('frontend/index.html', `<meta name="yatm-version" content="${version}"><meta name="yatm-commit" content="${commit}">`);
|
||||
write('frontend/assets/index-abcd1234.js', 'public frontend');
|
||||
write('licenses/dependencies.json', JSON.stringify([{ kind: 'node', name: '@public/example', version: '1.0.0', notices: ['LICENSE'] }]));
|
||||
write('licenses/node/@public/example@1.0.0/LICENSE', 'public license');
|
||||
write('licenses/node/@public/example@1.0.0/package.json', '{"name":"@public/example","version":"1.0.0"}');
|
||||
write('licenses/react-dnd.LICENSE', 'copied attribution');
|
||||
createFrontendArtifact(artifact, version, commit, lockfile);
|
||||
return { root, artifact, lockfile, write, validate: () => validateFrontendArtifact(artifact, version, commit, lockfile) };
|
||||
}
|
||||
|
||||
test('every target receives identical frontend and notices without the internal manifest', t => {
|
||||
const f = fixture(t);
|
||||
const contents = f.validate();
|
||||
for (const target of ['linux', 'darwin', 'freebsd']) {
|
||||
const destination = path.join(f.root, target);
|
||||
installFrontendArtifact(f.artifact, destination, version, commit, f.lockfile);
|
||||
for (const [name, bytes] of contents) assert.deepEqual(fs.readFileSync(path.join(destination, name)), bytes);
|
||||
assert(!fs.existsSync(path.join(destination, 'manifest.json')));
|
||||
}
|
||||
});
|
||||
|
||||
test('version, commit and lock identity must match actual build inputs', t => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => validateFrontendArtifact(f.artifact, 'v1.0.0-alpha.3', commit, f.lockfile), /version mismatch/);
|
||||
assert.throws(() => validateFrontendArtifact(f.artifact, version, 'b'.repeat(40), f.lockfile), /commit mismatch/);
|
||||
fs.writeFileSync(f.lockfile, 'other dependencies');
|
||||
assert.throws(f.validate, /lockfile mismatch/);
|
||||
});
|
||||
|
||||
test('modified, missing, extra and private files are rejected', async t => {
|
||||
const cases = [
|
||||
['modified', f => f.write('frontend/assets/index-abcd1234.js', 'changed'), /checksum mismatch/],
|
||||
['missing', f => fs.unlinkSync(path.join(f.artifact, 'frontend/assets/index-abcd1234.js')), /file list mismatch/],
|
||||
['extra asset', f => f.write('frontend/assets/extra-abcd1234.js', 'extra'), /file list mismatch/],
|
||||
['source map', f => f.write('frontend/assets/index-abcd1234.js.map', '{}'), /Unexpected frontend artifact file/],
|
||||
['private config', f => f.write('config.yaml', 'token: fake-secret'), /Unexpected frontend artifact file/],
|
||||
['missing license', f => fs.unlinkSync(path.join(f.artifact, 'licenses/node/@public/example@1.0.0/LICENSE')), /notice missing/],
|
||||
['symlink', f => fs.symlinkSync(f.lockfile, path.join(f.artifact, 'linked')), /ordinary files/],
|
||||
];
|
||||
for (const [name, mutate, error] of cases) await t.test(name, t => {
|
||||
const f = fixture(t);
|
||||
mutate(f);
|
||||
assert.throws(f.validate, error);
|
||||
});
|
||||
});
|
||||
|
||||
test('a stale dist cannot acquire the identity of a new artifact', t => {
|
||||
const f = fixture(t);
|
||||
f.write('frontend/index.html', '<meta name="yatm-version" content="development">');
|
||||
assert.throws(() => createFrontendArtifact(f.artifact, version, commit, f.lockfile), /Frontend version/);
|
||||
});
|
||||
|
||||
test('only owned Node notices enter the shared artifact', t => {
|
||||
const f = fixture(t);
|
||||
f.write('licenses/dependencies.json', JSON.stringify([{ kind: 'go', name: 'private', version: '1', notices: ['LICENSE'] }]));
|
||||
assert.throws(() => createFrontendArtifact(f.artifact, version, commit, f.lockfile), /notice ownership/);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// Shared ownership checks for distributed frontend files and dependency notices.
|
||||
import assert from 'node:assert/strict';
|
||||
import { checkMemberPath } from './archive-members.mjs';
|
||||
|
||||
export function frontendFiles(files, version, commit) {
|
||||
const html = files.get('frontend/index.html')?.toString();
|
||||
assert(html?.includes(`name="yatm-version" content="${version}"`), 'Frontend version does not match package');
|
||||
assert(html.includes(`name="yatm-commit" content="${commit}"`), 'Frontend commit does not match package');
|
||||
const allowed = new Set(['frontend/index.html']);
|
||||
for (const name of files.keys()) {
|
||||
if (/^frontend\/assets\/[A-Za-z0-9_-]+-[A-Za-z0-9_-]{8}\.(?:js|css|svg)$/.test(name)) allowed.add(name);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
export function dependencyFiles(files, kinds = ['go', 'node', 'native']) {
|
||||
const dependencies = JSON.parse(files.get('licenses/dependencies.json')?.toString() || 'null');
|
||||
assert(Array.isArray(dependencies) && dependencies.length, 'Invalid dependency notices manifest');
|
||||
const allowed = new Set(['licenses/dependencies.json']);
|
||||
for (const { kind, name, version, notices } of dependencies) {
|
||||
assert(kinds.includes(kind) && typeof name === 'string' && typeof version === 'string'
|
||||
&& Array.isArray(notices) && notices.length, 'Invalid dependency notice ownership');
|
||||
if (kind === 'native') assert((name === 'musl' && version === 'zig-0.15.2') || (name === 'zig' && version === '0.15.2'), 'Unowned native runtime notice');
|
||||
const directory = `licenses/${kind}/${name}@${version}`;
|
||||
checkMemberPath(directory);
|
||||
for (const notice of notices) {
|
||||
assert(typeof notice === 'string' && /^(licen[cs]e|notice|copying|copyright|patents)([.-].*)?$/i.test(notice)
|
||||
&& !notice.includes('/'), 'Invalid dependency notice filename');
|
||||
const entry = `${directory}/${notice}`;
|
||||
assert(files.has(entry), `Required dependency notice missing: ${entry}`);
|
||||
allowed.add(entry);
|
||||
}
|
||||
if (kind === 'node') {
|
||||
const entry = `${directory}/package.json`;
|
||||
assert(files.has(entry), `Required dependency metadata missing: ${directory}`);
|
||||
allowed.add(entry);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
+53
-1
@@ -1,12 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
dev_mode=0
|
||||
if [[ $# == 1 && $1 == --dev ]]; then
|
||||
dev_mode=1
|
||||
elif [[ $# != 0 ]]; then
|
||||
echo "Usage: $0 [--dev]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
repo_root=$(cd -- "${script_dir}/.." && pwd)
|
||||
demo_root=${YATM_DEMO_ROOT:-"${TMPDIR:-/tmp}/yatm-demo"}
|
||||
listen=${YATM_DEMO_LISTEN:-127.0.0.1:18080}
|
||||
frontend_port=${YATM_DEMO_FRONTEND_PORT:-5173}
|
||||
case "$demo_root" in /*) ;; *) demo_root="$PWD/$demo_root" ;; esac
|
||||
|
||||
if [[ $dev_mode == 1 ]]; then
|
||||
if [[ ! $frontend_port =~ ^[1-9][0-9]{0,4}$ ]] || (( frontend_port > 65535 )); then
|
||||
echo "Demo frontend port must be between 1 and 65535" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
cd "${repo_root}"
|
||||
prepare_args=(-root "${demo_root}" -listen "${listen}")
|
||||
if [[ ${YATM_DEMO_RESET:-0} == 1 ]]; then
|
||||
@@ -20,9 +36,45 @@ if [[ ${YATM_DEMO_IDENTICAL_FILES+x} ]]; then
|
||||
fi
|
||||
go run ./cmd/demo "${prepare_args[@]}"
|
||||
|
||||
pnpm --dir frontend build
|
||||
if [[ $dev_mode == 0 ]]; then
|
||||
pnpm --dir frontend build
|
||||
fi
|
||||
go build -o "${demo_root}/yatm-httpd" ./cmd/httpd
|
||||
|
||||
if [[ $dev_mode == 1 ]]; then
|
||||
backend_pid=
|
||||
frontend_pid=
|
||||
cleanup() {
|
||||
trap - EXIT INT TERM
|
||||
for pid in "$backend_pid" "$frontend_pid"; do
|
||||
if [[ -n $pid ]]; then kill "$pid" 2>/dev/null || true; fi
|
||||
done
|
||||
for pid in "$backend_pid" "$frontend_pid"; do
|
||||
if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
(cd "${demo_root}" && exec ./yatm-httpd -config ./config.yaml) &
|
||||
backend_pid=$!
|
||||
(cd "${repo_root}/frontend" && DEV_SERVICE_BASE="http://${listen}" exec node ./node_modules/vite/bin/vite.js \
|
||||
--host localhost --port "$frontend_port" --strictPort) &
|
||||
frontend_pid=$!
|
||||
|
||||
# Bash 3.2 has no wait -n. Stop both children when either one exits.
|
||||
while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do
|
||||
sleep 0.2
|
||||
done
|
||||
if kill -0 "$backend_pid" 2>/dev/null; then
|
||||
wait "$frontend_pid"
|
||||
else
|
||||
wait "$backend_pid"
|
||||
fi
|
||||
exit
|
||||
fi
|
||||
|
||||
rm -rf "${demo_root}/frontend"
|
||||
mkdir -p "${demo_root}/frontend"
|
||||
cp frontend/dist/index.html "${demo_root}/frontend/index.html"
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const frontend = fileURLToPath(new URL("../frontend/", import.meta.url));
|
||||
const record = `
|
||||
import { appendFileSync } from "node:fs";
|
||||
const args = process.argv.slice(2);
|
||||
const log = (kind) => appendFileSync(process.env.DEMO_TEST_LOG, JSON.stringify({
|
||||
kind, args, cwd: process.cwd(), pid: process.pid, proxy: process.env.DEV_SERVICE_BASE,
|
||||
}) + "\\n");
|
||||
`;
|
||||
const backend = `#!/usr/bin/env node
|
||||
${record}
|
||||
import { createServer } from "node:http";
|
||||
log("backend");
|
||||
if (process.env.DEMO_TEST_BACKEND_EXIT) process.exit(Number(process.env.DEMO_TEST_BACKEND_EXIT));
|
||||
const address = new URL("http://" + process.env.YATM_DEMO_LISTEN);
|
||||
const server = createServer((request, response) => response.end("Demo " + request.url));
|
||||
server.on("error", (error) => { console.error(error.message); process.exit(23); });
|
||||
server.listen(Number(address.port), address.hostname, () => log("backend-ready"));
|
||||
process.on("SIGTERM", () => server.close(() => process.exit(0)));
|
||||
`;
|
||||
const go = `#!/usr/bin/env node
|
||||
${record}
|
||||
import { copyFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
log("go");
|
||||
if (process.env.DEMO_TEST_GO_FAIL === args[0]) process.exit(31);
|
||||
if (args[0] === "run") {
|
||||
const root = args[args.indexOf("-root") + 1];
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(join(root, "config.yaml"), "Demo fixture");
|
||||
} else if (args[0] === "build") {
|
||||
const output = args[args.indexOf("-o") + 1];
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
copyFileSync(process.env.DEMO_TEST_BACKEND, output);
|
||||
}
|
||||
`;
|
||||
const pnpm = `#!/usr/bin/env node
|
||||
${record}
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
log("pnpm");
|
||||
mkdirSync("frontend/dist/assets", { recursive: true });
|
||||
writeFileSync("frontend/dist/index.html", "production frontend");
|
||||
writeFileSync("frontend/dist/assets/app.js", "production assets");
|
||||
`;
|
||||
const vite = `${record}
|
||||
log("frontend");
|
||||
if (process.env.DEMO_TEST_FRONTEND_EXIT) process.exit(Number(process.env.DEMO_TEST_FRONTEND_EXIT));
|
||||
setInterval(() => {}, 1000);
|
||||
process.on("SIGTERM", () => process.exit(0));
|
||||
`;
|
||||
|
||||
async function until(predicate, detail) {
|
||||
const deadline = Date.now() + 15000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await delay(20);
|
||||
}
|
||||
assert.fail(`Timed out: ${detail}`);
|
||||
}
|
||||
|
||||
async function listen(server, host = "127.0.0.1", port = 0) {
|
||||
server.listen(port, host);
|
||||
await once(server, "listening");
|
||||
return server.address().port;
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = createServer();
|
||||
const port = await listen(server);
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
function alive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code !== "ESRCH") throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture(
|
||||
t,
|
||||
{ realVite = false, env = {}, args = ["--dev"] } = {},
|
||||
) {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), "yatm-demo-script-")),
|
||||
);
|
||||
const repo = join(root, "repo");
|
||||
const caller = join(root, "caller directory");
|
||||
const demoRoot = join(caller, "yatm-demo-review");
|
||||
const logPath = join(root, "commands.jsonl");
|
||||
const bin = join(root, "bin");
|
||||
await Promise.all([
|
||||
mkdir(join(repo, "dev"), { recursive: true }),
|
||||
mkdir(join(repo, "frontend"), { recursive: true }),
|
||||
mkdir(caller),
|
||||
mkdir(bin),
|
||||
]);
|
||||
await copyFile(
|
||||
new URL("./demo.sh", import.meta.url),
|
||||
join(repo, "dev/demo.sh"),
|
||||
);
|
||||
await writeFile(logPath, "");
|
||||
await writeFile(join(bin, "backend"), backend, { mode: 0o755 });
|
||||
await writeFile(join(bin, "go"), go, { mode: 0o755 });
|
||||
await writeFile(join(bin, "pnpm"), pnpm, { mode: 0o755 });
|
||||
if (realVite) {
|
||||
await symlink(
|
||||
join(frontend, "node_modules"),
|
||||
join(repo, "frontend/node_modules"),
|
||||
);
|
||||
for (const file of ["vite.config.ts", "index.html", "package.json"]) {
|
||||
await copyFile(join(frontend, file), join(repo, "frontend", file));
|
||||
}
|
||||
} else {
|
||||
await mkdir(join(repo, "frontend/node_modules/vite/bin"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(join(repo, "frontend/package.json"), '{"type":"module"}');
|
||||
await writeFile(join(repo, "frontend/node_modules/vite/bin/vite.js"), vite);
|
||||
}
|
||||
await mkdir(join(demoRoot, "frontend"), { recursive: true });
|
||||
await writeFile(join(demoRoot, "frontend/reviewer.txt"), "retained frontend");
|
||||
|
||||
const environment = { ...process.env };
|
||||
for (const key of Object.keys(environment)) {
|
||||
if (key.startsWith("YATM_DEMO_") || key.startsWith("DEMO_TEST_"))
|
||||
delete environment[key];
|
||||
}
|
||||
const backendPort = await freePort();
|
||||
const child = spawn("bash", [join(repo, "dev/demo.sh"), ...args], {
|
||||
cwd: caller,
|
||||
detached: true,
|
||||
env: {
|
||||
...environment,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
YATM_DEMO_ROOT: "yatm-demo-review",
|
||||
YATM_DEMO_LISTEN: `127.0.0.1:${backendPort}`,
|
||||
DEMO_TEST_LOG: logPath,
|
||||
DEMO_TEST_BACKEND: join(bin, "backend"),
|
||||
...env,
|
||||
},
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (data) => (output += data));
|
||||
child.stderr.on("data", (data) => (output += data));
|
||||
let exit;
|
||||
child.on("exit", (code, signal) => (exit = { code, signal }));
|
||||
t.after(async () => {
|
||||
// The test owns this isolated process group, including cleanup after failed assertions.
|
||||
if (alive(-child.pid)) process.kill(-child.pid, "SIGTERM");
|
||||
await until(() => exit, output);
|
||||
if (alive(-child.pid)) process.kill(-child.pid, "SIGKILL");
|
||||
await rm(root, { force: true, recursive: true });
|
||||
});
|
||||
const commands = async () =>
|
||||
(await readFile(logPath, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(JSON.parse);
|
||||
return {
|
||||
child,
|
||||
commands,
|
||||
repo,
|
||||
demoRoot,
|
||||
backendPort,
|
||||
output: () => output,
|
||||
async exited() {
|
||||
await until(() => exit, output);
|
||||
return exit;
|
||||
},
|
||||
async ready() {
|
||||
await until(async () => {
|
||||
const rows = await commands();
|
||||
return (
|
||||
rows.some((row) => row.kind === "backend-ready") &&
|
||||
rows.some((row) => row.kind === "frontend")
|
||||
);
|
||||
}, "both Demo children started");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function childrenStopped(demo) {
|
||||
const children = (await demo.commands()).filter(
|
||||
(row) => row.kind === "backend" || row.kind === "frontend",
|
||||
);
|
||||
assert.equal(children.length, 2, demo.output());
|
||||
for (const child of children)
|
||||
assert.equal(alive(child.pid), false, `${child.kind} must be reaped`);
|
||||
}
|
||||
|
||||
test("production Demo builds and installs assets and forwards explicit fixture options", async (t) => {
|
||||
const demo = await fixture(t, {
|
||||
args: [],
|
||||
env: {
|
||||
YATM_DEMO_RESET: "1",
|
||||
YATM_DEMO_VIDEO: "/tmp/clip with spaces.mp4",
|
||||
YATM_DEMO_IDENTICAL_FILES: "10000",
|
||||
DEMO_TEST_BACKEND_EXIT: "0",
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await demo.exited(), { code: 0, signal: null });
|
||||
const rows = await demo.commands();
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.kind),
|
||||
["go", "pnpm", "go", "backend"],
|
||||
);
|
||||
assert.deepEqual(rows[0].args, [
|
||||
"run",
|
||||
"./cmd/demo",
|
||||
"-root",
|
||||
demo.demoRoot,
|
||||
"-listen",
|
||||
`127.0.0.1:${demo.backendPort}`,
|
||||
"-reset",
|
||||
"-video",
|
||||
"/tmp/clip with spaces.mp4",
|
||||
"-identical-files",
|
||||
"10000",
|
||||
]);
|
||||
assert.deepEqual(rows[1].args, ["--dir", "frontend", "build"]);
|
||||
assert.deepEqual(rows[2].args, [
|
||||
"build",
|
||||
"-o",
|
||||
join(demo.demoRoot, "yatm-httpd"),
|
||||
"./cmd/httpd",
|
||||
]);
|
||||
assert.equal(rows[0].cwd, demo.repo);
|
||||
assert.equal(rows[3].cwd, demo.demoRoot);
|
||||
assert.deepEqual(rows[3].args, ["-config", "./config.yaml"]);
|
||||
assert.equal(
|
||||
await readFile(join(demo.demoRoot, "frontend/index.html"), "utf8"),
|
||||
"production frontend",
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(demo.demoRoot, "frontend/assets/app.js"), "utf8"),
|
||||
"production assets",
|
||||
);
|
||||
});
|
||||
|
||||
for (const [signal, code] of [
|
||||
["SIGTERM", 143],
|
||||
["SIGINT", 130],
|
||||
]) {
|
||||
test(`development Demo preserves fixtures and cleans both children on ${signal}`, async (t) => {
|
||||
const demo = await fixture(t);
|
||||
await demo.ready();
|
||||
const rows = await demo.commands();
|
||||
const frontend = rows.find((row) => row.kind === "frontend");
|
||||
assert.deepEqual(
|
||||
rows.filter((row) => row.kind === "go").map((row) => row.args),
|
||||
[
|
||||
[
|
||||
"run",
|
||||
"./cmd/demo",
|
||||
"-root",
|
||||
demo.demoRoot,
|
||||
"-listen",
|
||||
`127.0.0.1:${demo.backendPort}`,
|
||||
],
|
||||
["build", "-o", join(demo.demoRoot, "yatm-httpd"), "./cmd/httpd"],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
rows.some((row) => row.kind === "pnpm"),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(frontend.args, [
|
||||
"--host",
|
||||
"localhost",
|
||||
"--port",
|
||||
"5173",
|
||||
"--strictPort",
|
||||
]);
|
||||
assert.equal(frontend.proxy, `http://127.0.0.1:${demo.backendPort}`);
|
||||
assert.equal(frontend.cwd, join(demo.repo, "frontend"));
|
||||
assert.equal(
|
||||
await readFile(join(demo.demoRoot, "frontend/reviewer.txt"), "utf8"),
|
||||
"retained frontend",
|
||||
);
|
||||
demo.child.kill(signal);
|
||||
assert.deepEqual(await demo.exited(), { code, signal: null });
|
||||
await childrenStopped(demo);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [kind, code] of [
|
||||
["BACKEND", 41],
|
||||
["FRONTEND", 42],
|
||||
]) {
|
||||
test(`development Demo cleans its peer on early ${kind.toLowerCase()} failure`, async (t) => {
|
||||
const demo = await fixture(t, {
|
||||
env: { [`DEMO_TEST_${kind}_EXIT`]: String(code) },
|
||||
});
|
||||
assert.deepEqual(await demo.exited(), { code, signal: null });
|
||||
// A peer may be stopped before its Node entrypoint records startup.
|
||||
for (const child of (await demo.commands()).filter(
|
||||
(row) => row.kind === "backend" || row.kind === "frontend",
|
||||
)) {
|
||||
assert.equal(alive(child.pid), false);
|
||||
}
|
||||
assert.equal(
|
||||
alive(-demo.child.pid),
|
||||
false,
|
||||
"no child process remains in the Demo group",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const stage of ["run", "build"]) {
|
||||
test(`development Demo starts no runtime children after failed Go ${stage}`, async (t) => {
|
||||
const demo = await fixture(t, { env: { DEMO_TEST_GO_FAIL: stage } });
|
||||
assert.deepEqual(await demo.exited(), { code: 31, signal: null });
|
||||
assert.equal(
|
||||
(await demo.commands()).every((row) => row.kind === "go"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("development Demo forwards reset only when explicitly requested", async (t) => {
|
||||
const demo = await fixture(t, {
|
||||
env: { YATM_DEMO_RESET: "1", YATM_DEMO_FRONTEND_PORT: "5199" },
|
||||
});
|
||||
await demo.ready();
|
||||
const rows = await demo.commands();
|
||||
assert.equal(rows[0].args.at(-1), "-reset");
|
||||
assert.deepEqual(rows.find((row) => row.kind === "frontend").args, [
|
||||
"--host",
|
||||
"localhost",
|
||||
"--port",
|
||||
"5199",
|
||||
"--strictPort",
|
||||
]);
|
||||
demo.child.kill("SIGTERM");
|
||||
await demo.exited();
|
||||
await childrenStopped(demo);
|
||||
});
|
||||
|
||||
test("invalid options and ports fail before fixture preparation", async (t) => {
|
||||
for (const options of [
|
||||
{ args: ["--unknown"] },
|
||||
{ args: ["--dev", "extra"] },
|
||||
...["0", "65536", "invalid"].map((port) => ({
|
||||
env: { YATM_DEMO_FRONTEND_PORT: port },
|
||||
})),
|
||||
]) {
|
||||
const demo = await fixture(t, options);
|
||||
assert.deepEqual(await demo.exited(), { code: 2, signal: null });
|
||||
assert.deepEqual(await demo.commands(), []);
|
||||
}
|
||||
});
|
||||
|
||||
test("real Vite serves HMR and proxies both Demo backend routes on a custom port", async (t) => {
|
||||
const port = await freePort();
|
||||
const demo = await fixture(t, {
|
||||
realVite: true,
|
||||
env: { YATM_DEMO_FRONTEND_PORT: String(port) },
|
||||
});
|
||||
let html;
|
||||
await until(async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/`);
|
||||
html = await response.text();
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "Vite frontend started");
|
||||
assert.match(html, /window\.apiBase = "\/services"/);
|
||||
assert.match(html, /\/@vite\/client/);
|
||||
assert.equal(
|
||||
(await fetch(`http://localhost:${port}/@vite/client`)).status,
|
||||
200,
|
||||
);
|
||||
for (const route of ["/services/probe", "/files/probe"]) {
|
||||
assert.equal(
|
||||
await (await fetch(`http://localhost:${port}${route}`)).text(),
|
||||
`Demo ${route}`,
|
||||
);
|
||||
}
|
||||
demo.child.kill("SIGTERM");
|
||||
assert.deepEqual(await demo.exited(), { code: 143, signal: null });
|
||||
await assert.rejects(fetch(`http://localhost:${port}/`));
|
||||
await assert.rejects(fetch(`http://127.0.0.1:${demo.backendPort}/`));
|
||||
});
|
||||
|
||||
test("an occupied frontend port fails instead of moving and stops the backend", async (t) => {
|
||||
const blocker = createServer();
|
||||
const port = await listen(blocker, "localhost");
|
||||
t.after(() => new Promise((resolve) => blocker.close(resolve)));
|
||||
const demo = await fixture(t, {
|
||||
realVite: true,
|
||||
env: { YATM_DEMO_FRONTEND_PORT: String(port) },
|
||||
});
|
||||
assert.deepEqual(await demo.exited(), { code: 1, signal: null });
|
||||
assert.match(demo.output(), new RegExp(`Port ${port} is already in use`));
|
||||
const backend = (await demo.commands()).find((row) => row.kind === "backend");
|
||||
assert.equal(alive(backend.pid), false);
|
||||
await assert.rejects(fetch(`http://127.0.0.1:${demo.backendPort}/`));
|
||||
});
|
||||
|
||||
test("an occupied backend port fails and stops the frontend", async (t) => {
|
||||
const blocker = createServer();
|
||||
const port = await listen(blocker);
|
||||
t.after(() => new Promise((resolve) => blocker.close(resolve)));
|
||||
const demo = await fixture(t, {
|
||||
env: { YATM_DEMO_LISTEN: `127.0.0.1:${port}` },
|
||||
});
|
||||
assert.deepEqual(await demo.exited(), { code: 23, signal: null });
|
||||
assert.match(demo.output(), /EADDRINUSE/);
|
||||
assert.equal(alive(-demo.child.pid), false);
|
||||
});
|
||||
+30
-10
@@ -7,11 +7,12 @@ import { resolveGo } from "./toolchain.mjs";
|
||||
import { digest, readSamples, validateInventory, validateSamples } from "./evidence.mjs";
|
||||
|
||||
export function options(args) {
|
||||
const result = { tier: "fast", cpu: 2, benchtime: "1s", cgo: "1" };
|
||||
const result = { tier: "fast", cpu: 2, benchtime: "1s", cgo: "1", packages: [] };
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const key = args[index].replace(/^--/, "");
|
||||
if (key === "idle") { result.idle = true; continue; }
|
||||
if (!["baseline", "candidate", "harness", "out", "tier", "cpu", "benchtime", "cgo", "environment", "manifest"].includes(key) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`Unknown or incomplete option ${args[index]}`);
|
||||
if (!["baseline", "candidate", "harness", "out", "tier", "cpu", "benchtime", "cgo", "environment", "manifest", "package"].includes(key) || !args[index + 1] || args[index + 1].startsWith("--")) throw new Error(`Unknown or incomplete option ${args[index]}`);
|
||||
if (key === "package") { result.packages.push(args[++index]); continue; }
|
||||
result[key] = args[++index];
|
||||
}
|
||||
for (const key of ["baseline", "candidate", "out", "environment"]) if (!result[key]) throw new Error(`--${key} is required`);
|
||||
@@ -84,8 +85,15 @@ function idle() {
|
||||
|
||||
export function collect(config, run = command, checkIdle = idle) {
|
||||
const manifestPath = resolve(config.manifest ?? join(dirname(fileURLToPath(import.meta.url)), "suites.json"));
|
||||
const tier = validateTier(JSON.parse(readFileSync(manifestPath, "utf8")).tiers[config.tier]);
|
||||
if (config.tier === "release" && !tier.structural?.length) throw new Error("Release tier requires explicit 1m structural checks");
|
||||
const manifest = readFileSync(manifestPath);
|
||||
const fullTier = validateTier(JSON.parse(manifest).tiers[config.tier]);
|
||||
if (config.tier === "release" && !fullTier.structural?.length) throw new Error("Release tier requires explicit 1m structural checks");
|
||||
const requested = [...new Set(config.packages ?? [])];
|
||||
for (const pkg of requested) if (!fullTier.suites.some((suite) => suite.package === pkg)) throw new Error(`Package is not in the ${config.tier} tier: ${pkg}`);
|
||||
const suites = fullTier.suites.filter((suite) => !requested.length || requested.includes(suite.package));
|
||||
const packages = suites.map((suite) => suite.package);
|
||||
const tier = { ...fullTier, suites, harness: fullTier.harness.filter((name) => packages.some((pkg) => name.startsWith(localPath(pkg) + "/"))),
|
||||
structural: (fullTier.structural ?? []).filter((check) => packages.includes(check.package)) };
|
||||
const output = physicalPath(config.out), harnessRoot = realpathSync(config.harness ?? config.candidate);
|
||||
if (existsSync(output)) throw new Error("Output must be a new directory; existing evidence is never overwritten");
|
||||
for (const root of [config.baseline, config.candidate, harnessRoot]) {
|
||||
@@ -95,7 +103,12 @@ export function collect(config, run = command, checkIdle = idle) {
|
||||
checkIdle();
|
||||
mkdirSync(output, { recursive: true });
|
||||
const workspace = mkdtempSync(join(dirname(output), ".yatm-performance-"));
|
||||
const record = { format: 1, complete: false, tier: config.tier, idleConfirmed: true, startedAt: new Date().toISOString(), sources: {}, runs: [], structural: [] };
|
||||
const record = { format: 1, complete: false, tier: config.tier, idleConfirmed: true, startedAt: new Date().toISOString(), sources: {}, runs: [], structural: [],
|
||||
fullReleaseComparison: false, scope: { requestedPackages: requested.length ? requested : packages, selectedPackages: packages, executedPackages: [],
|
||||
skippedPackages: fullTier.suites.map((suite) => suite.package).filter((pkg) => !packages.includes(pkg)),
|
||||
structural: tier.structural.map(({ package: pkg, tests }) => ({ package: pkg, tests })),
|
||||
skippedStructural: (fullTier.structural ?? []).filter((check) => !packages.includes(check.package)).map(({ package: pkg, tests }) => ({ package: pkg, tests })) } };
|
||||
record.scope.fullTier = record.scope.skippedPackages.length === 0;
|
||||
const save = () => writeFileSync(join(output, "pair.json"), JSON.stringify(record, null, 2) + "\n");
|
||||
try {
|
||||
// One environment and fixture filesystem serve both source revisions.
|
||||
@@ -106,6 +119,10 @@ export function collect(config, run = command, checkIdle = idle) {
|
||||
mkdirSync(env.TMPDIR);
|
||||
const selectedGo = resolveGo(`go${toolchain}`, harnessRoot, env, run);
|
||||
env = selectedGo.env;
|
||||
const fixtureEnvs = Object.fromEntries(["baseline", "candidate"].map((side) => {
|
||||
const TMPDIR = join(env.TMPDIR, side); mkdirSync(TMPDIR);
|
||||
return [side, { ...env, TMPDIR }];
|
||||
}));
|
||||
const go = selectedGo.info;
|
||||
const fs = statfsSync(workspace);
|
||||
record.environment = { label: config.environment, go, compiler: config.cgo === "1" ? run(go.CC, ["--version"], { env }).split("\n")[0] : null,
|
||||
@@ -122,7 +139,7 @@ export function collect(config, run = command, checkIdle = idle) {
|
||||
if (!lstatSync(path).isFile()) throw new Error(`Harness paths must name files: ${name}`);
|
||||
return { name, bytes: readFileSync(path) };
|
||||
});
|
||||
record.collector = { sha256: digest(readFileSync(fileURLToPath(import.meta.url))), manifestSHA256: digest(readFileSync(manifestPath)) };
|
||||
record.collector = { sha256: digest(readFileSync(fileURLToPath(import.meta.url))), manifestSHA256: digest(manifest) };
|
||||
record.harness = { commit: run("git", ["-C", harnessRoot, "rev-parse", "HEAD"]).trim(), files: harness.map(({ name, bytes }) => [name, digest(bytes)]), sha256: digest(JSON.stringify(harness.map(({ name, bytes }) => [name, digest(bytes)]))) };
|
||||
for (const side of ["baseline", "candidate"]) {
|
||||
for (const suite of tier.suites) {
|
||||
@@ -134,15 +151,17 @@ export function collect(config, run = command, checkIdle = idle) {
|
||||
writeFileSync(join(workspace, side, name), bytes);
|
||||
}
|
||||
writeFileSync(join(output, `${side}.bench`), "");
|
||||
for (const [index, suite] of record.suites.entries()) run(selectedGo.file, ["test", "-c", "-p=1", "-o", join(workspace, `${side}-${index}.test`), suite.package], { cwd: join(workspace, side), env });
|
||||
for (const [index, suite] of record.suites.entries()) run(selectedGo.file, ["test", "-c", "-p=1", "-o", join(workspace, `${side}-${index}.test`), suite.package], { cwd: join(workspace, side), env: fixtureEnvs[side] });
|
||||
}
|
||||
save();
|
||||
|
||||
// Run each standard Go benchmark suite once per source. Go calibrates its own loop.
|
||||
for (const [index, suite] of record.suites.entries()) {
|
||||
for (const side of ["baseline", "candidate"]) {
|
||||
if (!record.scope.executedPackages.includes(suite.package)) record.scope.executedPackages.push(suite.package);
|
||||
save();
|
||||
const args = ["-test.run=^$", `-test.bench=${suite.bench}`, "-test.benchmem", `-test.benchtime=${suite.benchtime}`, "-test.count=1", `-test.cpu=${config.cpu}`, "-test.parallel=1", "-test.timeout=30m"];
|
||||
const stdout = run(join(workspace, `${side}-${index}.test`), args, { cwd: join(workspace, side, localPath(suite.package)), env });
|
||||
const stdout = run(join(workspace, `${side}-${index}.test`), args, { cwd: join(workspace, side, localPath(suite.package)), env: fixtureEnvs[side] });
|
||||
const text = `pkg: ${suite.importPath}\n${stdout}\n`;
|
||||
appendFileSync(join(output, `${side}.bench`), text);
|
||||
if (!/^PASS\s*$/m.test(stdout)) throw new Error("Benchmark suite did not complete");
|
||||
@@ -156,7 +175,7 @@ export function collect(config, run = command, checkIdle = idle) {
|
||||
const result = { ...check, passed: false, outputs: {} }; record.structural.push(result);
|
||||
// Structural correctness is checked once on the candidate, separately from timing.
|
||||
const binary = join(workspace, `candidate-${suiteIndex}.test`);
|
||||
const settings = { cwd: join(workspace, "candidate", localPath(check.package)), env: { ...env, ...check.env } };
|
||||
const settings = { cwd: join(workspace, "candidate", localPath(check.package)), env: { ...fixtureEnvs.candidate, ...check.env } };
|
||||
const listed = run(binary, [`-test.list=${check.run}`], settings).trim().split(/\r?\n/).sort();
|
||||
if (JSON.stringify(listed) !== JSON.stringify([...check.tests].sort())) throw new Error("Required candidate structural tests are absent");
|
||||
const stdout = run(binary, [`-test.run=${check.run}`, "-test.count=1", "-test.v", "-test.timeout=60m"], settings);
|
||||
@@ -167,7 +186,8 @@ export function collect(config, run = command, checkIdle = idle) {
|
||||
}
|
||||
validateSamples(readSamples(readFileSync(join(output, "baseline.bench"), "utf8")), readSamples(readFileSync(join(output, "candidate.bench"), "utf8")), record.suites);
|
||||
record.outputs = Object.fromEntries(["baseline", "candidate"].map((side) => [side, digest(readFileSync(join(output, `${side}.bench`)))]));
|
||||
record.complete = true; record.finishedAt = new Date().toISOString(); save();
|
||||
record.complete = true; record.fullReleaseComparison = config.tier === "release" && record.scope.fullTier;
|
||||
record.finishedAt = new Date().toISOString(); save();
|
||||
return record;
|
||||
} catch (error) {
|
||||
record.error = error.message; save();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { basename, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { collect, options, validateTier } from "./collect.mjs";
|
||||
import { readSamples, validateInventory, validatePair, validateSamples } from "./evidence.mjs";
|
||||
import { digest, readSamples, validateInventory, validatePair, validateSamples } from "./evidence.mjs";
|
||||
import { comparePair } from "../check-performance.mjs";
|
||||
|
||||
function fixture(t) {
|
||||
@@ -359,3 +359,127 @@ test("snapshot rejects source symlinks including dangling links", async (t) => {
|
||||
assert.equal(JSON.parse(readFileSync(join(f.config.out, "pair.json"))).complete, false);
|
||||
});
|
||||
});
|
||||
|
||||
function packageFixture(t) {
|
||||
const f = fixture(t);
|
||||
const extra = "internal/other/performance_test.go";
|
||||
f.tier.harness.push(extra);
|
||||
f.tier.suites.push({ package: "./internal/other", bench: "^BenchmarkOther$", benchmarks: ["BenchmarkOther"], benchtime: "1x" });
|
||||
f.tier.structural = f.tier.suites.map((suite, index) => ({ scope: "candidate", package: suite.package,
|
||||
run: `^TestRows${index}$`, tests: [`TestRows${index}`] }));
|
||||
f.config.tier = "release";
|
||||
writeFileSync(f.config.manifest, JSON.stringify({ tiers: { release: f.tier } }));
|
||||
for (const side of ["baseline", "candidate"]) {
|
||||
mkdirSync(join(f.root, side, "internal/other"), { recursive: true });
|
||||
writeFileSync(join(f.root, side, extra), `other harness from ${side}\n`);
|
||||
}
|
||||
const original = f.run;
|
||||
f.run = (file, args, settings = {}) => {
|
||||
if (file === "git" && args.includes("ls-files")) return original(file, args, settings) + extra + "\0";
|
||||
if (basename(file) === "go" && args[0] === "test") {
|
||||
f.calls.push({ file, args, settings });
|
||||
const pkg = args.at(-1).replace(/^\.\//, "");
|
||||
const expected = pkg.endsWith("other") ? "other harness from candidate\n" : "common harness from candidate\n";
|
||||
assert.equal(readFileSync(join(settings.cwd, pkg, "performance_test.go"), "utf8"), expected);
|
||||
assert.equal(f.calls.some((call) => call.file.endsWith(".test")), false);
|
||||
return "";
|
||||
}
|
||||
if (file.endsWith(".test")) {
|
||||
f.calls.push({ file, args, settings });
|
||||
const list = /^-test.list=\^(TestRows\d+)\$$/.exec(args[0]);
|
||||
if (list) return list[1] + "\n";
|
||||
const structural = /^-test.run=\^(TestRows\d+)\$$/.exec(args[0]);
|
||||
if (structural) return `--- PASS: ${structural[1]} (0.00s)\nPASS\n`;
|
||||
const benchmark = basename(settings.cwd) === "other" ? "BenchmarkOther" : "BenchmarkList";
|
||||
return `goos: linux\ngoarch: amd64\ncpu: synthetic test CPU\n${benchmark}-2 1 100 ns/op 0 B/op 0 allocs/op\nPASS\n`;
|
||||
}
|
||||
return original(file, args, settings);
|
||||
};
|
||||
return f;
|
||||
}
|
||||
|
||||
test("package selection filters an existing tier, harness and applicable structural checks", (t) => {
|
||||
const f = packageFixture(t);
|
||||
f.config.packages = ["./internal/other", "./internal/other"];
|
||||
const record = collect(f.config, (file, args, settings) => {
|
||||
if (args.includes("-test.run=^$")) {
|
||||
const state = join(settings.env.TMPDIR, "mutable-fixture");
|
||||
assert.equal(existsSync(state), false, "the baseline fixture cannot leak into the candidate");
|
||||
writeFileSync(state, "owned by this source");
|
||||
}
|
||||
return f.run(file, args, settings);
|
||||
}, () => {});
|
||||
assert.deepEqual(record.scope, { requestedPackages: ["./internal/other"], selectedPackages: ["./internal/other"],
|
||||
executedPackages: ["./internal/other"], skippedPackages: ["./internal/example"], fullTier: false,
|
||||
structural: [{ package: "./internal/other", tests: ["TestRows1"] }],
|
||||
skippedStructural: [{ package: "./internal/example", tests: ["TestRows0"] }] });
|
||||
assert.equal(record.fullReleaseComparison, false, "a completed subset cannot claim release comparison");
|
||||
assert.deepEqual(record.harness.files.map(([name]) => name), ["internal/other/performance_test.go"]);
|
||||
assert.deepEqual(record.runs.map(({ side, package: pkg }) => [side, pkg]), [["baseline", "./internal/other"], ["candidate", "./internal/other"]]);
|
||||
const benchmarks = f.calls.filter((call) => call.args.includes("-test.run=^$"));
|
||||
assert.equal(benchmarks.length, 2);
|
||||
assert.deepEqual(benchmarks[0].args, benchmarks[1].args);
|
||||
assert.ok(benchmarks[0].args.includes("-test.count=1"));
|
||||
assert.ok(benchmarks[0].args.includes("-test.benchtime=1x"));
|
||||
assert.notEqual(benchmarks[0].settings.env.TMPDIR, benchmarks[1].settings.env.TMPDIR);
|
||||
assert.equal(validatePair(f.config.out).fullReleaseComparison, false);
|
||||
assert.equal(comparePair(f.config.out).flagged, false);
|
||||
|
||||
for (const change of [
|
||||
(value) => { value.fullReleaseComparison = true; },
|
||||
(value) => { value.scope.fullTier = true; },
|
||||
(value) => { value.scope.executedPackages = []; },
|
||||
(value) => { value.structural = []; },
|
||||
(value) => { delete value.scope; },
|
||||
]) {
|
||||
const invalid = structuredClone(record); change(invalid);
|
||||
writeFileSync(join(f.config.out, "pair.json"), JSON.stringify(invalid));
|
||||
assert.throws(() => validatePair(f.config.out), /scope|structural/);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit package requests retain manifest order and complete default release scope", (t) => {
|
||||
const f = packageFixture(t);
|
||||
f.config.packages = ["./internal/other", "./internal/example"];
|
||||
const record = collect(f.config, f.run, () => {});
|
||||
assert.deepEqual(record.scope.requestedPackages, f.config.packages);
|
||||
assert.deepEqual(record.scope.executedPackages, ["./internal/example", "./internal/other"]);
|
||||
assert.equal(record.fullReleaseComparison, true);
|
||||
assert.equal(validatePair(f.config.out).scope.fullTier, true);
|
||||
const compile = f.calls.filter((call) => call.args.includes("-c"));
|
||||
const benchmarks = f.calls.filter((call) => call.args.includes("-test.run=^$"));
|
||||
assert.equal(compile.length, 4);
|
||||
assert.equal(benchmarks.length, 4);
|
||||
for (const call of benchmarks) {
|
||||
const side = basename(call.file).split("-")[0], pkg = "./internal/" + basename(call.settings.cwd);
|
||||
assert.equal(call.settings.env.TMPDIR, compile.find((item) => basename(item.settings.cwd) === side && item.args.at(-1) === pkg).settings.env.TMPDIR);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown or incomplete package requests fail before source or fixture setup", (t) => {
|
||||
const f = packageFixture(t);
|
||||
assert.throws(() => collect({ ...f.config, packages: ["./internal/absent"] }, f.run, () => {}), /not in the release tier/);
|
||||
assert.equal(f.calls.length, 0);
|
||||
assert.equal(existsSync(f.config.out), false);
|
||||
const args = ["--baseline", "a", "--candidate", "b", "--out", "c", "--environment", "d", "--idle"];
|
||||
assert.deepEqual(options([...args, "--package", "./internal/apis", "--package", "./internal/executor/scan"]).packages,
|
||||
["./internal/apis", "./internal/executor/scan"]);
|
||||
assert.throws(() => options([...args, "--package"]), /incomplete option --package/);
|
||||
});
|
||||
|
||||
test("collection records attempted package scope on failure and hashes the consumed immutable manifest", (t) => {
|
||||
const f = packageFixture(t);
|
||||
f.config.packages = ["./internal/other"];
|
||||
const manifest = readFileSync(f.config.manifest);
|
||||
assert.throws(() => collect(f.config, (file, args, settings) => {
|
||||
if (basename(file) === "go" && args[0] === "env") writeFileSync(f.config.manifest, "changed after selection");
|
||||
if (file.endsWith("baseline-0.test")) throw new Error("selected benchmark failed");
|
||||
return f.run(file, args, settings);
|
||||
}, () => {}), /selected benchmark failed/);
|
||||
const record = JSON.parse(readFileSync(join(f.config.out, "pair.json")));
|
||||
assert.equal(record.collector.manifestSHA256, digest(manifest));
|
||||
assert.deepEqual(record.scope.executedPackages, ["./internal/other"]);
|
||||
assert.equal(record.complete, false);
|
||||
assert.equal(record.fullReleaseComparison, false);
|
||||
assert.equal(readdirSync(f.root).some((name) => name.startsWith(".yatm-performance-")), false);
|
||||
});
|
||||
@@ -63,6 +63,17 @@ export function validateSamples(base, candidate, suites) {
|
||||
export function validatePair(directory) {
|
||||
const record = JSON.parse(readFileSync(resolve(directory, "pair.json"), "utf8"));
|
||||
if (record.format !== 1 || record.complete !== true || !record.idleConfirmed || !record.environment?.label || !record.environment?.go?.GOVERSION || !record.environment?.host?.platform || !record.harness?.sha256 || !record.suites?.length) throw new Error("Missing completed comparable collection metadata");
|
||||
if (record.fullReleaseComparison !== undefined && !record.scope) throw new Error("Missing recorded package scope");
|
||||
if (record.scope) {
|
||||
const packages = record.suites.map((suite) => suite.package), scope = record.scope;
|
||||
if (JSON.stringify(scope.selectedPackages) !== JSON.stringify(packages) || JSON.stringify(scope.executedPackages) !== JSON.stringify(packages)
|
||||
|| !scope.requestedPackages?.length || JSON.stringify([...scope.requestedPackages].sort()) !== JSON.stringify([...packages].sort())
|
||||
|| !Array.isArray(scope.skippedPackages) || scope.skippedPackages.some((pkg) => packages.includes(pkg))
|
||||
|| scope.fullTier !== (scope.skippedPackages.length === 0)
|
||||
|| record.fullReleaseComparison !== (record.tier === "release" && scope.fullTier)) throw new Error("Recorded package scope differs from executed comparison");
|
||||
const checks = (record.structural ?? []).map(({ package: pkg, tests }) => ({ package: pkg, tests }));
|
||||
if (JSON.stringify(scope.structural) !== JSON.stringify(checks)) throw new Error("Required selected structural checks did not finish");
|
||||
}
|
||||
for (const side of ["baseline", "candidate"]) {
|
||||
if (!/^[a-f0-9]{40,64}$/.test(record.sources?.[side]?.commit ?? "") || !/^[a-f0-9]{64}$/.test(record.sources[side].sha256 ?? "")) throw new Error(`Missing recorded ${side} source identity`);
|
||||
const contents = readFileSync(resolve(directory, `${side}.bench`));
|
||||
@@ -77,6 +88,6 @@ export function validatePair(directory) {
|
||||
if (check.scope !== "candidate" || JSON.stringify(Object.keys(check.outputs)) !== '["candidate"]') throw new Error("Structural evidence must explicitly cover only candidate correctness");
|
||||
if (digest(readFileSync(resolve(directory, check.outputs.candidate.file))) !== check.outputs.candidate.sha256) throw new Error("Structural evidence changed");
|
||||
}
|
||||
if (record.tier === "release" && !record.structural?.length) throw new Error("Release evidence requires the 1m structural checks");
|
||||
if (record.tier === "release" && record.scope?.fullTier !== false && !record.structural?.length) throw new Error("Release evidence requires the 1m structural checks");
|
||||
return record;
|
||||
}
|
||||
@@ -134,7 +134,7 @@ Authorized path browsing skips explicit policy/resource exclusions; filesystem o
|
||||
|
||||
Retain the product logo, build-derived version, contextual Library/Location root labels, compact row height, full-width separators, inherited surfaces, and usable empty spacing. Tape drawers close independently and physical-file panel height stays stable when expanded/collapsed. Visible dot-prefixed directories remain navigable unless a backend explicitly excludes them. Shared browser interaction fixes belong in Chonky, not per-page CSS/event workarounds.
|
||||
|
||||
[MUI class-name initialization](../../frontend/src/mui-classnames.ts) must run before component imports in both the application entry and test setup. The entry dynamically loads [application bootstrap](../../frontend/src/bootstrap.tsx) after initialization, preserving that order across production shared chunks. Cached internal selectors and rendered classes must use the same prefix; otherwise default select overflow and outlined-label styles can fail. Verify long selected values and floating labels in real rendering.
|
||||
[MUI class-name initialization](../../frontend/src/mui-classnames.ts) must run before component imports in both the application entry and browser test setup. The entry dynamically loads [application bootstrap](../../frontend/src/bootstrap.tsx) after initialization, preserving that order across production shared chunks. Cached internal selectors and rendered classes must use the same prefix; otherwise default select overflow and outlined-label styles can fail. Verify long selected values and floating labels in real rendering.
|
||||
|
||||
The [application theme](../../frontend/src/theme.ts) owns MUI primitive appearance; `sx` and `styled` use the same Emotion engine. Shared components own repeated structure and placement, including tabs, section cards, action rows, confirmations, feedback, result rows and saved-version choices. Less owns page layout and scoped third-party adaptations; it does not restyle those shared components. Bootstrap loads the reset before importing the application and its global styles. Component styles stay scoped, so opening a lazy route cannot change another surface. Chonky retains its own Emotion styles and notifications retain their distributed CSS.
|
||||
|
||||
|
||||
@@ -75,6 +75,18 @@ A missing or non-executable candidate fails explicitly. Package checks reject al
|
||||
|
||||
### Remote Packaged-Binary Acceptance
|
||||
|
||||
Use repeatable `--case NAME` to select exact existing case names. The controller automatically
|
||||
runs their required cases in normal order and always allocates a fresh isolated environment.
|
||||
Omit `--case` for the complete selected profile. The report's `case_scope` distinguishes requested
|
||||
cases, prerequisites, executed cases and skipped cases with reasons; a passing subset does not
|
||||
establish full acceptance. The legacy profile supports the same selection: upgrade is required
|
||||
before historical repair or JSONL roundtrip, while review/decline/abort checks do not run those actions.
|
||||
|
||||
Reports use monotonic elapsed times for preparation, transfer, installation, workflows and cleanup,
|
||||
plus individual commands, cases and the whole run. Independent read-only host, tool and identity
|
||||
checks run in batches of at most four; each retains its result and exit status even if another fails.
|
||||
Nested or concurrent durations overlap and must not be added to claim total elapsed time.
|
||||
|
||||
Keep the test controller on the local machine. On the explicitly selected isolated test host, verify and extract the approved release archive into an isolated attempt directory; invoke only its shipped YATM programs through SSH. Read their offline `--version` output and compare the commit/version and archive checksum before starting. The package's installer/templates/scripts and approved fixture data are the transfer boundary. Repository source, separately built harnesses and additional test-helper source are not deployed.
|
||||
|
||||
The [package controller](../../e2e/package_acceptance.py) runs from the local checkout. It requires
|
||||
|
||||
+96
-18
@@ -27,11 +27,51 @@ automate repeatable setup and checks where useful, and record each manual step's
|
||||
why it needs an operator or visual judgment. Follow [Media and Migration Safety](#media-and-migration-safety);
|
||||
automation does not expand access or publication authorization.
|
||||
|
||||
### Check Scope and Reuse
|
||||
|
||||
During development and review, test the changed behavior and its affected callers. Choose the
|
||||
scope before running commands; include unchanged code when a shared dependency, contract or
|
||||
configuration change can affect it. A new commit by itself does not invalidate every earlier
|
||||
check. Batch related fixes before expensive integration checks.
|
||||
|
||||
| Change | Checks during development and review |
|
||||
| --- | --- |
|
||||
| Documentation | Document links/examples and `git diff --check` |
|
||||
| Go implementation | Changed packages and affected callers; add race checks for concurrency/shared state and both drivers for SQLite behavior |
|
||||
| Frontend | Affected Vitest tests and type checking; style/lint checks for touched presentation, browser interactions for affected visible behavior |
|
||||
| API, persistence or shared infrastructure | Both sides of the changed boundary, generated output where relevant, and the workflows that consume it |
|
||||
| Installer, packaging or test tooling | The owning script tests and the affected installation/package workflow |
|
||||
| Performance-sensitive implementation | The affected native benchmarks, once per source; larger fixtures when traversal or scale behavior changes |
|
||||
|
||||
Use runner selection (`go test PACKAGE -run PATTERN`, Vitest file filters and explicit script
|
||||
tests) rather than creating another test framework. After fixing a failure, rerun its reproducer
|
||||
and affected suite/callers. Do not restart unrelated passed suites to get another all-green run.
|
||||
A failed or interrupted full run remains incomplete; record the completed checks separately.
|
||||
|
||||
For slow verification, report the whole workflow's elapsed time, including setup, transfers,
|
||||
queueing, retries and diagnosis, separately from test execution. Use existing runner/CI timings
|
||||
to identify the costly stages; individual step timings do not explain the whole wait.
|
||||
|
||||
Finish review and fixes before the final full source and package acceptance. The candidate CI
|
||||
runs `make release-check` once before its platform matrix; a duplicate local full preflight is
|
||||
not required. Use `make check` for an intentional full integration check, not after every edit.
|
||||
If a final check finds a defect, verify the fix locally at its affected boundaries before
|
||||
starting the replacement candidate. Changes to shared contracts or infrastructure may justify
|
||||
wider checks; record the reason instead of automatically repeating everything.
|
||||
|
||||
Retain passing evidence for unaffected components when their implementation, relevant
|
||||
dependencies, configuration, fixtures, harness and environment remain unchanged. Record the
|
||||
old and final source identities and the reviewed diff that establishes this. New archives still
|
||||
need their own content, identity and checksum checks. Rebuilt binaries require relevant package
|
||||
smoke tests; changed behavior requires its package workflows. Acceptance of earlier bytes alone
|
||||
cannot certify a new archive, but a new archive does not require repeating unrelated benchmarks
|
||||
or every exploratory/manual check.
|
||||
|
||||
## Repository Layout and Entrypoints
|
||||
|
||||
File ownership and placement rules are maintained in [Repository Layout](../../AGENTS.md#repository-layout). Follow those rules when adding or moving files; this section owns the development commands and output overrides.
|
||||
|
||||
The root [Makefile](../../Makefile) is the common development/CI entrypoint. Its default target displays help; implementation stays with the owning project or script. Frontend compilation remains in package.json. Use `make build`, `make backend`, `make backend-linux`, `make frontend`, `make release`, `make preview`, `make generate`, or `make demo`. `make check` runs documentation and IDL naming checks, Go unit tests/vet, compilation and vet of the E2E harness, frontend checks, tool-script tests and shell syntax checks; `make test-race` and `make test-e2e` are explicit additional checks. Set `YATM_E2E_BIN_DIR` to the extracted candidate directory for `make test-e2e`; the [E2E guide](e2e-test.md) also documents source-based harness runs. Native Preview module validation remains separate from the root Go test suite.
|
||||
The root [Makefile](../../Makefile) is the common development/CI entrypoint. Its default target displays help; implementation stays with the owning project or script. Frontend compilation remains in package.json. Use `make build`, `make backend`, `make backend-linux`, `make frontend`, `make release`, `make preview`, `make generate`, or `make demo` / `make demo-dev`. `make check` runs documentation and IDL naming checks, Go unit tests/vet, compilation and vet of the E2E harness, frontend checks, tool-script tests and shell syntax checks; `make test-race` and `make test-e2e` are explicit additional checks. Set `YATM_E2E_BIN_DIR` to the extracted candidate directory for `make test-e2e`; the [E2E guide](e2e-test.md) also documents source-based harness runs. Native Preview module validation remains separate from the root Go test suite.
|
||||
|
||||
Scripts locate the checkout independently of the caller's working directory. `RELEASE_DIRECTORY` overrides the final archive directory; relative values resolve from the caller's working directory before scripts change directories. Build tools are source files, not build output. The root `install-release.sh` remains the user installation entrypoint, and installation package paths are independent of source layout.
|
||||
|
||||
@@ -49,18 +89,25 @@ For official LTFS file-backend acceptance, explicitly configure the optional `te
|
||||
|
||||
## Local Checks
|
||||
|
||||
Select checks using [Check Scope and Reuse](#check-scope-and-reuse). For example, a legacy
|
||||
migration repair checks its implementation and installer caller:
|
||||
|
||||
```shell
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go test -race ./internal/library ./internal/migrate/legacy ./internal/apis ./internal/executor/... ./internal/preview/...
|
||||
go test ./internal/migrate/legacy ./cmd/migrate
|
||||
go vet ./internal/migrate/legacy ./cmd/migrate
|
||||
go test -race ./internal/migrate/legacy ./cmd/migrate
|
||||
CGO_ENABLED=0 go test ./internal/migrate/legacy ./cmd/migrate
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The coverage below is an inventory for choosing affected checks, not a requirement to run
|
||||
every listed suite after each change. The [Release SOP](#release-sop) owns full verification.
|
||||
|
||||
For content-model changes, cover opaque nullable signatures, unique File/version pairs, independent same-content Files, one original per File, repeated-content version reuse, shared-copy lookup, cross-batch import rollback, and evidence-based legacy migration with source preservation. These checks do not repair or maintain compatibility with old Draft databases.
|
||||
|
||||
Restore association checks include existing original associations, ignored outputs, multi-version candidate freezing, exact-content adoption, namespace collisions, final Media identity failure and explicit Library/Job publication failure. Scan verification checks include real uncached reads, per-item versus device errors, absent baselines and content-guarded health publication. Run both SQLite drivers and race checks for `./internal/executor/restore`, `./internal/executor/scan` and `./internal/library`; imported restored Files and original associations must remain intact, and imported bad-copy observations must not become healthy or unchecked accidentally.
|
||||
|
||||
For Files and Location changes, include `./internal/executor/scan`, `./internal/executor/observation`, `./internal/executor/fileops`, `./internal/treeops`, `./internal/ignore`, `./internal/demo`, and `./cmd/yatm-cli` in affected unit/race checks, and run the [live-file, Volume, and Preview CLI E2E](e2e-test.md). Cover optional File identity, pure browsing versus explicit collection, complete directory List, paged Search and changefeed departures, Ignore versus authorization, guarded references, physical-operation results without persistent receipts and ordered continuity. Run the shared organization contract against Library, filesystem and object-storage-semantic test adapters: directory merges, retained target associations, duplicate selections, conflicts, cancellation, bounded traversal and partial publication. Do not add user Copy acceptance to the supported move/mkdir/delete contract.
|
||||
For Files and Location changes, inspect callers in `./internal/executor/scan`, `./internal/executor/observation`, `./internal/executor/fileops`, `./internal/treeops`, `./internal/ignore`, `./internal/demo`, and `./cmd/yatm-cli`; run the affected unit/race checks and select the affected [live-file, Volume, or Preview CLI E2E](e2e-test.md). Cover optional File identity, pure browsing versus explicit collection, complete directory List, paged Search and changefeed departures, Ignore versus authorization, guarded references, physical-operation results without persistent receipts and ordered continuity where changed. Shared organization changes run the contract against its affected Library, filesystem and object-storage-semantic adapters: directory merges, retained target associations, duplicate selections, conflicts, cancellation, bounded traversal and partial publication. Do not add user Copy acceptance to the supported move/mkdir/delete contract.
|
||||
|
||||
Scan tests exercise one pipeline with known-only, fill-missing and force-read policies, optional comparison/Preview and each publication policy. Known-only cache misses must not hash; verification must neither trust cache nor replace its saved baseline. Preview failures must preserve otherwise valid observations, while source-read and physical-identity failures prevent publication of the current scope; a new Scan reads its complete selection. `analyze`, `preview create` and `verify` CLI presets must resolve to this same Scan service, not separate runners.
|
||||
|
||||
@@ -126,6 +173,19 @@ This focused check measures archived-content reconciliation, while complete migr
|
||||
uses the packaged installer and a copied legacy backup through the
|
||||
[package controller](e2e-test.md#remote-packaged-binary-acceptance).
|
||||
|
||||
Use repeatable `--package ./internal/apis` or `--package ./internal/executor/scan` to compare
|
||||
only that package's suites within the selected tier. Package names must match the manifest;
|
||||
structural checks and common harness files follow the selected packages. `pair.json` records
|
||||
requested, selected, executed and skipped packages and structural scope. A completed subset is
|
||||
not a full release comparison. Baseline and candidate use independent mutable temporary fixtures.
|
||||
The manifest and shared harness are captured once and applied identically to both sources.
|
||||
|
||||
Fixture-only changes can measure preparation separately with
|
||||
`go test ./internal/apis -run '^$' -bench '^BenchmarkFilesFixtureSetup$' -benchtime=1x -benchmem -count=1`.
|
||||
It measures fresh file creation and catalog seeding at 10,000 and 100,000 entries; deferred cleanup
|
||||
is outside the timer. It is not a List/Search runtime comparison. Reuse their existing measurements
|
||||
when their implementation and equivalent input distribution remain unchanged.
|
||||
|
||||
The collector snapshots the supplied sources and installs the same reviewed harness in both.
|
||||
It records commits and actual source hashes, including dirty changes, then compiles both suites
|
||||
before measuring. Each suite executes its standard Go test binary once per source. This is the
|
||||
@@ -179,11 +239,12 @@ an incomplete comparison or unexplained flag remains open work. A completed loca
|
||||
satisfies this requirement without uploading its baseline or repeating it on a hosted runner.
|
||||
Candidate assembly runs its source and package gates without automatically invoking Performance.
|
||||
|
||||
If only documentation or unrelated test tooling changes after measurement, retain the measured
|
||||
commit and comparison unchanged. Record the final commit and review the complete diff to establish
|
||||
that runtime source, dependencies, benchmark harness and measurement settings are identical.
|
||||
Such a diff does not require another performance run; changed runtime inputs or harnesses do.
|
||||
The final source preflight and acceptance of the exact release archives are still required.
|
||||
After measurement, review the diff against each measured operation. Retain its evidence when
|
||||
the relevant runtime source, dependencies, harness, fixtures and measurement settings are
|
||||
unchanged, recording the measured and final commits. Documentation or unrelated runtime/tooling
|
||||
changes do not require repeating that operation. Rerun only affected benchmarks and resolve their
|
||||
flags; do not repeat the whole release tier for a change to one component. The final source
|
||||
preflight and acceptance of the exact release archives remain required.
|
||||
|
||||
## Large Identical Results
|
||||
|
||||
@@ -207,6 +268,12 @@ skips this fixture, and the default Demo remains small enough for interactive re
|
||||
|
||||
## Local Demo
|
||||
|
||||
`make demo-dev` prepares the same Demo fixture and builds the backend incrementally, then serves
|
||||
Vite hot reload at `http://localhost:5173`, proxying the selected backend listen address.
|
||||
`YATM_DEMO_FRONTEND_PORT` overrides the strict frontend port. A port conflict fails startup;
|
||||
both children stop on signals or either child's exit. Fixture reset remains explicit through
|
||||
`YATM_DEMO_RESET=1`. `make demo` retains the production frontend build for packaged-browser checks.
|
||||
|
||||
`make demo` builds and serves the disposable review fixture at `http://127.0.0.1:18080`, normally
|
||||
under the system temporary directory. Restarting preserves reviewer changes. Select an isolated
|
||||
temporary root and reset it explicitly after fixture or pre-stable format changes. Stop its server
|
||||
@@ -317,7 +384,15 @@ source review and physical acceptance retain their own explicit decisions.
|
||||
Do not publish the backup branch or rewrite already public history. Review the final diff and
|
||||
commit message again; squashing does not sanitize final content. Finish required dependency
|
||||
releases and update their pins before freezing YATM.
|
||||
3. **Run the source preflight on the clean release commit.** Put Bash 4.4 or newer on `PATH` for
|
||||
3. **Complete review and fixes with affected checks.** Review the integrated source and finish
|
||||
focused fixes/verification until no unresolved, unaccepted finding remains. Use
|
||||
[Check Scope and Reuse](#check-scope-and-reuse); do not run a full preflight per fix.
|
||||
Complete the [performance acceptance](#performance-acceptance) before source-push approval,
|
||||
retaining unaffected measurements and checking only changed operations again.
|
||||
4. **Run the final source preflight on the clean release commit.** The candidate workflow owns
|
||||
the final full run before its platform matrix. A local full run is available for an intentional
|
||||
integration check or diagnosis, but is not a prerequisite that CI must then repeat.
|
||||
Put Bash 4.4 or newer on `PATH` for
|
||||
the Linux installer unit tests; macOS's bundled Bash 3.2 is insufficient. Install the frozen frontend dependencies,
|
||||
pinned scanner and the protobuf tools used by current generated source: protoc 26.0,
|
||||
protoc-gen-go 1.33.0 and protoc-gen-go-grpc 1.3.0. Preview acceptance also requires a separate
|
||||
@@ -343,12 +418,6 @@ source review and physical acceptance retain their own explicit decisions.
|
||||
Native Preview race tests and vet run with its private libraries during each runnable helper build.
|
||||
Its corresponding-source archive has a separate inventory that retains required vendor/native
|
||||
sources. Filename gates do not prove that a module is used or documentation is semantically correct.
|
||||
4. **Complete review and fixes.** Review the integrated source and repeat focused fixes/verification
|
||||
until no unresolved, unaccepted finding remains. Source changes require a new clean commit and
|
||||
another preflight; record each check against its actual inputs and follow the performance
|
||||
policy above when only unrelated tooling or documentation changed.
|
||||
Complete the final-source [performance acceptance](#performance-acceptance) before approving
|
||||
the candidate or source push.
|
||||
5. **Build and accept one candidate set.** GitHub Actions requires the exact reviewed commit to
|
||||
exist in this repository's remote. Obtain explicit authorization for that source push before
|
||||
dispatch; this does not authorize a tag, Release or asset upload. Dispatch the Release candidate
|
||||
@@ -398,6 +467,15 @@ node build/release/check-static-linux.mjs output/yatm-httpd
|
||||
|
||||
The [optional native Preview helper](../../previewworker/build/README.md) has its own module, build, platform baseline and private native libraries. Its native dependencies are never linked into the main binaries.
|
||||
|
||||
The source-check job builds the frontend once and collects its locked production dependency
|
||||
notices. The internal `shared-frontend` artifact records version, commit, lockfile hash and every
|
||||
file checksum. Each main-platform build validates its identity and exact file inventory, then
|
||||
copies the same frontend and Node notices and adds its own Go/native notices. Source maps, private
|
||||
configuration, missing files and extra files are rejected. The internal manifest is never copied
|
||||
into a release archive. Local `make release` calls the same collector; set
|
||||
`FRONTEND_ARTIFACT_DIRECTORY` to reuse a previously collected artifact with matching inputs.
|
||||
Preview native-object caching follows the [native build guide](../../previewworker/build/README.md#native-compiler-cache).
|
||||
|
||||
Release validation requires exactly nine main archives, three optional helper archives and three matching source archives. Each main archive still passes `build/release/check-release.mjs`; `build/release/check-candidate-set.mjs` additionally verifies the complete platform set, matching identities, helper file boundaries and corresponding-source checksums. Linux E2E receives the extracted helper through `YATM_TEST_PREVIEW_HELPER` so native Preview acceptance cannot silently skip.
|
||||
|
||||
Release builds require clean committed source and a matching `RELEASE_COMMIT`. Inputs are exported
|
||||
@@ -461,6 +539,6 @@ a new build and acceptance run before publication. Mocked provenance and upload
|
||||
|
||||
## Delivery Gates
|
||||
|
||||
The candidate preserves [independent format identities](../architecture/persistence.md#published-data-formats) and legacy migration/import. Record acceptance for the exact candidate bytes; earlier development results do not certify a rebuilt release. Preserve a forward path for supported published data. Before the first stable v1 release, replace the [pre-stable compatibility policy](../README.md#pre-stable-compatibility) with the published stable API/upgrade policy.
|
||||
The candidate preserves [independent format identities](../architecture/persistence.md#published-data-formats) and legacy migration/import. Record acceptance for the exact candidate bytes, reusing unaffected source-level evidence under [Check Scope and Reuse](#check-scope-and-reuse); earlier development results alone do not certify a rebuilt release. Preserve a forward path for supported published data. Before the first stable v1 release, replace the [pre-stable compatibility policy](../README.md#pre-stable-compatibility) with the published stable API/upgrade policy.
|
||||
|
||||
Branch, push and publication gates follow [Delivery and Authorization](../../AGENTS.md#delivery-and-authorization). Release packages include only the operator documents allowlisted in the [documentation convention](../README.md#release-packages); development and architecture documents remain in the repository checkout.
|
||||
+212
-52
@@ -1,6 +1,8 @@
|
||||
"""Local SSH controller for shipped YATM programs on an isolated Linux test host."""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -11,11 +13,76 @@ import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PROGRAMS = ("yatm-httpd", "yatm-cli", "yatm-migrate", "yatm-export-library", "yatm-lto-info")
|
||||
|
||||
# Dependencies own required state, not the preceding case's position in the full run.
|
||||
CASE_DEPENDENCIES = {
|
||||
"normal": {
|
||||
"fresh-systemd-installation": (),
|
||||
"readonly-checks-and-unchanged-rerun": ("fresh-systemd-installation",),
|
||||
"literal-filenames-and-nanoseconds": ("fresh-systemd-installation",),
|
||||
"invalid-byte-row-and-cli-failure": ("fresh-systemd-installation",),
|
||||
"scan-all-source-preparation-barrier": ("fresh-systemd-installation",),
|
||||
"scan-overlap-multiple-locations-and-recreate": ("fresh-systemd-installation",),
|
||||
"recorded-library-and-live-location-search": ("scan-overlap-multiple-locations-and-recreate",),
|
||||
"dryrun-delete-and-recover-literal-path": ("fresh-systemd-installation",),
|
||||
"volume-archive-restore-and-media-scan": ("fresh-systemd-installation",),
|
||||
"volume-copy-damage-missing-and-health": ("volume-archive-restore-and-media-scan",),
|
||||
"volume-inventory-import-delete-and-register": ("volume-copy-damage-missing-and-health",),
|
||||
"lossless-jsonl-export-import": ("scan-overlap-multiple-locations-and-recreate",),
|
||||
"native-preview-assets-and-generation-policies": ("fresh-systemd-installation",),
|
||||
"ltfs-format-append-restore-verify": ("fresh-systemd-installation",),
|
||||
"ltfs-full-prefix-second-media-restore-verify": ("fresh-systemd-installation",),
|
||||
"sqlite-wal-enable-persist-and-disable": ("scan-overlap-multiple-locations-and-recreate",),
|
||||
# Preserve actual Catalog and historical Jobs rather than comparing empty inventories.
|
||||
"same-version-replacement-and-complete-backup-recovery": ("scan-overlap-multiple-locations-and-recreate",),
|
||||
},
|
||||
"legacy": {
|
||||
"legacy-readonly-installer-review": (),
|
||||
"legacy-prepare-decline-and-abort": (),
|
||||
"legacy-exact-package-installer-upgrade": (),
|
||||
"legacy-repeated-cleanup-and-historical-repair": ("legacy-exact-package-installer-upgrade",),
|
||||
"legacy-complete-jsonl-roundtrip": ("legacy-exact-package-installer-upgrade",),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def case_scope(args):
|
||||
profile = "legacy" if getattr(args, "legacy_package", None) else "normal"
|
||||
dependencies = CASE_DEPENDENCIES[profile]
|
||||
unavailable = {}
|
||||
if profile == "normal":
|
||||
if not args.preview_archive:
|
||||
unavailable["native-preview-assets-and-generation-policies"] = "No Preview archive was selected."
|
||||
if not args.ltfs:
|
||||
for name in ("ltfs-format-append-restore-verify", "ltfs-full-prefix-second-media-restore-verify"):
|
||||
unavailable[name] = "--ltfs was not selected."
|
||||
available = [name for name in dependencies if name not in unavailable]
|
||||
requested = list(dict.fromkeys(getattr(args, "case", None) or available))
|
||||
selected = set()
|
||||
|
||||
def include(name):
|
||||
require(name in dependencies, f"Unknown {profile} case: {name}.")
|
||||
require(name not in unavailable, f"Case {name} is unavailable: {unavailable.get(name)}")
|
||||
if name in selected:
|
||||
return
|
||||
for prerequisite in dependencies[name]:
|
||||
include(prerequisite)
|
||||
selected.add(name)
|
||||
|
||||
for name in requested:
|
||||
include(name)
|
||||
return {"profile": profile, "requested": requested,
|
||||
"prerequisite": [name for name in dependencies if name in selected and name not in requested],
|
||||
"selected": [name for name in dependencies if name in selected], "executed": [],
|
||||
"skipped": [{"name": name, "reason": unavailable.get(name, "Not selected by --case.")}
|
||||
for name in dependencies if name not in selected],
|
||||
"subset": selected != set(available)}
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
@@ -51,6 +118,7 @@ def check_arguments(args):
|
||||
require(not args.ltfs and not args.preview_archive, "Run legacy migration separately from LTFS/Preview acceptance.")
|
||||
require(re.fullmatch(r"yatm-linux-amd64-v0\.1\.\d+\.tar\.gz", legacy_package.name),
|
||||
"Select a published Linux amd64 v0.1.x package.")
|
||||
case_scope(args)
|
||||
|
||||
|
||||
class Acceptance:
|
||||
@@ -61,32 +129,53 @@ class Acceptance:
|
||||
self.install = None
|
||||
self.url = None
|
||||
self.sequence = 0
|
||||
self.started = time.monotonic()
|
||||
self.current_case = None
|
||||
self.current_phase = None
|
||||
scope = case_scope(args)
|
||||
self.selected_cases = set(scope["selected"])
|
||||
args.out.mkdir(parents=True, mode=0o700)
|
||||
self.report = {"status": "running", "version": args.version, "commit": args.commit,
|
||||
"host": args.host, "cases": [], "commands": [], "limits": [],
|
||||
"host": args.host, "cases": [], "commands": [], "phases": [], "limits": [],
|
||||
"case_scope": scope,
|
||||
"release_accepted": False,
|
||||
"scope": "Exact Linux package over SSH; controller and assertions remain local."}
|
||||
self.save()
|
||||
|
||||
def save(self):
|
||||
self.report["duration_seconds"] = time.monotonic() - self.started
|
||||
(self.args.out / "report.json").write_text(json.dumps(self.report, indent=2) + "\n")
|
||||
|
||||
def run(self, argv, body=None, expected=0, timeout=180):
|
||||
def command_record(self, argv):
|
||||
self.sequence += 1
|
||||
stem = f"{self.sequence:04d}"
|
||||
record = {"argv": [str(value) for value in argv], "stdout": stem + ".stdout", "stderr": stem + ".stderr"}
|
||||
record = {"argv": [str(value) for value in argv], "stdout": stem + ".stdout", "stderr": stem + ".stderr",
|
||||
"case": self.current_case, "phase": self.current_phase}
|
||||
self.report["commands"].append(record)
|
||||
self.save()
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def invoke(argv, body, timeout):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
result = subprocess.run(record["argv"], input=body, capture_output=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
(self.args.out / record["stdout"]).write_bytes(error.stdout or b"")
|
||||
(self.args.out / record["stderr"]).write_bytes(error.stderr or b"")
|
||||
result = subprocess.run(argv, input=body, capture_output=True, timeout=timeout)
|
||||
except Exception as error:
|
||||
result = error
|
||||
return result, time.monotonic() - start
|
||||
|
||||
def command_result(self, record, outcome, expected, timeout):
|
||||
result, record["duration_seconds"] = outcome
|
||||
(self.args.out / record["stdout"]).write_bytes(getattr(result, "stdout", None) or b"")
|
||||
(self.args.out / record["stderr"]).write_bytes(getattr(result, "stderr", None) or b"")
|
||||
stem = record["stdout"].removesuffix(".stdout")
|
||||
if isinstance(result, subprocess.TimeoutExpired):
|
||||
record["timed_out_seconds"] = timeout
|
||||
self.save()
|
||||
raise RuntimeError(f"Command {stem} timed out; inspect its private transcript.") from error
|
||||
(self.args.out / record["stdout"]).write_bytes(result.stdout)
|
||||
(self.args.out / record["stderr"]).write_bytes(result.stderr)
|
||||
raise RuntimeError(f"Command {stem} timed out; inspect its private transcript.") from result
|
||||
if isinstance(result, Exception):
|
||||
record["error"] = str(result)
|
||||
self.save()
|
||||
raise result
|
||||
record["exit_code"] = result.returncode
|
||||
self.save()
|
||||
if expected is not None:
|
||||
@@ -94,11 +183,36 @@ class Acceptance:
|
||||
f"Command {stem} exited {result.returncode}, expected {expected}; inspect its private transcript.")
|
||||
return result
|
||||
|
||||
def remote(self, argv, body=None, expected=0, timeout=180):
|
||||
def run(self, argv, body=None, expected=0, timeout=180):
|
||||
record = self.command_record(argv)
|
||||
self.save()
|
||||
return self.command_result(record, self.invoke(record["argv"], body, timeout), expected, timeout)
|
||||
|
||||
def remote_arguments(self, argv, timeout):
|
||||
# Quote each argument once; no fixture filename or CLI selection becomes shell source.
|
||||
command = ["timeout", "--kill-after=10s", f"{timeout}s", *map(str, argv)]
|
||||
return self.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", self.args.host,
|
||||
shlex.join(command)], body, expected, timeout + 20)
|
||||
return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", self.args.host, shlex.join(command)]
|
||||
|
||||
def remote(self, argv, body=None, expected=0, timeout=180):
|
||||
return self.run(self.remote_arguments(argv, timeout), body, expected, timeout + 20)
|
||||
|
||||
def remote_batch(self, commands, expected=0, timeout=180):
|
||||
"""Batch independent read-only calls; retain every result even when one fails."""
|
||||
if not commands:
|
||||
return []
|
||||
records = [self.command_record(self.remote_arguments(argv, timeout)) for argv in commands]
|
||||
self.save()
|
||||
results, failures = [], []
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(records))) as pool:
|
||||
futures = [pool.submit(self.invoke, record["argv"], None, timeout + 20) for record in records]
|
||||
for record, future in zip(records, futures):
|
||||
try:
|
||||
results.append(self.command_result(record, future.result(), expected, timeout + 20))
|
||||
except Exception as error:
|
||||
failures.append(error)
|
||||
if failures:
|
||||
raise failures[0]
|
||||
return results
|
||||
|
||||
def upload(self, files, directory):
|
||||
require(directory == self.root or directory.startswith(self.root + "/"), "Upload is outside the owned root.")
|
||||
@@ -128,10 +242,37 @@ class Acceptance:
|
||||
require(response["job"]["status"] == status, "Job reached an unexpected durable state.")
|
||||
return response["job"]
|
||||
|
||||
def wants_case(self, name):
|
||||
return name in self.selected_cases
|
||||
|
||||
@contextmanager
|
||||
def phase(self, name):
|
||||
item = {"name": name, "status": "running"}
|
||||
self.report["phases"].append(item)
|
||||
previous, self.current_phase = self.current_phase, name
|
||||
self.save()
|
||||
start = time.monotonic()
|
||||
try:
|
||||
yield
|
||||
except Exception:
|
||||
item["status"] = "failed"
|
||||
raise
|
||||
else:
|
||||
item["status"] = "passed"
|
||||
finally:
|
||||
item["duration_seconds"] = time.monotonic() - start
|
||||
self.current_phase = previous
|
||||
self.save()
|
||||
|
||||
def case(self, name, operation):
|
||||
if not self.wants_case(name):
|
||||
return
|
||||
item = {"name": name, "status": "running"}
|
||||
self.report["cases"].append(item)
|
||||
self.report["case_scope"]["executed"].append(name)
|
||||
previous, self.current_case = self.current_case, name
|
||||
self.save()
|
||||
start = time.monotonic()
|
||||
try:
|
||||
operation()
|
||||
except Exception:
|
||||
@@ -141,6 +282,8 @@ class Acceptance:
|
||||
item["status"] = "passed"
|
||||
print(name + " passed", flush=True)
|
||||
finally:
|
||||
item["duration_seconds"] = time.monotonic() - start
|
||||
self.current_case = previous
|
||||
self.save()
|
||||
|
||||
def verify_inputs(self):
|
||||
@@ -169,17 +312,17 @@ class Acceptance:
|
||||
|
||||
def prepare_host(self):
|
||||
# No service or existing installation is touched before host and storage checks succeed.
|
||||
require(self.remote(["uname", "-sm"]).stdout.strip() == b"Linux x86_64", "Linux x86_64 is required.")
|
||||
require(self.remote(["id", "-u"]).stdout.strip() == b"0", "Systemd acceptance needs an authorized root test session.")
|
||||
space = self.remote(["df", "-Pk", self.args.test_parent]).stdout.decode()
|
||||
required_gib = 16 if self.args.ltfs else 4
|
||||
require(int(space.splitlines()[-1].split()[3]) >= required_gib * 1024 * 1024,
|
||||
f"At least {required_gib} GiB of test space is required.")
|
||||
required_tools = ["bash", "tar", "sha256sum", "systemctl", "curl", "jq", "ss", "setfattr", "getfattr"]
|
||||
if self.args.ltfs:
|
||||
required_tools += ["mkltfs", "ltfs", "fusermount", "mountpoint", "realpath", "findmnt", "truncate"]
|
||||
for tool in required_tools:
|
||||
self.remote(["sh", "-c", 'command -v "$1"', "check-tool", tool])
|
||||
checks = self.remote_batch([["uname", "-sm"], ["id", "-u"], ["df", "-Pk", self.args.test_parent],
|
||||
*[["sh", "-c", 'command -v "$1"', "check-tool", tool] for tool in required_tools]])
|
||||
require(checks[0].stdout.strip() == b"Linux x86_64", "Linux x86_64 is required.")
|
||||
require(checks[1].stdout.strip() == b"0", "Systemd acceptance needs an authorized root test session.")
|
||||
space = checks[2].stdout.decode()
|
||||
required_gib = 16 if self.args.ltfs else 4
|
||||
require(int(space.splitlines()[-1].split()[3]) >= required_gib * 1024 * 1024,
|
||||
f"At least {required_gib} GiB of test space is required.")
|
||||
root = self.remote(["mktemp", "-d", self.args.test_parent.rstrip("/") + "/yatm-package-acceptance.XXXXXXXX"]).stdout.decode().strip()
|
||||
require(re.fullmatch(re.escape(self.args.test_parent.rstrip("/")) + r"/yatm-package-acceptance\.[A-Za-z0-9]{8}", root),
|
||||
"Remote allocation returned an unexpected root.")
|
||||
@@ -223,9 +366,11 @@ class Acceptance:
|
||||
"-C", self.root + "/package"])
|
||||
self.verify_programs(self.root + "/package")
|
||||
# Supply an explicit Volume-only configuration using the published template's field names.
|
||||
template = self.remote(["cat", self.root + "/package/templates/config.example.yaml"]).stdout
|
||||
template_result, listeners = self.remote_batch([["cat", self.root + "/package/templates/config.example.yaml"],
|
||||
["ss", "-H", "-lnt"]])
|
||||
template = template_result.stdout
|
||||
require(b"database:" in template and b"paths:" in template, "Packaged configuration template is missing.")
|
||||
used = self.remote(["ss", "-H", "-lnt"]).stdout.decode()
|
||||
used = listeners.stdout.decode()
|
||||
ports = []
|
||||
while len(ports) < 2:
|
||||
port = 20000 + secrets.randbelow(30000)
|
||||
@@ -245,8 +390,9 @@ class Acceptance:
|
||||
self.save()
|
||||
|
||||
def verify_programs(self, directory):
|
||||
for program in PROGRAMS:
|
||||
value = json.loads(self.remote([directory + "/" + program, "--version"]).stdout)
|
||||
results = self.remote_batch([[directory + "/" + program, "--version"] for program in PROGRAMS])
|
||||
for program, result in zip(PROGRAMS, results):
|
||||
value = json.loads(result.stdout)
|
||||
require(value.get("program") == program and value.get("version") == self.args.version
|
||||
and value.get("commit") == self.args.commit, f"Offline identity differs: {program}.")
|
||||
|
||||
@@ -309,6 +455,44 @@ class Acceptance:
|
||||
self.report["cleanup"]["root_retained"] = False
|
||||
self.save()
|
||||
|
||||
def execute(self):
|
||||
try:
|
||||
with self.phase("verify-inputs"):
|
||||
self.verify_inputs()
|
||||
with self.phase("prepare-host"):
|
||||
self.prepare_host()
|
||||
with self.phase("stage"):
|
||||
self.stage()
|
||||
with self.phase("cases"):
|
||||
if getattr(self.args, "legacy_package", None):
|
||||
from package_legacy_cases import run_legacy
|
||||
run_legacy(self)
|
||||
else:
|
||||
self.case("fresh-systemd-installation", self.install_fresh)
|
||||
self.case("readonly-checks-and-unchanged-rerun", self.installation_checks)
|
||||
from package_cases import run_cases
|
||||
run_cases(self)
|
||||
from package_install_cases import installation_replacement
|
||||
self.case("same-version-replacement-and-complete-backup-recovery", lambda: installation_replacement(self))
|
||||
require(self.report["case_scope"]["executed"] == self.report["case_scope"]["selected"],
|
||||
"Selected cases did not execute in their stable order.")
|
||||
self.report["status"] = "passed"
|
||||
except Exception as error:
|
||||
self.report.update(status="failed", error=str(error))
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
with self.phase("cleanup"):
|
||||
self.cleanup()
|
||||
except Exception as error:
|
||||
self.report.update(status="failed", cleanup_error=str(error))
|
||||
raise
|
||||
finally:
|
||||
scope = self.report["case_scope"]
|
||||
scope["skipped"] += [{"name": name, "reason": "Not reached after failure."}
|
||||
for name in scope["selected"] if name not in scope["executed"]]
|
||||
self.save()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
@@ -323,36 +507,12 @@ def main():
|
||||
parser.add_argument("--keep-root", action="store_true", help="Retain fixture data after stopping the owned service.")
|
||||
parser.add_argument("--legacy-package", type=Path, help="Published v0.1.x Linux package for isolated upgrade acceptance.")
|
||||
parser.add_argument("--legacy-fixture", type=Path, help="Approved copied metadata archive; never a live installation.")
|
||||
parser.add_argument("--case", action="append", help="Select an exact existing case name; repeat to select more. Required cases run automatically.")
|
||||
args = parser.parse_args()
|
||||
check_arguments(args)
|
||||
os.umask(0o077)
|
||||
acceptance = Acceptance(args)
|
||||
try:
|
||||
acceptance.verify_inputs()
|
||||
acceptance.prepare_host()
|
||||
acceptance.stage()
|
||||
if args.legacy_package:
|
||||
from package_legacy_cases import run_legacy
|
||||
run_legacy(acceptance)
|
||||
else:
|
||||
acceptance.case("fresh-systemd-installation", acceptance.install_fresh)
|
||||
acceptance.case("readonly-checks-and-unchanged-rerun", acceptance.installation_checks)
|
||||
from package_cases import run_cases
|
||||
run_cases(acceptance)
|
||||
from package_install_cases import installation_replacement
|
||||
acceptance.case("same-version-replacement-and-complete-backup-recovery", lambda: installation_replacement(acceptance))
|
||||
acceptance.report["status"] = "passed"
|
||||
except Exception as error:
|
||||
acceptance.report.update(status="failed", error=str(error))
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
acceptance.cleanup()
|
||||
except Exception as error:
|
||||
acceptance.report.update(status="failed", cleanup_error=str(error))
|
||||
raise
|
||||
finally:
|
||||
acceptance.save()
|
||||
acceptance.execute()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+19
-12
@@ -306,18 +306,25 @@ class Workflows:
|
||||
|
||||
|
||||
def run_cases(test):
|
||||
workflows = Workflows(test)
|
||||
for name, method in (("literal-filenames-and-nanoseconds", workflows.listing),
|
||||
("invalid-byte-row-and-cli-failure", workflows.invalid_listing),
|
||||
("scan-all-source-preparation-barrier", workflows.preparation_barrier),
|
||||
("scan-overlap-multiple-locations-and-recreate", workflows.scan),
|
||||
("recorded-library-and-live-location-search", workflows.search),
|
||||
("dryrun-delete-and-recover-literal-path", workflows.file_operations),
|
||||
("volume-archive-restore-and-media-scan", workflows.archive_restore),
|
||||
("volume-copy-damage-missing-and-health", workflows.verify),
|
||||
("volume-inventory-import-delete-and-register", workflows.inventory),
|
||||
("lossless-jsonl-export-import", workflows.backup)):
|
||||
test.case(name, method)
|
||||
cases = (("literal-filenames-and-nanoseconds", "listing"),
|
||||
("invalid-byte-row-and-cli-failure", "invalid_listing"),
|
||||
("scan-all-source-preparation-barrier", "preparation_barrier"),
|
||||
("scan-overlap-multiple-locations-and-recreate", "scan"),
|
||||
("recorded-library-and-live-location-search", "search"),
|
||||
("dryrun-delete-and-recover-literal-path", "file_operations"),
|
||||
("volume-archive-restore-and-media-scan", "archive_restore"),
|
||||
("volume-copy-damage-missing-and-health", "verify"),
|
||||
("volume-inventory-import-delete-and-register", "inventory"),
|
||||
("lossless-jsonl-export-import", "backup"))
|
||||
names = [name for name, _ in cases] + ["native-preview-assets-and-generation-policies",
|
||||
"ltfs-format-append-restore-verify", "ltfs-full-prefix-second-media-restore-verify",
|
||||
"sqlite-wal-enable-persist-and-disable"]
|
||||
if not any(test.wants_case(name) for name in names):
|
||||
return
|
||||
with test.phase("workflow-fixtures"):
|
||||
workflows = Workflows(test)
|
||||
for name, method in cases:
|
||||
test.case(name, getattr(workflows, method))
|
||||
if test.args.preview_archive:
|
||||
test.case("native-preview-assets-and-generation-policies", workflows.preview)
|
||||
else:
|
||||
|
||||
@@ -84,6 +84,7 @@ def run_legacy(test):
|
||||
archive.add(item, arcname=item.name)
|
||||
test.report["legacy_inputs"] = {"version": legacy_version, "package_sha256": sha256(args.legacy_package),
|
||||
"source_sha256": source_hash, "fixture_sha256": sha256(fixture)}
|
||||
test.report["limits"].append("Legacy acceptance uses copied metadata and Archive history; no original file or physical Tape is accessed.")
|
||||
test.save()
|
||||
test.upload([args.legacy_package, fixture], test.root)
|
||||
test.remote(["mkdir", test.install])
|
||||
@@ -133,6 +134,10 @@ def run_legacy(test):
|
||||
test.case("legacy-readonly-installer-review", lambda: test.installer(check=True))
|
||||
test.case("legacy-prepare-decline-and-abort", abort)
|
||||
test.case("legacy-exact-package-installer-upgrade", upgrade)
|
||||
if not test.wants_case("legacy-exact-package-installer-upgrade"):
|
||||
require(sha256(args.legacy_fixture) == source_hash, "Original copied backup changed.")
|
||||
test.save()
|
||||
return
|
||||
test.verify_programs(test.install)
|
||||
test.one("status")
|
||||
|
||||
@@ -194,9 +199,9 @@ def run_legacy(test):
|
||||
|
||||
# Finish migration validation before an import can regenerate derived directory IDs.
|
||||
from package_install_cases import wait_for_service
|
||||
test.remote(["systemctl", "start", test.service])
|
||||
wait_for_service(test)
|
||||
test.case("legacy-complete-jsonl-roundtrip", roundtrip)
|
||||
if test.wants_case("legacy-complete-jsonl-roundtrip"):
|
||||
test.remote(["systemctl", "start", test.service])
|
||||
wait_for_service(test)
|
||||
test.case("legacy-complete-jsonl-roundtrip", roundtrip)
|
||||
require(sha256(args.legacy_fixture) == source_hash, "Original copied backup changed.")
|
||||
test.report["limits"].append("Legacy acceptance uses copied metadata and Archive history; no original file or physical Tape is accessed.")
|
||||
test.save()
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Focused controller selection, timing and batching regressions; no host connections."""
|
||||
|
||||
from contextlib import ExitStack, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from package_acceptance import Acceptance, CASE_DEPENDENCIES, PROGRAMS, case_scope, check_arguments, main, sha256
|
||||
from package_legacy_cases import run_legacy
|
||||
|
||||
|
||||
class SelectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.args = SimpleNamespace(host="isolated-test", test_parent="/srv/acceptance",
|
||||
archive=Path("yatm-linux-amd64-v1.0.0-alpha.2.tar.gz"),
|
||||
preview_archive=None, version="v1.0.0-alpha.2", commit="1" * 40,
|
||||
out=self.root / "evidence", keep_root=False, ltfs=False,
|
||||
legacy_package=None, legacy_fixture=None, case=None)
|
||||
|
||||
def test_exact_requests_add_only_dependencies_once_in_stable_order(self):
|
||||
self.args.case = ["volume-inventory-import-delete-and-register", "recorded-library-and-live-location-search",
|
||||
"volume-inventory-import-delete-and-register"]
|
||||
scope = case_scope(self.args)
|
||||
self.assertEqual(scope["requested"], self.args.case[:2])
|
||||
self.assertEqual(scope["selected"], ["fresh-systemd-installation", "scan-overlap-multiple-locations-and-recreate",
|
||||
"recorded-library-and-live-location-search", "volume-archive-restore-and-media-scan",
|
||||
"volume-copy-damage-missing-and-health", "volume-inventory-import-delete-and-register"])
|
||||
self.assertEqual(scope["prerequisite"], ["fresh-systemd-installation", "scan-overlap-multiple-locations-and-recreate",
|
||||
"volume-archive-restore-and-media-scan", "volume-copy-damage-missing-and-health"])
|
||||
self.assertTrue(scope["subset"])
|
||||
self.assertEqual(scope["executed"], [])
|
||||
self.assertIn({"name": "native-preview-assets-and-generation-policies", "reason": "No Preview archive was selected."},
|
||||
scope["skipped"])
|
||||
|
||||
def test_invalid_profiles_and_unavailable_cases_fail_before_any_setup(self):
|
||||
for name in ("literal*", "literal-filenames", "legacy-readonly-installer-review",
|
||||
"native-preview-assets-and-generation-policies", "ltfs-format-append-restore-verify"):
|
||||
with self.subTest(name=name):
|
||||
self.args.case = [name]
|
||||
with self.assertRaisesRegex(RuntimeError, "Unknown|unavailable"):
|
||||
check_arguments(self.args)
|
||||
self.assertFalse(self.args.out.exists())
|
||||
self.args.legacy_package = Path("yatm-linux-amd64-v0.1.8.tar.gz")
|
||||
self.args.legacy_fixture = self.root / "approved-copy.tar.gz"
|
||||
self.args.case = ["fresh-systemd-installation"]
|
||||
with self.assertRaisesRegex(RuntimeError, "Unknown legacy case"):
|
||||
check_arguments(self.args)
|
||||
|
||||
def test_case_cli_option_is_repeatable(self):
|
||||
argv = ["package_acceptance.py", "--host", self.args.host, "--test-parent", self.args.test_parent,
|
||||
"--archive", str(self.args.archive), "--version", self.args.version, "--commit", self.args.commit,
|
||||
"--out", str(self.args.out), "--case", "literal-filenames-and-nanoseconds",
|
||||
"--case", "invalid-byte-row-and-cli-failure"]
|
||||
with mock.patch("package_acceptance.sys.argv", argv), mock.patch("package_acceptance.os.umask"), \
|
||||
mock.patch("package_acceptance.Acceptance") as controller:
|
||||
self.assertEqual(main(), 0)
|
||||
self.assertEqual(controller.call_args.args[0].case,
|
||||
["literal-filenames-and-nanoseconds", "invalid-byte-row-and-cli-failure"])
|
||||
controller.return_value.execute.assert_called_once()
|
||||
|
||||
def test_every_dependency_precedes_its_case_and_defaults_keep_complete_profiles(self):
|
||||
for profile, dependencies in CASE_DEPENDENCIES.items():
|
||||
names = list(dependencies)
|
||||
for name, prerequisites in dependencies.items():
|
||||
self.assertTrue(all(names.index(prerequisite) < names.index(name) for prerequisite in prerequisites))
|
||||
self.args.preview_archive = Path("preview.tar.gz")
|
||||
self.args.ltfs = True
|
||||
scope = case_scope(self.args)
|
||||
self.assertEqual(scope["selected"], list(CASE_DEPENDENCIES["normal"]))
|
||||
self.assertEqual(scope["prerequisite"], [])
|
||||
self.assertEqual(scope["skipped"], [])
|
||||
self.assertFalse(scope["subset"])
|
||||
self.args.legacy_package = Path("legacy.tar.gz")
|
||||
self.args.case = ["legacy-complete-jsonl-roundtrip", "legacy-repeated-cleanup-and-historical-repair"]
|
||||
scope = case_scope(self.args)
|
||||
self.assertEqual(scope["selected"], ["legacy-exact-package-installer-upgrade",
|
||||
"legacy-repeated-cleanup-and-historical-repair", "legacy-complete-jsonl-roundtrip"])
|
||||
self.assertEqual(scope["prerequisite"], ["legacy-exact-package-installer-upgrade"])
|
||||
self.args.case = None
|
||||
self.assertEqual(case_scope(self.args)["selected"], list(CASE_DEPENDENCIES["legacy"]))
|
||||
|
||||
def execute_normal(self, controller):
|
||||
with ExitStack() as stack:
|
||||
for method in ("verify_inputs", "prepare_host", "stage", "install_fresh", "installation_checks", "cleanup"):
|
||||
stack.enter_context(mock.patch.object(controller, method))
|
||||
workflows = stack.enter_context(mock.patch("package_cases.Workflows"))
|
||||
workflows.return_value.locations = {"source": "1", "restored": "2"}
|
||||
stack.enter_context(mock.patch("package_install_cases.sqlite_wal_restart"))
|
||||
stack.enter_context(mock.patch("package_install_cases.installation_replacement"))
|
||||
stack.enter_context(mock.patch("package_ltfs_cases.TapeWorkflows"))
|
||||
stack.enter_context(redirect_stdout(io.StringIO()))
|
||||
controller.execute()
|
||||
return workflows
|
||||
|
||||
def test_normal_execution_automatically_runs_prerequisites_and_never_claims_release(self):
|
||||
self.args.case = ["recorded-library-and-live-location-search", "lossless-jsonl-export-import"]
|
||||
controller = Acceptance(self.args)
|
||||
workflows = self.execute_normal(controller)
|
||||
self.assertEqual(controller.report["case_scope"]["executed"], ["fresh-systemd-installation",
|
||||
"scan-overlap-multiple-locations-and-recreate", "recorded-library-and-live-location-search",
|
||||
"lossless-jsonl-export-import"])
|
||||
workflows.return_value.scan.assert_called_once()
|
||||
workflows.return_value.search.assert_called_once()
|
||||
workflows.return_value.backup.assert_called_once()
|
||||
workflows.return_value.listing.assert_not_called()
|
||||
workflows.return_value.preparation_barrier.assert_not_called()
|
||||
self.assertEqual(controller.report["status"], "passed")
|
||||
self.assertTrue(controller.report["case_scope"]["subset"])
|
||||
self.assertFalse(controller.report["release_accepted"])
|
||||
self.assertTrue(all(item["duration_seconds"] >= 0 for item in controller.report["cases"] + controller.report["phases"]))
|
||||
self.assertEqual(controller.report["phases"][-1]["name"], "cleanup")
|
||||
|
||||
def test_default_normal_execution_keeps_all_existing_cases_in_order(self):
|
||||
self.args.preview_archive = Path("preview.tar.gz")
|
||||
self.args.ltfs = True
|
||||
controller = Acceptance(self.args)
|
||||
self.execute_normal(controller)
|
||||
self.assertEqual([item["name"] for item in controller.report["cases"]], list(CASE_DEPENDENCIES["normal"]))
|
||||
self.assertFalse(controller.report["case_scope"]["subset"])
|
||||
|
||||
def test_installer_only_selection_does_not_create_workflow_fixtures(self):
|
||||
self.args.case = ["readonly-checks-and-unchanged-rerun"]
|
||||
controller = Acceptance(self.args)
|
||||
workflows = self.execute_normal(controller)
|
||||
workflows.assert_not_called()
|
||||
self.assertEqual(controller.report["case_scope"]["executed"], ["fresh-systemd-installation", "readonly-checks-and-unchanged-rerun"])
|
||||
|
||||
def test_monotonic_timing_retains_failure_timeout_cleanup_and_unreached_scope(self):
|
||||
self.args.case = ["recorded-library-and-live-location-search"]
|
||||
controller = Acceptance(self.args)
|
||||
timeout = subprocess.TimeoutExpired(["stub"], 2, output=b"partial", stderr=b"waiting")
|
||||
with ExitStack() as stack:
|
||||
for method in ("verify_inputs", "prepare_host", "stage"):
|
||||
stack.enter_context(mock.patch.object(controller, method))
|
||||
stack.enter_context(mock.patch.object(controller, "install_fresh", side_effect=lambda: controller.run(["stub"], timeout=2)))
|
||||
stack.enter_context(mock.patch.object(controller, "cleanup", side_effect=lambda: controller.run(["cleanup-stub"])))
|
||||
stack.enter_context(mock.patch("package_acceptance.subprocess.run", side_effect=[timeout, subprocess.CompletedProcess([], 0, b"", b"")]))
|
||||
stack.enter_context(mock.patch("package_acceptance.time.time", side_effect=AssertionError("wall clock used")))
|
||||
with self.assertRaisesRegex(RuntimeError, "timed out"):
|
||||
controller.execute()
|
||||
report = json.loads((self.args.out / "report.json").read_text())
|
||||
self.assertEqual(report["status"], "failed")
|
||||
self.assertEqual(report["cases"][0]["status"], "failed")
|
||||
self.assertEqual(report["commands"][0]["case"], "fresh-systemd-installation")
|
||||
self.assertEqual(report["commands"][0]["phase"], "cases")
|
||||
self.assertNotIn("exit_code", report["commands"][0])
|
||||
self.assertEqual(report["commands"][0]["timed_out_seconds"], 2)
|
||||
self.assertEqual(report["commands"][1]["phase"], "cleanup")
|
||||
self.assertEqual(report["phases"][-1]["status"], "passed")
|
||||
self.assertTrue(all(item["duration_seconds"] >= 0 for item in report["commands"] + report["cases"] + report["phases"]))
|
||||
self.assertIn({"name": "recorded-library-and-live-location-search", "reason": "Not reached after failure."}, report["case_scope"]["skipped"])
|
||||
|
||||
def test_cleanup_failure_is_reported_and_timed(self):
|
||||
self.args.case = ["fresh-systemd-installation"]
|
||||
controller = Acceptance(self.args)
|
||||
with ExitStack() as stack:
|
||||
for method in ("verify_inputs", "prepare_host", "stage", "install_fresh"):
|
||||
stack.enter_context(mock.patch.object(controller, method))
|
||||
stack.enter_context(mock.patch.object(controller, "cleanup", side_effect=RuntimeError("ownership unknown")))
|
||||
stack.enter_context(redirect_stdout(io.StringIO()))
|
||||
with self.assertRaisesRegex(RuntimeError, "ownership unknown"):
|
||||
controller.execute()
|
||||
self.assertEqual(controller.report["status"], "failed")
|
||||
self.assertEqual(controller.report["cleanup_error"], "ownership unknown")
|
||||
self.assertEqual(controller.report["phases"][-1]["status"], "failed")
|
||||
self.assertGreaterEqual(controller.report["phases"][-1]["duration_seconds"], 0)
|
||||
|
||||
def test_readonly_batch_is_bounded_quoted_and_retains_every_individual_outcome(self):
|
||||
controller = Acceptance(self.args)
|
||||
names = ["$(literal)", "line\nbreak", "back\\slash", "日本語", "failed", "timeout"]
|
||||
barrier = threading.Barrier(4, timeout=5)
|
||||
|
||||
def invoke(argv, **kwargs):
|
||||
command = shlex.split(argv[-1])
|
||||
self.assertEqual(command[:4], ["timeout", "--kill-after=10s", "3s", "read-only-stub"])
|
||||
self.assertEqual(kwargs["timeout"], 23)
|
||||
name = command[-1]
|
||||
if name in names[:4]:
|
||||
barrier.wait()
|
||||
if name == "timeout":
|
||||
raise subprocess.TimeoutExpired(argv, 23, output=b"partial", stderr=b"waiting")
|
||||
return subprocess.CompletedProcess(argv, 7 if name == "failed" else 0, name.encode(), b"individual stderr")
|
||||
|
||||
with mock.patch("package_acceptance.subprocess.run", side_effect=invoke):
|
||||
with self.assertRaisesRegex(RuntimeError, "exited 7"):
|
||||
controller.remote_batch([["read-only-stub", name] for name in names], timeout=3)
|
||||
commands = controller.report["commands"]
|
||||
self.assertEqual(len(commands), len(names))
|
||||
self.assertEqual([shlex.split(item["argv"][-1])[-1] for item in commands], names)
|
||||
self.assertEqual([item.get("exit_code") for item in commands], [0, 0, 0, 0, 7, None])
|
||||
for name, item in zip(names, commands):
|
||||
self.assertGreaterEqual(item["duration_seconds"], 0)
|
||||
self.assertEqual((self.args.out / item["stdout"]).read_bytes(), b"partial" if name == "timeout" else name.encode())
|
||||
|
||||
def test_missing_tool_records_all_prerequisites_and_never_allocates_a_root(self):
|
||||
controller = Acceptance(self.args)
|
||||
|
||||
def invoke(argv, **kwargs):
|
||||
command = shlex.split(argv[-1])[3:]
|
||||
output, code = b"", 0
|
||||
if command[0] == "uname":
|
||||
output = b"Linux x86_64\n"
|
||||
elif command[0] == "id":
|
||||
output = b"0\n"
|
||||
elif command[0] == "df":
|
||||
output = b"Filesystem 1024-blocks Used Available Capacity Mounted\n/dev/test 99999999 0 99999999 0% /srv\n"
|
||||
elif command[-1] == "jq":
|
||||
code = 1
|
||||
else:
|
||||
output = ("/usr/bin/" + command[-1]).encode()
|
||||
return subprocess.CompletedProcess(argv, code, output, b"")
|
||||
|
||||
with mock.patch("package_acceptance.subprocess.run", side_effect=invoke):
|
||||
with self.assertRaisesRegex(RuntimeError, "exited 1"):
|
||||
controller.prepare_host()
|
||||
self.assertIsNone(controller.root)
|
||||
self.assertEqual(len(controller.report["commands"]), 12)
|
||||
self.assertEqual(sum(item["exit_code"] != 0 for item in controller.report["commands"]), 1)
|
||||
self.assertFalse(any("mktemp" in shlex.split(item["argv"][-1]) for item in controller.report["commands"]))
|
||||
|
||||
def test_offline_identity_batch_keeps_each_result_before_rejecting_a_mismatch(self):
|
||||
controller = Acceptance(self.args)
|
||||
|
||||
def invoke(argv, **kwargs):
|
||||
command = shlex.split(argv[-1])[3:]
|
||||
self.assertEqual(command[-1], "--version")
|
||||
program = command[0].rsplit("/", 1)[-1]
|
||||
value = {"program": program, "version": self.args.version, "commit": self.args.commit}
|
||||
if program == "yatm-migrate":
|
||||
value["commit"] = "2" * 40
|
||||
return subprocess.CompletedProcess(argv, 0, json.dumps(value).encode(), b"")
|
||||
|
||||
with mock.patch("package_acceptance.subprocess.run", side_effect=invoke):
|
||||
with self.assertRaisesRegex(RuntimeError, "Offline identity differs: yatm-migrate"):
|
||||
controller.verify_programs("/owned/package")
|
||||
self.assertEqual(len(controller.report["commands"]), len(PROGRAMS))
|
||||
self.assertTrue(all(item["exit_code"] == 0 for item in controller.report["commands"]))
|
||||
|
||||
def test_each_invocation_allocates_a_new_owned_root_and_refuses_report_reuse(self):
|
||||
roots = []
|
||||
for index in range(2):
|
||||
args = SimpleNamespace(**{**vars(self.args), "out": self.root / f"evidence-{index}"})
|
||||
controller = Acceptance(args)
|
||||
root = f"/srv/acceptance/yatm-package-acceptance.{index:08d}"
|
||||
|
||||
def remote(argv, **kwargs):
|
||||
output = root.encode() if argv[0] == "mktemp" else b"owned-fixture" if argv[0] == "getfattr" else b""
|
||||
return subprocess.CompletedProcess(argv, 0, output, b"")
|
||||
|
||||
checks = [subprocess.CompletedProcess([], 0, output, b"") for output in
|
||||
(b"Linux x86_64", b"0", b"Filesystem 1024-blocks Used Available Capacity Mounted\n/dev/test 99999999 0 99999999 0% /srv\n")]
|
||||
with mock.patch.object(controller, "remote_batch", return_value=checks), mock.patch.object(controller, "remote", side_effect=remote) as calls:
|
||||
controller.prepare_host()
|
||||
self.assertEqual(calls.call_args_list[0].args[0][:2], ["mktemp", "-d"])
|
||||
roots.append(controller.root)
|
||||
with self.assertRaises(FileExistsError):
|
||||
Acceptance(args)
|
||||
self.assertNotEqual(roots[0], roots[1])
|
||||
|
||||
def test_legacy_review_and_abort_selection_never_runs_upgrade_or_v1_checks(self):
|
||||
for name in ("legacy-readonly-installer-review", "legacy-prepare-decline-and-abort"):
|
||||
with self.subTest(name=name):
|
||||
args = SimpleNamespace(**{**vars(self.args), "out": self.root / name, "case": [name],
|
||||
"legacy_package": self.root / "yatm-linux-amd64-v0.1.8.tar.gz",
|
||||
"legacy_fixture": self.root / "metadata.tar.gz"})
|
||||
for path, member, contents in ((args.legacy_package, "VERSION", b"v0.1.8\n"),
|
||||
(args.legacy_fixture, "tapes.db", b"synthetic legacy data")):
|
||||
with tarfile.open(path, "w:gz") as archive:
|
||||
entry = tarfile.TarInfo(member)
|
||||
entry.size = len(contents)
|
||||
archive.addfile(entry, io.BytesIO(contents))
|
||||
controller = Acceptance(args)
|
||||
controller.root = "/srv/acceptance/yatm-package-acceptance.12345678"
|
||||
controller.install = controller.root + "/install"
|
||||
controller.service = "yatm-acceptance-12345678.service"
|
||||
controller.url = "http://127.0.0.1:23456"
|
||||
|
||||
def remote(argv, **kwargs):
|
||||
output, code = b"", 0
|
||||
if argv[0] == "sha256sum":
|
||||
local = args.legacy_package if argv[1].endswith(args.legacy_package.name) else args.out / "legacy-metadata.tar.gz"
|
||||
output = sha256(local).encode()
|
||||
if argv[:2] == ["systemctl", "is-active"]:
|
||||
code = 3
|
||||
if "-phase" in argv and argv[argv.index("-phase") + 1] == "schema":
|
||||
output = b"legacy\n"
|
||||
return subprocess.CompletedProcess(argv, code, output, b"")
|
||||
|
||||
with mock.patch.object(controller, "upload"), mock.patch.object(controller, "write"), \
|
||||
mock.patch.object(controller, "remote", side_effect=remote), \
|
||||
mock.patch.object(controller, "installer", return_value=SimpleNamespace(stdout=b"Prepared migration aborted\n")) as installer, \
|
||||
mock.patch.object(controller, "verify_programs") as verify, \
|
||||
mock.patch.object(controller, "one") as one, redirect_stdout(io.StringIO()):
|
||||
run_legacy(controller)
|
||||
self.assertEqual(controller.report["case_scope"]["executed"], [name])
|
||||
installer.assert_called_once()
|
||||
verify.assert_not_called()
|
||||
one.assert_not_called()
|
||||
self.assertEqual(controller.report["cases"][0]["status"], "passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+9
-2
@@ -1,7 +1,14 @@
|
||||
# YATM frontend
|
||||
|
||||
Use Node.js 24 and the repository's pnpm lockfile. Run `pnpm check` in this
|
||||
folder for formatting, lint, tests, type checking and the production build.
|
||||
Use Node.js 24 and the repository's pnpm lockfile. During development/review, select
|
||||
affected Vitest files (for example `pnpm exec vitest run src/components/files-browser.test.ts`)
|
||||
and run `pnpm typecheck`; add the relevant style/lint, build or browser checks when those
|
||||
boundaries change. `pnpm check` runs all formatting, lint, tests, type checking and the
|
||||
production build for final integration/release. The
|
||||
[check-scope policy](../docs/operations/testing.md#check-scope-and-reuse) owns evidence reuse.
|
||||
|
||||
DOM-free tests declare `// @vitest-environment node`. Shared setup loads browser helpers only in
|
||||
jsdom. Component tests and benchmarks retain jsdom; tests default to two workers.
|
||||
|
||||
## Shared file browser
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FileContentSummary, OriginalAvailability as Original } from "@/entity";
|
||||
import { archiveIndicator, archiveSummary, contentTime } from "./content-status";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeTags, tagDelta, unicodeLength, validateNote, validateTags } from "./file-metadata";
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EntryKind, FileOperationKind, FilesEntry, FileScope, FilesInclude, FilesArchive, FilesCoverage, FilesIssue, OriginalAvailability } from "@/entity";
|
||||
import { filesEntryData, filesPage, listDirectory, libraryDirectoryReference, locationDirectoryReference } from "./files-browser";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { JobLogDirection, type JobLogLine, type ListJobLogLinesResponse } from "@/entity/job";
|
||||
|
||||
@@ -60,6 +60,8 @@ beforeEach(() => {
|
||||
listLogLines.mockReturnValue(result(page([], 0n, 0n)));
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("opens at the log tail and pages older lines using the returned cursor", async () => {
|
||||
listLogLines
|
||||
.mockReturnValueOnce(result(page([line(5000000n, "time=now level=error msg=last failure", "error")], 5000000n, 5000042n, true)))
|
||||
@@ -181,6 +183,7 @@ it("stops before the first filtered reply and offers a resume from the beginning
|
||||
});
|
||||
|
||||
it("keeps a stopped active search paused until Resume", async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveOld!: (reply: ListJobLogLinesResponse) => void;
|
||||
const resumed = line(100n, "level=error msg=resumed", "error");
|
||||
listLogLines
|
||||
@@ -189,24 +192,28 @@ it("keeps a stopped active search paused until Resume", async () => {
|
||||
.mockReturnValueOnce({ response: new Promise<ListJobLogLinesResponse>((resolve) => (resolveOld = resolve)) })
|
||||
.mockReturnValueOnce(result(page([resumed], 0n, 300n)));
|
||||
open(true);
|
||||
await waitFor(() => expect(listLogLines).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {});
|
||||
expect(listLogLines).toHaveBeenCalledTimes(1);
|
||||
filterErrors();
|
||||
await waitFor(() => expect(listLogLines).toHaveBeenCalledTimes(3));
|
||||
await act(async () => {});
|
||||
expect(listLogLines).toHaveBeenCalledTimes(3);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop search" }));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Search stopped.");
|
||||
expect(screen.queryByText("No matching log lines.")).not.toBeInTheDocument();
|
||||
await act(async () => void (await new Promise((resolve) => setTimeout(resolve, 2200))));
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2200));
|
||||
expect(listLogLines).toHaveBeenCalledTimes(3);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume search" }));
|
||||
expect(await screen.findByText(/resumed/)).toBeInTheDocument();
|
||||
await act(async () => {});
|
||||
expect(screen.getByText(/resumed/)).toBeInTheDocument();
|
||||
expect(listLogLines).toHaveBeenNthCalledWith(4, { id: 7n, direction: JobLogDirection.OLDER, cursor: 300n, level: "error", query: "" });
|
||||
await act(async () => resolveOld(page([line(200n, "level=info msg=obsolete")], 200n, 300n)));
|
||||
expect(screen.queryByText(/obsolete/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps following an active Job after a scrolled log is filtered to no matches", async () => {
|
||||
vi.useFakeTimers();
|
||||
const old = line(0n, "level=info msg=old");
|
||||
const first = line(old.endOffset, "level=error msg=first match", "error");
|
||||
const second = line(first.endOffset, "level=error msg=second match", "error");
|
||||
@@ -216,13 +223,16 @@ it("keeps following an active Job after a scrolled log is filtered to no matches
|
||||
.mockReturnValueOnce(result(page([first], old.endOffset, first.endOffset)))
|
||||
.mockReturnValueOnce(result(page([second], first.endOffset, second.endOffset)));
|
||||
open(true);
|
||||
expect(await screen.findByText(/msg=old/)).toBeInTheDocument();
|
||||
await act(async () => {});
|
||||
expect(screen.getByText(/msg=old/)).toBeInTheDocument();
|
||||
act(() => virtuoso.current?.atBottomStateChange?.(false));
|
||||
expect(virtuoso.current?.followOutput).toBe(false);
|
||||
|
||||
filterErrors();
|
||||
expect(await screen.findByText("No matching log lines.")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/first match/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("No matching log lines.")).toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2000));
|
||||
expect(screen.getByText(/first match/)).toBeInTheDocument();
|
||||
expect(virtuoso.current?.followOutput).toBe("auto");
|
||||
expect(listLogLines).toHaveBeenNthCalledWith(3, {
|
||||
id: 7n,
|
||||
@@ -232,7 +242,8 @@ it("keeps following an active Job after a scrolled log is filtered to no matches
|
||||
query: "",
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/second match/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2000));
|
||||
expect(screen.getByText(/second match/)).toBeInTheDocument();
|
||||
expect(listLogLines).toHaveBeenNthCalledWith(4, {
|
||||
id: 7n,
|
||||
direction: JobLogDirection.NEWER,
|
||||
@@ -240,9 +251,10 @@ it("keeps following an active Job after a scrolled log is filtered to no matches
|
||||
level: "error",
|
||||
query: "",
|
||||
});
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
it("polls an empty filtered page even when the old viewport reports atBottom false", async () => {
|
||||
vi.useFakeTimers();
|
||||
const old = line(0n, "level=info msg=old");
|
||||
const match = line(old.endOffset, "level=error msg=new match", "error");
|
||||
listLogLines
|
||||
@@ -250,14 +262,17 @@ it("polls an empty filtered page even when the old viewport reports atBottom fal
|
||||
.mockReturnValueOnce(result(page([], old.endOffset, old.endOffset)))
|
||||
.mockReturnValueOnce(result(page([match], old.endOffset, match.endOffset)));
|
||||
open(true);
|
||||
expect(await screen.findByText(/msg=old/)).toBeInTheDocument();
|
||||
await act(async () => {});
|
||||
expect(screen.getByText(/msg=old/)).toBeInTheDocument();
|
||||
const reportAtBottom = virtuoso.current?.atBottomStateChange;
|
||||
|
||||
filterErrors();
|
||||
expect(await screen.findByText("No matching log lines.")).toBeInTheDocument();
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("No matching log lines.")).toBeInTheDocument();
|
||||
act(() => reportAtBottom?.(false));
|
||||
|
||||
expect(await screen.findByText(/new match/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2000));
|
||||
expect(screen.getByText(/new match/)).toBeInTheDocument();
|
||||
expect(listLogLines).toHaveBeenNthCalledWith(3, {
|
||||
id: 7n,
|
||||
direction: JobLogDirection.NEWER,
|
||||
@@ -281,6 +296,7 @@ it("loads newer lines and replaces a growing partial row", async () => {
|
||||
});
|
||||
|
||||
it("joins a level-matched continuation onto the filtered partial row", async () => {
|
||||
vi.useFakeTimers();
|
||||
const partial = line(0n, "level=error msg=half", "error", false);
|
||||
const continuation = { ...line(partial.endOffset, " done", "error"), continuation: true };
|
||||
listLogLines
|
||||
@@ -288,17 +304,21 @@ it("joins a level-matched continuation onto the filtered partial row", async ()
|
||||
.mockReturnValueOnce(result(page([partial], 0n, partial.endOffset)))
|
||||
.mockReturnValueOnce(result(page([continuation], partial.endOffset, continuation.endOffset)));
|
||||
open(true);
|
||||
await waitFor(() => expect(listLogLines).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {});
|
||||
expect(listLogLines).toHaveBeenCalledTimes(1);
|
||||
filterErrors();
|
||||
expect(await screen.findByText(/half/)).toBeInTheDocument();
|
||||
await act(async () => {});
|
||||
expect(screen.getByText(/half/)).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/half done/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2000));
|
||||
expect(screen.getByText(/half done/)).toBeInTheDocument();
|
||||
expect(listLogLines).toHaveBeenNthCalledWith(3, { id: 7n, direction: JobLogDirection.NEWER, cursor: partial.endOffset, level: "error", query: "" });
|
||||
expect(virtuoso.current?.data).toHaveLength(1);
|
||||
expect(virtuoso.current?.data[0]).toMatchObject({ offset: 0n, endOffset: continuation.endOffset, complete: true });
|
||||
});
|
||||
|
||||
it("rechecks the raw partial tail before a text-filtered newer read", async () => {
|
||||
vi.useFakeTimers();
|
||||
const partial = line(0n, "level=info msg=need", "info", false);
|
||||
const complete = line(0n, "level=info msg=needle", "info");
|
||||
listLogLines
|
||||
@@ -307,11 +327,14 @@ it("rechecks the raw partial tail before a text-filtered newer read", async () =
|
||||
.mockReturnValueOnce(result(page([partial], 0n, partial.endOffset)))
|
||||
.mockReturnValueOnce(result(page([complete], 0n, complete.endOffset)));
|
||||
open(true);
|
||||
await waitFor(() => expect(listLogLines).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {});
|
||||
expect(listLogLines).toHaveBeenCalledTimes(1);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search log" }), { target: { value: "needle" } });
|
||||
expect(await screen.findByText("No matching log lines.")).toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300));
|
||||
expect(screen.getByText("No matching log lines.")).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/needle/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(2000));
|
||||
expect(screen.getByText(/needle/)).toBeInTheDocument();
|
||||
expect(listLogLines).toHaveBeenNthCalledWith(3, { id: 7n, direction: JobLogDirection.OLDER, cursor: partial.endOffset, level: "", query: "" });
|
||||
expect(listLogLines).toHaveBeenNthCalledWith(4, { id: 7n, direction: JobLogDirection.NEWER, cursor: 0n, level: "", query: "needle" });
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FileScope, FileSelection, FileVersion, RestoreVersionMatch, RestoreVersionResolution } from "@/entity";
|
||||
import { followRestorePolicy, restoreCutoff, restoreVersionLabel } from "./restore-version-policy";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { jobListPath } from "./routes";
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import "../mui-classnames";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserverStub;
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
}),
|
||||
});
|
||||
@@ -1,30 +1,5 @@
|
||||
import "../mui-classnames";
|
||||
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
if (typeof window !== "undefined") {
|
||||
await import("./setup-dom");
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserverStub;
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
}),
|
||||
});
|
||||
export {};
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dateFromNs, dateToNs, maxUnixNs, minUnixNs, parseUnixNs } from "./time";
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { errorMessage, formatFilesize } from "./tools";
|
||||
import { RpcError } from "@protobuf-ts/runtime-rpc";
|
||||
|
||||
@@ -13,11 +13,14 @@ export default defineConfig(({ mode }) => {
|
||||
react(),
|
||||
{
|
||||
name: "release-identity",
|
||||
transformIndexHtml() {
|
||||
return [
|
||||
{ tag: "meta", attrs: { name: "yatm-version", content: process.env.VITE_YATM_VERSION || "development" }, injectTo: "head" },
|
||||
{ tag: "meta", attrs: { name: "yatm-commit", content: process.env.VITE_YATM_COMMIT || "unknown" }, injectTo: "head" },
|
||||
];
|
||||
transformIndexHtml(html, context) {
|
||||
return {
|
||||
html: context.server ? html.replace("%%API_BASE%%", "/services") : html,
|
||||
tags: [
|
||||
{ tag: "meta", attrs: { name: "yatm-version", content: process.env.VITE_YATM_VERSION || "development" }, injectTo: "head" },
|
||||
{ tag: "meta", attrs: { name: "yatm-commit", content: process.env.VITE_YATM_COMMIT || "unknown" }, injectTo: "head" },
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Fixture creation and catalog seeding are measured separately from List/Search execution.
|
||||
func BenchmarkFilesFixtureSetup(b *testing.B) {
|
||||
for _, count := range []int{10000, 100000} {
|
||||
b.Run(strconv.Itoa(count), func(b *testing.B) {
|
||||
// Each iteration owns fresh mutable data; filesystem cleanup follows the timed work.
|
||||
for b.Loop() {
|
||||
setupFilesListMixedBenchmark(b, count, "random", 16, filesPerformanceRules())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package apis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFilesPerformanceFixtureNames(t *testing.T) {
|
||||
// Odd counts also verify that associations follow lexical names rather than creation order.
|
||||
for _, order := range []string{"random", "reverse"} {
|
||||
t.Run(order, func(t *testing.T) {
|
||||
// Inspect all physical files, including those excluded from the projected List.
|
||||
const count = 101
|
||||
ctx := context.Background()
|
||||
rules := filesPerformanceRules()
|
||||
service, directory := setupFilesListMixedBenchmark(t, count, order, 16, rules)
|
||||
location, err := service.api.lib.GetLocation(ctx, directory.GetLocation().LocationId)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rules, location.Config.Ignore.Text)
|
||||
require.Len(t, strings.Split(directory.GetLocation().Path, "/"), 16)
|
||||
physical := filepath.Join(location.RootPath, filepath.FromSlash(directory.GetLocation().Path))
|
||||
entries, err := os.ReadDir(physical)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, count)
|
||||
|
||||
// Content, mode and recorded facts must describe each final filename exactly.
|
||||
for index, entry := range entries {
|
||||
name := fmt.Sprintf("entry-%08d", index)
|
||||
require.Equal(t, name, entry.Name())
|
||||
data, err := os.ReadFile(filepath.Join(physical, name))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "content", string(data))
|
||||
info, err := entry.Info()
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0644, info.Mode().Perm())
|
||||
association, err := service.api.lib.GetFileLocationAtPath(ctx, location.ID, directory.GetLocation().Path+"/"+name)
|
||||
require.NoError(t, err)
|
||||
if index%2 != 0 {
|
||||
require.Nil(t, association)
|
||||
continue
|
||||
}
|
||||
require.NotNil(t, association)
|
||||
require.Equal(t, info.Size(), association.Size)
|
||||
require.Equal(t, uint32(info.Mode()), association.Mode)
|
||||
require.Equal(t, info.ModTime().UnixNano(), association.MtimeNS)
|
||||
file, err := service.api.lib.GetFile(ctx, association.FileID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, name, file.Name)
|
||||
}
|
||||
|
||||
// The 102 rules exclude ten names and restore the one ending in 17.
|
||||
stream := &filesStructuralRecorder{filesListRecorder: filesListRecorder{ctx: ctx}, maxBatch: 8}
|
||||
require.NoError(t, service.List(filesPerformanceRequest(directory, 8), stream))
|
||||
require.Equal(t, 92, stream.count)
|
||||
require.EqualValues(t, 92, stream.total)
|
||||
require.Equal(t, 51, stream.associated)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesLocationFixtureNames(t *testing.T) {
|
||||
// The existing unmixed helper keeps its original names and complete List contract.
|
||||
service, directory, physical := setupFilesLocationBenchmark(t, 3, 0, "")
|
||||
entries, err := os.ReadDir(physical)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 3)
|
||||
for index, entry := range entries {
|
||||
require.Equal(t, fmt.Sprintf("file-%05d.txt", index), entry.Name())
|
||||
}
|
||||
|
||||
// The shared setup does not add catalog associations for plain live fixtures.
|
||||
stream := &filesStructuralRecorder{filesListRecorder: filesListRecorder{ctx: context.Background()}, maxBatch: 2}
|
||||
require.NoError(t, service.List(filesPerformanceRequest(directory, 2), stream))
|
||||
require.Equal(t, 3, stream.count)
|
||||
require.EqualValues(t, 3, stream.total)
|
||||
require.Zero(t, stream.associated)
|
||||
}
|
||||
@@ -20,6 +20,14 @@ import (
|
||||
|
||||
func setupFilesLocationBenchmark(t testing.TB, entries, depth int, ignoreText string) (*filesService, *entity.FileOperationRef, string) {
|
||||
t.Helper()
|
||||
service, directory, physical, _ := setupFilesLocationBenchmarkNames(t, entries, depth, ignoreText, func(index int) string {
|
||||
return fmt.Sprintf("file-%05d.txt", index)
|
||||
})
|
||||
return service, directory, physical
|
||||
}
|
||||
|
||||
func setupFilesLocationBenchmarkNames(t testing.TB, entries, depth int, ignoreText string, name func(int) string) (*filesService, *entity.FileOperationRef, string, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
// Register one nested live directory holding the enumerated entries.
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
@@ -33,6 +41,7 @@ func setupFilesLocationBenchmark(t testing.TB, entries, depth int, ignoreText st
|
||||
lib := library.New(db)
|
||||
require.NoError(t, lib.AutoMigrate())
|
||||
|
||||
// Preserve the directory depth and configured Ignore rules independently of its names.
|
||||
parts := make([]string, 0, depth)
|
||||
for index := 0; index < depth; index++ {
|
||||
parts = append(parts, fmt.Sprintf("d%02d", index))
|
||||
@@ -45,40 +54,37 @@ func setupFilesLocationBenchmark(t testing.TB, entries, depth int, ignoreText st
|
||||
location.Config.Ignore = &entity.IgnoreRules{Format: "gitignore", Text: ignoreText}
|
||||
}
|
||||
require.NoError(t, lib.CreateLocation(context.Background(), location))
|
||||
|
||||
// Write each final name once in the supplied creation order.
|
||||
for index := 0; index < entries; index++ {
|
||||
name := fmt.Sprintf("file-%05d.txt", index)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(physical, name), []byte("content"), 0644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(physical, name(index)), []byte("content"), 0644))
|
||||
}
|
||||
|
||||
// Expose the same service and fixture connection for optional catalog seeding.
|
||||
exe := executor.New(db, lib, nil, executor.Paths{Access: []executor.AccessRange{{Root: root}}, Work: filepath.Join(root, "work")}, executor.Scripts{}, nil)
|
||||
directory := &entity.FileOperationRef{Target: &entity.FileOperationRef_Location{Location: &entity.LocationEntryRef{LocationId: location.ID, Path: relative}}}
|
||||
return &filesService{api: New(lib, exe)}, directory, physical
|
||||
return &filesService{api: New(lib, exe)}, directory, physical, db
|
||||
}
|
||||
|
||||
// setupFilesListMixedBenchmark keeps the timed List fixture identical for both source revisions.
|
||||
func setupFilesListMixedBenchmark(t testing.TB, count int, order string, depth int, ignoreText string) (*filesService, *entity.FileOperationRef) {
|
||||
t.Helper()
|
||||
|
||||
// Register the same live directory and fixed-seed name order for every implementation.
|
||||
service, directory, physical := setupFilesLocationBenchmark(t, count, depth, ignoreText)
|
||||
location, err := service.api.lib.GetLocation(context.Background(), directory.GetLocation().LocationId)
|
||||
require.NoError(t, err)
|
||||
names := rand.New(rand.NewSource(19)).Perm(count)
|
||||
var names []int
|
||||
if order == "reverse" {
|
||||
names = make([]int, count)
|
||||
for i := range names {
|
||||
names[i] = count - i - 1
|
||||
}
|
||||
} else {
|
||||
names = rand.New(rand.NewSource(19)).Perm(count)
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
require.NoError(t, os.Rename(filepath.Join(physical, fmt.Sprintf("file-%05d.txt", i)), filepath.Join(physical, fmt.Sprintf("entry-%08d", names[i]))))
|
||||
}
|
||||
service, directory, physical, db := setupFilesLocationBenchmarkNames(t, count, depth, ignoreText, func(index int) string {
|
||||
return fmt.Sprintf("entry-%08d", names[index])
|
||||
})
|
||||
|
||||
// Seed alternate lexical names in one fixture transaction; setup is never part of List timing.
|
||||
db, err := resource.OpenSQLite(filepath.Join(filepath.Dir(location.RootPath), "library.db"))
|
||||
require.NoError(t, err)
|
||||
db.Logger = logger.Discard
|
||||
connection, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
require.NoError(t, db.Transaction(func(tx *gorm.DB) error {
|
||||
lib := library.New(tx)
|
||||
for i := 0; i < count; i += 2 {
|
||||
@@ -91,7 +97,7 @@ func setupFilesListMixedBenchmark(t testing.TB, count int, order string, depth i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&library.FileLocation{FileID: file.ID, LocationID: location.ID, Path: directory.GetLocation().Path + "/" + name,
|
||||
if err := tx.Create(&library.FileLocation{FileID: file.ID, LocationID: directory.GetLocation().LocationId, Path: directory.GetLocation().Path + "/" + name,
|
||||
Size: info.Size(), Mode: uint32(info.Mode()), MtimeNS: info.ModTime().UnixNano()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -83,6 +83,29 @@ guide](../../docs/operations/install.md#preview-preferences), or verify its chec
|
||||
in an explicitly selected installation. Configure the native helper executable path in Preview
|
||||
Settings when it is outside YATM's executable directory.
|
||||
|
||||
## Native Compiler Cache
|
||||
|
||||
Set `CCACHE_DIR` to opt into [ccache](https://ccache.dev/manual/latest.html) when it is installed.
|
||||
Without it, the build uses the ordinary compilers. CI restores the native object cache by target,
|
||||
compiler and SDK identity, and native build recipe; a YATM source commit alone does not discard it.
|
||||
Each build still creates the Go helper, verifies runtime behavior and collects licenses and
|
||||
corresponding sources from its current committed inputs.
|
||||
|
||||
The cache normalizes the temporary build root with `CCACHE_BASEDIR`, retains the public compiler
|
||||
path maps and checks compiler content. It does not relax header or source checks. Native configure
|
||||
commands use the cache wrapper; Go and runtime-license discovery retain the underlying compiler.
|
||||
CI reports cache statistics. Compare one cold and one warm build in the same environment when
|
||||
changing this integration; verify hits, elapsed time, relocation and path sanitization. From a
|
||||
repository checkout:
|
||||
|
||||
```shell
|
||||
RELEASE_VERSION=<tag> node previewworker/build/check-cache.mjs /new/private/cache-check-output
|
||||
```
|
||||
|
||||
This optional check runs the normal build twice with its own fresh cache, retaining logs and a
|
||||
`report.json`. `PREVIEW_SOURCE_DIRECTORY` can supply verified native archives to exclude downloads
|
||||
from the comparison. The check does not clear a user's existing cache or add another release gate.
|
||||
|
||||
## Rebuild From Distributed Sources
|
||||
|
||||
The corresponding-source archive includes the exact native archives, checksums, FFmpeg
|
||||
|
||||
@@ -7,6 +7,9 @@ RELEASE_DIRECTORY="${RELEASE_DIRECTORY:-$REPOSITORY/output/releases}"
|
||||
if [[ -n "${PREVIEW_SOURCE_DIRECTORY:-}" && "$PREVIEW_SOURCE_DIRECTORY" != /* ]]; then
|
||||
PREVIEW_SOURCE_DIRECTORY="$PWD/$PREVIEW_SOURCE_DIRECTORY"
|
||||
fi
|
||||
if [[ -n "${CCACHE_DIR:-}" && "$CCACHE_DIR" != /* ]]; then
|
||||
export CCACHE_DIR="$PWD/$CCACHE_DIR"
|
||||
fi
|
||||
cd "$REPOSITORY"
|
||||
: "${RELEASE_VERSION:?Set RELEASE_VERSION to the candidate tag}"
|
||||
[[ "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]] || { echo 'Invalid release version' >&2; exit 1; }
|
||||
@@ -69,6 +72,7 @@ if [[ "$TARGET" != "$HOST_TARGET" ]]; then
|
||||
*) echo "Unsupported Preview cross target: $HOST_TARGET to $TARGET" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
source "$REPOSITORY/previewworker/build/compiler-cache.sh"
|
||||
command -v pkg-config >/dev/null
|
||||
if [[ "$RUNNABLE" == true ]]; then
|
||||
node build/release/check-preview-fixtures.mjs
|
||||
@@ -99,7 +103,7 @@ node "$REPOSITORY/previewworker/build/native-notices.mjs" "$BUILD_DIRECTORY" "$P
|
||||
|
||||
# Disable optional system dependency discovery; every non-system library is private.
|
||||
cd "$BUILD_DIRECTORY/libwebp-1.6.0"
|
||||
./configure ${WEBP_TARGET[@]+"${WEBP_TARGET[@]}"} --prefix="$PREFIX" --enable-shared --disable-static \
|
||||
CC="$NATIVE_CC" CXX="$NATIVE_CXX" ./configure ${WEBP_TARGET[@]+"${WEBP_TARGET[@]}"} --prefix="$PREFIX" --enable-shared --disable-static \
|
||||
--disable-gl --disable-sdl --disable-png --disable-jpeg --disable-tiff --disable-gif \
|
||||
--disable-libwebpdemux --disable-libwebpmux
|
||||
make -j "$JOBS"
|
||||
@@ -110,15 +114,15 @@ export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig"
|
||||
export PKG_CONFIG_LIBDIR="$PREFIX/lib/pkgconfig"
|
||||
cd "$BUILD_DIRECTORY/zlib-1.3.1"
|
||||
if [[ "$GOOS" == linux && "$TARGET" != "$HOST_TARGET" ]]; then
|
||||
LDSHARED="$CC -shared -Wl,-soname,libz.so.1,--version-script,zlib.map,--undefined-version" ./configure --prefix="$PREFIX" --shared
|
||||
CC="$NATIVE_CC" CXX="$NATIVE_CXX" LDSHARED="$NATIVE_CC -shared -Wl,-soname,libz.so.1,--version-script,zlib.map,--undefined-version" ./configure --prefix="$PREFIX" --shared
|
||||
else
|
||||
./configure --prefix="$PREFIX" --shared
|
||||
CC="$NATIVE_CC" CXX="$NATIVE_CXX" ./configure --prefix="$PREFIX" --shared
|
||||
fi
|
||||
make -j "$JOBS"
|
||||
make install
|
||||
cp LICENSE "$PACKAGE/licenses/zlib-LICENSE"
|
||||
cd "$BUILD_DIRECTORY/jpeg-9f"
|
||||
./configure ${WEBP_TARGET[@]+"${WEBP_TARGET[@]}"} --prefix="$PREFIX" --enable-shared --disable-static
|
||||
CC="$NATIVE_CC" CXX="$NATIVE_CXX" ./configure ${WEBP_TARGET[@]+"${WEBP_TARGET[@]}"} --prefix="$PREFIX" --enable-shared --disable-static
|
||||
make -j "$JOBS"
|
||||
make install
|
||||
cp README "$PACKAGE/licenses/libjpeg-README"
|
||||
@@ -133,7 +137,7 @@ if [[ "$GOOS" == linux ]]; then
|
||||
RAW_LIBS='-lc++ -lc++abi -lc'
|
||||
fi
|
||||
fi
|
||||
CPPFLAGS="-I$PREFIX/include" LDFLAGS="$RAW_LDFLAGS" LIBS="$RAW_LIBS" ./configure ${WEBP_TARGET[@]+"${WEBP_TARGET[@]}"} \
|
||||
CPPFLAGS="-I$PREFIX/include" LDFLAGS="$RAW_LDFLAGS" LIBS="$RAW_LIBS" CC="$NATIVE_CC" CXX="$NATIVE_CXX" ./configure ${WEBP_TARGET[@]+"${WEBP_TARGET[@]}"} \
|
||||
--prefix="$PREFIX" --enable-shared --disable-static --disable-examples --disable-openmp \
|
||||
--disable-lcms --enable-jpeg --enable-zlib
|
||||
make -j "$JOBS"
|
||||
@@ -142,7 +146,7 @@ cp COPYRIGHT LICENSE.LGPL LICENSE.CDDL "$PACKAGE/licenses/"
|
||||
cd "$BUILD_DIRECTORY/ffmpeg-8.0"
|
||||
# Some cross sysroots expose the legacy symbol without its removed header.
|
||||
patch -p1 < "$REPOSITORY/previewworker/build/ffmpeg-sysctl-header.patch"
|
||||
./configure ${FFMPEG_TARGET[@]+"${FFMPEG_TARGET[@]}"} --prefix="$PREFIX" --cc="$CC" --cxx="$CXX" \
|
||||
./configure ${FFMPEG_TARGET[@]+"${FFMPEG_TARGET[@]}"} --prefix="$PREFIX" --cc="$NATIVE_CC" --cxx="$NATIVE_CXX" \
|
||||
--extra-cflags="$CFLAGS" --extra-cxxflags="$CXXFLAGS" \
|
||||
--disable-autodetect --disable-programs --disable-doc --disable-debug \
|
||||
--disable-x86asm --disable-static --enable-shared --enable-pic --enable-libwebp --enable-zlib \
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Run one cold and one warm native build with an isolated compiler cache.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { releaseCommit } from '../../build/release/committed-inputs.mjs';
|
||||
|
||||
const repository = path.resolve(import.meta.dirname, '../..');
|
||||
const output = path.resolve(process.argv[2] || 'output/preview-cache-check');
|
||||
assert(!fs.existsSync(output), 'Select a new cache-check output directory');
|
||||
assert(process.env.RELEASE_VERSION, 'Set RELEASE_VERSION before comparing native builds');
|
||||
const commit = releaseCommit(repository, process.env.RELEASE_COMMIT);
|
||||
const env = { ...process.env, RELEASE_COMMIT: commit, CCACHE_DIR: path.join(output, 'cache') };
|
||||
const probe = spawnSync('ccache', ['--version'], { encoding: 'utf8', env });
|
||||
assert.equal(probe.status, 0, 'Install ccache before checking cache performance');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const report = { commit, version: env.RELEASE_VERSION, ccache: probe.stdout.split('\n')[0], builds: [] };
|
||||
const stats = () => {
|
||||
const result = spawnSync('ccache', ['--print-stats'], { encoding: 'utf8', env });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return Object.fromEntries(result.stdout.trim().split('\n').map(line => {
|
||||
const [name, count] = line.split(/\s+/);
|
||||
return [name, Number(count)];
|
||||
}));
|
||||
};
|
||||
for (const phase of ['cold', 'warm']) {
|
||||
const log = fs.openSync(path.join(output, `${phase}.log`), 'w');
|
||||
const before = stats();
|
||||
const start = performance.now();
|
||||
let result;
|
||||
try {
|
||||
result = spawnSync('bash', ['previewworker/build/build.sh'], { cwd: repository,
|
||||
env: { ...env, RELEASE_DIRECTORY: path.join(output, phase) }, stdio: ['ignore', log, log] });
|
||||
} finally { fs.closeSync(log); }
|
||||
const after = stats();
|
||||
const item = { phase, seconds: (performance.now() - start) / 1000, exit: result.status,
|
||||
counters: Object.fromEntries(Object.keys(after).map(name => [name, after[name] - (before[name] || 0)])) };
|
||||
report.builds.push(item);
|
||||
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify(report, null, 2) + '\n');
|
||||
assert.equal(result.status, 0, `${phase} build failed; inspect ${phase}.log`);
|
||||
console.log(`${phase}: ${item.seconds.toFixed(1)}s, cache hits ${(item.counters.direct_cache_hit || 0) + (item.counters.preprocessed_cache_hit || 0)}`);
|
||||
}
|
||||
const warm = report.builds[1].counters;
|
||||
assert((warm.direct_cache_hit || 0) + (warm.preprocessed_cache_hit || 0) > 0, 'Warm build had no compiler cache hits');
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Native compiler caching is optional; Go always uses the underlying compiler.
|
||||
|
||||
native_cache_key() {
|
||||
{
|
||||
printf '%s\n' "$TARGET" "$CC" "$CXX"
|
||||
uname -smr
|
||||
$CC --version
|
||||
$CXX --version
|
||||
if [[ "$TARGET" == darwin-* ]]; then
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
xcrun --sdk macosx --show-sdk-build-version
|
||||
fi
|
||||
for recipe in previewworker/build/build.sh previewworker/build/compiler-cache.sh previewworker/build/ffmpeg-sysctl-header.patch build/release/sanitize-native.mjs; do
|
||||
shasum -a 256 "$REPOSITORY/$recipe" | cut -d ' ' -f 1
|
||||
done
|
||||
} | shasum -a 256 | cut -d ' ' -f 1
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
set -euo pipefail
|
||||
[[ "${1:-}" == key ]] || { echo 'Usage: compiler-cache.sh key' >&2; exit 1; }
|
||||
REPOSITORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TARGET="${PREVIEW_TARGET:-$(go env GOHOSTOS)-$(go env GOHOSTARCH)}"
|
||||
CC="${PREVIEW_CC:-cc}" CXX="${PREVIEW_CXX:-c++}"
|
||||
native_cache_key
|
||||
else
|
||||
NATIVE_CC="$CC" NATIVE_CXX="$CXX"
|
||||
if [[ -n "${CCACHE_DIR:-}" ]] && command -v ccache >/dev/null; then
|
||||
export CCACHE_BASEDIR="$BUILD_DIRECTORY" CCACHE_COMPILERCHECK=content
|
||||
export CCACHE_NAMESPACE="yatm-preview-$(native_cache_key)"
|
||||
NATIVE_CC="ccache $CC" NATIVE_CXX="ccache $CXX"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const repository = path.resolve(import.meta.dirname, '../..');
|
||||
const script = path.join(import.meta.dirname, 'compiler-cache.sh');
|
||||
const recipes = ['previewworker/build/build.sh', 'previewworker/build/compiler-cache.sh', 'previewworker/build/ffmpeg-sysctl-header.patch', 'build/release/sanitize-native.mjs'];
|
||||
const run = (args, env = {}, cwd) => {
|
||||
const result = spawnSync('bash', args, { cwd, env: { ...process.env, ...env }, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return result.stdout.trim();
|
||||
};
|
||||
function temporary(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'yatm-compiler-cache-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
test('native cache identities follow recipe, target, compiler and SDK, not checkout paths', t => {
|
||||
const root = temporary(t);
|
||||
const tool = path.join(root, 'compiler');
|
||||
fs.writeFileSync(tool, '#!/bin/sh\nprintf "%s\\n" "$COMPILER_VERSION"\n', { mode: 0o755 });
|
||||
fs.writeFileSync(path.join(root, 'xcrun'), '#!/bin/sh\nprintf "%s\\n" "$SDK_VERSION"\n', { mode: 0o755 });
|
||||
function copy(name) {
|
||||
const dir = path.join(root, name);
|
||||
for (const recipe of recipes) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, recipe)), { recursive: true });
|
||||
fs.copyFileSync(path.join(repository, recipe), path.join(dir, recipe));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
const first = copy('one');
|
||||
const second = copy('two');
|
||||
const env = { PREVIEW_TARGET: 'linux-amd64', PREVIEW_CC: tool, PREVIEW_CXX: tool, COMPILER_VERSION: 'compiler-1', SDK_VERSION: '15.0', PATH: `${root}:${process.env.PATH}` };
|
||||
const key = (dir, extra = {}) => run([path.join(dir, 'previewworker/build/compiler-cache.sh'), 'key'], { ...env, ...extra });
|
||||
const original = key(first);
|
||||
assert.match(original, /^[a-f0-9]{64}$/);
|
||||
assert.equal(key(second), original);
|
||||
assert.notEqual(key(first, { PREVIEW_TARGET: 'linux-arm64' }), original);
|
||||
assert.notEqual(key(first, { COMPILER_VERSION: 'compiler-2' }), original);
|
||||
const mac = key(first, { PREVIEW_TARGET: 'darwin-arm64' });
|
||||
assert.notEqual(key(first, { PREVIEW_TARGET: 'darwin-arm64', SDK_VERSION: '16.0' }), mac);
|
||||
fs.appendFileSync(path.join(second, recipes[0]), '\n# changed native recipe\n');
|
||||
assert.notEqual(key(second), original);
|
||||
});
|
||||
|
||||
test('cache opt-in preserves the underlying Go compiler and uncached fallback', t => {
|
||||
const root = temporary(t);
|
||||
const shell = `set -euo pipefail; REPOSITORY="$CACHE_REPOSITORY"; TARGET=linux-amd64; CC=cc; CXX=c++; BUILD_DIRECTORY="$CACHE_BUILD"; source "$CACHE_SCRIPT"; printf '%s\\n' "$CC" "$CXX" "$NATIVE_CC" "$NATIVE_CXX"`;
|
||||
const env = { CACHE_REPOSITORY: repository, CACHE_BUILD: root, CACHE_SCRIPT: script, CCACHE_DIR: '' };
|
||||
assert.equal(run(['-c', shell], env), 'cc\nc++\ncc\nc++');
|
||||
fs.writeFileSync(path.join(root, 'ccache'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
assert.equal(run(['-c', shell], { ...env, CCACHE_DIR: path.join(root, 'cache'), PATH: `${root}:${process.env.PATH}` }), 'cc\nc++\nccache cc\nccache c++');
|
||||
});
|
||||
|
||||
const hasCompilerCache = spawnSync('ccache', ['--version']).status === 0 && spawnSync('cc', ['--version']).status === 0;
|
||||
test('real ccache reuses relocated objects and still detects changed source', { skip: !hasCompilerCache }, t => {
|
||||
const root = temporary(t);
|
||||
const cache = path.join(root, 'cache');
|
||||
const objects = [];
|
||||
for (const [name, value] of [['cold', 1], ['warm', 1], ['changed', 2]]) {
|
||||
const build = path.join(root, name);
|
||||
fs.mkdirSync(build);
|
||||
fs.writeFileSync(path.join(build, 'sample.c'), `const char *source_path = __FILE__; int sample(void) { return ${value}; }\n`);
|
||||
const object = path.join(build, 'sample.o');
|
||||
run(['-c', 'set -euo pipefail; REPOSITORY="$CACHE_REPOSITORY"; TARGET=linux-amd64; CC=cc; CXX=c++; BUILD_DIRECTORY="$CACHE_BUILD"; source "$CACHE_SCRIPT"; $NATIVE_CC -O2 -g0 "-ffile-prefix-map=$BUILD_DIRECTORY=/build/yatm-preview" "-fdebug-prefix-map=$BUILD_DIRECTORY=/build/yatm-preview" -c sample.c -o sample.o'],
|
||||
{ CCACHE_DIR: cache, CACHE_REPOSITORY: repository, CACHE_BUILD: build, CACHE_SCRIPT: script }, build);
|
||||
objects.push(fs.readFileSync(object));
|
||||
}
|
||||
assert.deepEqual(objects[0], objects[1]);
|
||||
assert.notDeepEqual(objects[1], objects[2]);
|
||||
assert(!objects[1].includes(Buffer.from(root)), 'Cached object leaked a build path');
|
||||
const stats = spawnSync('ccache', ['--print-stats'], { env: { ...process.env, CCACHE_DIR: cache }, encoding: 'utf8' });
|
||||
assert.equal(stats.status, 0, stats.stderr);
|
||||
const counters = new Map(stats.stdout.trim().split('\n').map(line => line.split(/\s+/)));
|
||||
assert(Number(counters.get('direct_cache_hit')) + Number(counters.get('preprocessed_cache_hit')) >= 1, stats.stdout);
|
||||
});
|
||||
Reference in new issue
Block a user