Speed up development and release candidate verification

This commit is contained in:
Samuel Cui committed 2026-10-05 23:52:58 +08:00
1 parent 18bdddbd34
commit fa821facf4
45 files changed
+2091 -238

No files matched your search

+31 -5
View File
@@ -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
+1 -1
View File
@@ -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.
+9 -4
View File
@@ -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
+20
View File
@@ -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"
+30 -18
View File
@@ -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.`);
+51
View File
@@ -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);
});
+9 -2
View File
@@ -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"
+1 -1
View File
@@ -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}`),
];
+9 -28
View File
@@ -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}).`);
+11 -2
View File
@@ -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],
+59
View File
@@ -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]');
}
+78
View File
@@ -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/);
});
+40
View File
@@ -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
View File
@@ -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"
+437
View File
@@ -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
View File
@@ -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();
+125 -1
View File
@@ -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);
});
+12 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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.
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+9 -4
View File
@@ -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()
+314
View File
@@ -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
View File
@@ -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";
+42 -19
View File
@@ -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";
+2
View File
@@ -1,3 +1,5 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { jobListPath } from "./routes";
+30
View File
@@ -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: () => {},
}),
});
+3 -28
View File
@@ -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 {};
+2
View File
@@ -1,3 +1,5 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { dateFromNs, dateToNs, maxUnixNs, minUnixNs, parseUnixNs } from "./time";
+2
View File
@@ -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";
+8 -5
View File
@@ -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)
}
+23 -17
View File
@@ -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
}
+23
View File
@@ -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
+10 -6
View File
@@ -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 \
+45
View File
@@ -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');
+34
View File
@@ -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);
});