diff --git a/scripts/test_disk_combined_ui.mjs b/scripts/test_disk_combined_ui.mjs new file mode 100644 index 0000000..ecbd740 --- /dev/null +++ b/scripts/test_disk_combined_ui.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(process.env.MODAVD_COMBINED_SOURCE || fileURLToPath(new URL('..', import.meta.url))); +const read = (name) => readFile(resolve(root, 'internal/ui/web/js', name), 'utf8'); +const format = await read('format.js'); +const fmt = await import(`data:text/javascript;base64,${Buffer.from(format).toString('base64')}`); +const moduleBody = (s) => s.replace(/^import .*;\r?\n/gm, '').replace(/^export /gm, ''); + +test('trim, fallback info, growth and skipped notifications coexist', async () => { + const notices = []; + const S = { jobs: new Map(), watch: new Set(), logs: [] }; + const scope = { ...fmt, S, instance: () => null, update() {}, toast: (r) => notices.push(r) }; + vm.runInNewContext(moduleBody(await read('actions.js')) + '\nglobalThis.accept = onJob;', scope); + const send = (id, kind, result) => { + S.watch.add(id); + scope.accept({ id, index: 2, kind, state: 'done', result }); + return notices.at(-1); + }; + const trim = send('trim', 'disk-trim', { discard_max_bytes: 2 ** 30 }); + assert.match(trim.title, /已触发/); + assert.doesNotMatch(trim.title, /已回收|已完成/); + assert.match(trim.text, /不等待安卓回收完成/); + const info = send('info', 'disk-info', { logical_bytes: 64 * 2 ** 20, warnings: ['正在整理磁盘'] }); + assert.match(info.text, /正在整理磁盘/); + assert.doesNotMatch(info.text, /宿主占用|开机时/); + const growth = send('growth', 'disk-compact', { before_on_disk_bytes: 32 * 2 ** 20, after_on_disk_bytes: 33 * 2 ** 20, on_disk_delta_bytes: 2 ** 20, freed_bytes: 0 }); + assert.match(growth.text, /增加 1 MB/); + assert.doesNotMatch(growth.text, /省下|释放/); + const skip = send('skip', 'disk-compact', { before_on_disk_bytes: 4096, skipped: true, reclaim_threshold_bytes: 16 * 2 ** 20 }); + assert.match(skip.title, /已跳过磁盘重写/); + assert.match(skip.text, /已跳过重写/); + assert.equal(notices.length, 4); + // An old progress snapshot must not replace a completed trim request. + scope.accept({ id: 'trim', kind: 'disk-trim', state: 'running' }); + assert.equal(S.jobs.get('trim').state, 'done'); + assert.equal(notices.length, 4); +}); + +test('activity keeps trim request timing and compact cancellation distinct', async () => { + const element = () => ({ hidden: false, style: {}, classList: { toggle() {} }, replaceChildren() {} }); + const row = () => Object.fromEntries(['title', 'meta', 'pill', 'prog', 'acts', 'cancel', 'cancelHint', 'why', 'err', 'warn', 'res', 'det', 'sum', 'msg', 'pctEl', 'bar', 'fill'].map((k) => [k, element()])); + const scope = { ...fmt, S: { logs: [] }, instance: () => null, setText: (el, text) => { el.text = text; }, attr() {}, h: element, icon: element }; + vm.runInNewContext(moduleBody(await read('panels.js')) + '\nglobalThis.render = updateJob;', scope); + const r = row(); + const common = { id: 'one', index: 2, created_at: '2026-11-05T00:00:00Z', finished_at: '2026-11-05T00:00:02Z' }; + scope.render(r, { ...common, kind: 'disk-trim', state: 'running', finished_at: undefined }); + assert.equal(r.pill.text, '进行中'); + assert.match(r.meta.text, /已进行/); + assert.doesNotMatch(r.meta.text, /触发请求用时/); + scope.render(r, { ...common, kind: 'disk-trim', state: 'done', result: {} }); + assert.equal(r.pill.text, '已触发'); + assert.match(r.meta.text, /触发请求用时/); + assert.match(r.res.text, /不等待安卓回收完成/); + scope.render(r, { ...common, kind: 'disk-compact', state: 'running', progress: { done: 1, total: 10 } }); + assert.equal(r.acts.hidden, false); + assert.equal(r.cancel.text, '取消整理'); + assert.doesNotMatch(r.meta.text, /触发请求/); + scope.render(r, { ...common, kind: 'disk-compact', state: 'done', result: { before_on_disk_bytes: 2 ** 54, after_on_disk_bytes: 2 ** 54, on_disk_delta_bytes: 1, freed_bytes: 0 } }); + assert.match(r.res.text, /增加 1 B/); + assert.equal(r.acts.hidden, true); + assert.equal(r.pill.text, '完成'); +}); diff --git a/scripts/testdata/disk_combined/README.md b/scripts/testdata/disk_combined/README.md new file mode 100644 index 0000000..c76d3af --- /dev/null +++ b/scripts/testdata/disk_combined/README.md @@ -0,0 +1,61 @@ +# Disk combination regression + +Run these fixtures against a local integration tree containing Manager PRs +#56, #59, #60, #63, #64 and #65. They test the contracts shared by those PRs. +The existing PRs remain the delivery points for their implementations. + +```powershell +python -B -I -X utf8 scripts/verify_disk_combined.py ` + --source C:\work\manager-integration ` + --sdk C:\test-sdk ` + --out C:\test-results\disk-combined-1 ` + --go C:\go1.26.5\bin\go.exe ` + --mutations +``` + +Use a new output directory for every run. The runner copies qemu-img and its DLLs +from the existing SDK and creates its own test roots. It never downloads files, +fetches repositories, starts adb or an emulator, or writes to the selected source. +The selected source must contain all six PRs. The runner fails when a required +test is absent or skipped. + +The Go files live in `testdata` because main does not yet contain their +dependencies. Go's file overlay adds them to the selected emu test package for +this run. They reuse the existing PR fixtures and assertions. Normal main builds +and tests do not compile these files. + +The runner checks: + +- Real qcow2 backing data and overlay data through convert, check, compare and + replacement, including #56's original BackingData test. +- Full DiskCompact calls stopped at convert or compare, with separate Windows + parent and child processes. Start, delete, bridge and another compact must + report disk busy within one second. Info must return safe sizes and warnings + without launching qemu-img. +- Parent termination, cancellation and five-second injected budgets. It checks + child handles, exclusive file access, original bytes and identity, all locks, + scratch cleanup, actual successful retry, complete guest content and repeat skip. +- Low-benefit real images with hardlinks or symlinks. The link error must precede + both lock contention and skip, preserving aliases and existing scratch files. +- Compressed real images for which the policy must skip expansion, and the + existing controlled-process growth cases and CLI, HTTP/GUI, JSON and MCP result + contracts. +- Interleaved trim, degraded info, growth and skipped GUI notifications, including + activity timing, cancellation controls and stale job snapshots. + +Normal image operations use the supplied qemu-img. Interruption tests use a real +Windows helper process named qemu-img.exe. It forwards normal stages to the +supplied binary and holds real file handles at the selected interruption stage. +This makes the lifecycle checks deterministic without requiring a very slow disk. +The original disk remains a real qcow2 and is checked with the supplied qemu-img +before retry, then exported and compared byte for byte after retry. + +`--mutations` removes the link check, disk lock and kill-on-close flag in separate +Go overlays. Each must fail a specific interaction assertion. A compiler error or +an unrelated cleanup failure does not count as detection. Source files remain +unchanged. Logs, overlays, hashes and test outcomes stay in the output directory. + +This does not verify Android discard completion, a physical slow USB disk, +power loss, native Linux/macOS behavior, or a desktop GUI window. Increased +allocation results use the existing controlled-process cases, not a claim that +the real compressed-image fixture grows. That fixture correctly skips. diff --git a/scripts/testdata/disk_combined/compact_compressed_windows_test.go b/scripts/testdata/disk_combined/compact_compressed_windows_test.go new file mode 100644 index 0000000..2847e7a --- /dev/null +++ b/scripts/testdata/disk_combined/compact_compressed_windows_test.go @@ -0,0 +1,67 @@ +//go:build windows + +package emu + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestCompactRealCompressedSkip(t *testing.T) { + sdk := os.Getenv("MODAVD_TEST_SDK_ROOT") + if sdk == "" { + t.Skip("requires existing qemu-img SDK") + } + qemuImg := filepath.Join(sdk, "emulator", "qemu-img.exe") + m := New(managerTestRoot(t, 2), nil) + // Root metadata, rather than a shared installation, selects the SDK. + meta, _ := json.Marshal(map[string]any{"sdkRoot": sdk, "adbServerPort": 28391, "scriptAdbServerPort": 28392}) + if err := os.WriteFile(m.Root.MetadataPath(), meta, 0600); err != nil { + t.Fatal(err) + } + in, err := m.instance(2) + if err != nil { + t.Fatal(err) + } + raw := filepath.Join(m.Root.Dir, "compressed-source.raw") + data := bytes.Repeat([]byte{0x63}, 64<<20) + if err := os.WriteFile(raw, data, 0600); err != nil { + t.Fatal(err) + } + backing := filepath.Join(in.Dir, userdataBacking) + overlay := filepath.Join(in.Dir, userdataOverlay) + qemu := func(args ...string) { + t.Helper() + cmd := exec.Command(qemuImg, args...) + cmd.Dir = in.Dir + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, b) + } + } + qemu("create", "-f", "qcow2", backing, "64M") + qemu("convert", "-c", "-O", "qcow2", "-B", userdataBacking, "-o", "backing_fmt=qcow2", raw, overlay) + addCompactGarbage(t, overlay) + before, err := os.ReadFile(overlay) + if err != nil { + t.Fatal(err) + } + id := compactFileID(t, overlay) + res, err := m.DiskCompact(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if !res.Skipped || res.OnDiskDeltaBytes != 0 || res.FreedBytes != 0 || res.BeforeOnDiskBytes != res.AfterOnDiskBytes { + t.Fatalf("compressed data must not be expanded for no estimated benefit: %+v", res) + } + if after, err := os.ReadFile(overlay); err != nil || !bytes.Equal(before, after) || id != compactFileID(t, overlay) { + t.Fatalf("skip changed compressed image: %v", err) + } + qemu("compare", raw, overlay) + qemu("check", overlay) + t.Logf("real compressed skip: %+v", res) +} diff --git a/scripts/testdata/disk_combined/disk_combined_windows_test.go b/scripts/testdata/disk_combined/disk_combined_windows_test.go new file mode 100644 index 0000000..c96ae29 --- /dev/null +++ b/scripts/testdata/disk_combined/disk_combined_windows_test.go @@ -0,0 +1,358 @@ +//go:build windows + +package emu + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "modavd/internal/errs" + "modavd/internal/root" + "modavd/internal/win" +) + +const combinedEnv = "MODAVD_TEST_DISK_COMBINED" + +type combinedRequest struct{ Real, Overlay, Stage string } + +// Forward to the existing real qemu-img, except at a controller-owned gate. +// The gate holds the original and scratch files like an active image operation. +func init() { + if path := os.Getenv(combinedEnv); path != "" && strings.EqualFold(filepath.Base(os.Args[0]), "qemu-img.exe") { + os.Exit(combinedChild(path)) + } +} + +func combinedChild(path string) int { + b, err := os.ReadFile(path) + var req combinedRequest + if err != nil || json.Unmarshal(b, &req) != nil || len(os.Args) < 2 { + return 90 + } + stage := os.Args[1] + f, err := os.OpenFile(path+".calls", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return 91 + } + fmt.Fprintln(f, stage) + f.Close() + if stage == req.Stage { + p, _ := syscall.UTF16PtrFromString(req.Overlay) + h, err := syscall.CreateFile(p, syscall.GENERIC_READ, syscall.FILE_SHARE_READ, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + return 92 + } + defer syscall.CloseHandle(h) + if stage == "convert" { + if err := os.WriteFile(os.Args[len(os.Args)-1], []byte("incomplete conversion"), 0600); err != nil { + return 93 + } + } + p, _ = syscall.UTF16PtrFromString(filepath.Join(filepath.Dir(req.Overlay), userdataCompactTmp)) + scratch, err := syscall.CreateFile(p, syscall.GENERIC_READ|syscall.GENERIC_WRITE, syscall.FILE_SHARE_READ, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + return 94 + } + defer syscall.CloseHandle(scratch) + p, _ = syscall.UTF16PtrFromString(path + ".held") + held, err := syscall.CreateFile(p, syscall.GENERIC_READ|syscall.GENERIC_WRITE, 0, nil, syscall.CREATE_ALWAYS, 0, 0) + if err != nil { + return 94 + } + defer syscall.CloseHandle(held) + if err := os.WriteFile(path+".ready", []byte(strconv.Itoa(os.Getpid())), 0600); err != nil { + return 95 + } + // The controller must interrupt this stage. Expiry is a test failure, + // never a successful conversion after an ineffective cancellation. + time.Sleep(time.Minute) + return 97 + } + cmd := exec.Command(req.Real, os.Args[1:]...) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + win.HideConsoleCmd(cmd) + if err := cmd.Run(); err != nil { + if e, ok := err.(*exec.ExitError); ok { + return e.ExitCode() + } + return 96 + } + return 0 +} + +func TestCombinedCompactParent(t *testing.T) { + dir := os.Getenv("MODAVD_TEST_COMBINED_ROOT") + if dir == "" { + return + } + r, err := root.New(dir) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if os.Getenv("MODAVD_TEST_COMBINED_MODE") == "cancel" { + go func() { + for ctx.Err() == nil { + if _, err := os.Stat(os.Getenv(combinedEnv) + ".cancel"); err == nil { + cancel() + return + } + time.Sleep(10 * time.Millisecond) + } + }() + } + res, err := New(r, nil).DiskCompact(ctx, 2) + if err == nil { + t.Fatalf("expected interrupted compact, got %+v", res) + } + want := errs.Canceled + if os.Getenv("MODAVD_TEST_COMBINED_MODE") == "timeout" { + want = errs.Timeout + } + if errs.CodeOf(err) != want { + t.Fatalf("want %s: %v", want, err) + } +} + +func combinedFixture(t *testing.T) (*Manager, string, string, []byte) { + t.Helper() + sdk := os.Getenv("MODAVD_TEST_SDK_ROOT") + if sdk == "" { + t.Skip("requires existing qemu-img SDK") + } + real := filepath.Join(sdk, "emulator", "qemu-img.exe") + m := New(managerTestRoot(t, 2), nil) + in, err := m.instance(2) + if err != nil { + t.Fatal(err) + } + qemu := func(args ...string) { + t.Helper() + cmd := exec.Command(real, args...) + cmd.Dir = in.Dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %v\n%s", args, err, out) + } + } + raw := filepath.Join(m.Root.Dir, "source.raw") + data := make([]byte, 64<<20) + copy(data[1<<20:], bytes.Repeat([]byte{0x31}, 64<<10)) + copy(data[32<<20:], bytes.Repeat([]byte{0x72}, 64<<10)) + if err := os.WriteFile(raw, data, 0600); err != nil { + t.Fatal(err) + } + qemu("convert", "-f", "raw", "-O", "qcow2", raw, userdataBacking) + clear(data) + copy(data[32<<20:], bytes.Repeat([]byte{0xa5}, 64<<10)) + if err := os.WriteFile(raw, data, 0600); err != nil { + t.Fatal(err) + } + qemu("convert", "-f", "raw", "-O", "qcow2", raw, userdataOverlay) + qemu("rebase", "-u", "-f", "qcow2", "-F", "qcow2", "-b", userdataBacking, userdataOverlay) + copy(data[1<<20:], bytes.Repeat([]byte{0x31}, 64<<10)) + overlay := filepath.Join(in.Dir, userdataOverlay) + addCompactGarbage(t, overlay) + copyTestExe(t, filepath.Join(m.Root.RuntimeSDK(), "emulator", "qemu-img.exe")) + return m, real, overlay, data +} + +func combinedWriteRequest(t *testing.T, path string, req combinedRequest) { + t.Helper() + b, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0600); err != nil { + t.Fatal(err) + } +} + +func TestCombinedCompactInterruption(t *testing.T) { + for _, mode := range []string{"kill", "cancel", "timeout"} { + if mode == "timeout" && os.Getenv("MODAVD_TEST_COMPACT_SHORT_BUDGET") != "1" { + continue + } + for _, stage := range []string{"convert", "compare"} { + t.Run(mode+"/"+stage, func(t *testing.T) { + m, real, overlay, expected := combinedFixture(t) + request := filepath.Join(m.Root.Dir, "request.json") + combinedWriteRequest(t, request, combinedRequest{real, overlay, stage}) + t.Setenv(combinedEnv, request) + before, err := os.ReadFile(overlay) + if err != nil { + t.Fatal(err) + } + id := compactFileID(t, overlay) + backing := filepath.Join(filepath.Dir(overlay), userdataBacking) + backingBefore, err := os.ReadFile(backing) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestCombinedCompactParent$", "-test.timeout=30s") + cmd.Env = append(os.Environ(), "MODAVD_TEST_COMBINED_ROOT="+m.Root.Dir, "MODAVD_TEST_COMBINED_MODE="+mode) + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + win.HideConsoleCmd(cmd) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + finished := make(chan struct{}) + var parentErr error + go func() { parentErr = cmd.Wait(); close(finished) }() + t.Cleanup(func() { cmd.Process.Kill(); <-finished }) + h := lifetimeAwaitChild(t, request) + calls, err := os.ReadFile(request + ".calls") + if err != nil { + t.Fatal(err) + } + assertDiskBusy(t, func(ctx context.Context) error { _, err := m.Start(ctx, 2, StartOptions{}); return err }) + assertDiskBusy(t, func(ctx context.Context) error { _, err := m.Delete(ctx, 2); return err }) + assertDiskBusy(t, func(ctx context.Context) error { _, err := m.Bridge(ctx, 2, "install"); return err }) + assertDiskBusy(t, func(ctx context.Context) error { _, err := m.DiskCompact(ctx, 2); return err }) + start := time.Now() + info, err := m.DiskInfo(context.Background(), 2) + if err != nil || info.AllocatedBytes != nil || info.ReclaimableBytes != nil || info.LogicalBytes != 64<<20 || info.OnDiskBytes == nil || !strings.Contains(strings.Join(info.Warnings, " "), "正在整理磁盘") || time.Since(start) > time.Second { + t.Fatalf("unsafe info: %+v %v", info, err) + } + if after, err := os.ReadFile(request + ".calls"); err != nil || !bytes.Equal(calls, after) { + t.Fatalf("busy/info launched qemu-img: %s %v", after, err) + } + if mode == "kill" { + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + } + if mode == "cancel" { + if err := os.WriteFile(request+".cancel", nil, 0600); err != nil { + t.Fatal(err) + } + } + select { + case <-finished: + case <-time.After(12 * time.Second): + t.Fatal("parent did not finish") + } + if mode != "kill" && parentErr != nil { + t.Fatalf("parent: %v\n%s", parentErr, &output) + } + lifetimeAssertExited(t, h, request) + if after, err := os.ReadFile(overlay); err != nil || !bytes.Equal(before, after) || id != compactFileID(t, overlay) { + t.Fatalf("interruption changed original: %v", err) + } + unlock, err := m.lockInstanceForCompact(2) + if err != nil { + t.Fatal(err) + } + unlock() + tmp := filepath.Join(filepath.Dir(overlay), userdataCompactTmp) + if mode != "kill" { + if _, err := os.Stat(tmp); !os.IsNotExist(err) { + t.Fatalf("scratch after cancel/timeout: %v", err) + } + } + combinedWriteRequest(t, request, combinedRequest{real, overlay, ""}) + info, err = m.DiskInfo(context.Background(), 2) + if err != nil || info.AllocatedBytes == nil { + t.Fatalf("offline info did not recover: %+v %v", info, err) + } + res, err := m.DiskCompact(context.Background(), 2) + if err != nil || res.Skipped || res.FreedBytes < 16<<20 || id == compactFileID(t, overlay) { + t.Fatalf("retry: %+v %v", res, err) + } + if _, err := os.Stat(tmp); !os.IsNotExist(err) { + t.Fatalf("retry left scratch: %v", err) + } + outRaw := filepath.Join(m.Root.Dir, "after.raw") + if out, err := exec.Command(real, "convert", "-O", "raw", overlay, outRaw).CombinedOutput(); err != nil { + t.Fatalf("unreadable original: %v %s", err, out) + } + if data, err := os.ReadFile(outRaw); err != nil || !bytes.Equal(data, expected) { + t.Fatalf("guest content after retry: %v", err) + } + if b, err := os.ReadFile(backing); err != nil || !bytes.Equal(b, backingBefore) { + t.Fatalf("backing changed: %v", err) + } + res, err = m.DiskCompact(context.Background(), 2) + if err != nil || !res.Skipped { + t.Fatalf("second retry did not skip: %+v %v", res, err) + } + }) + } + } +} + +func TestCombinedLinkedLowBenefit(t *testing.T) { + for _, kind := range []string{"hardlink", "symlink"} { + t.Run(kind, func(t *testing.T) { + m, real, overlay, _ := combinedFixture(t) + request := filepath.Join(m.Root.Dir, "request.json") + combinedWriteRequest(t, request, combinedRequest{real, overlay, ""}) + t.Setenv(combinedEnv, request) + if res, err := m.DiskCompact(context.Background(), 2); err != nil || res.Skipped { + t.Fatalf("initial rewrite: %+v %v", res, err) + } + alias := filepath.Join(filepath.Dir(overlay), "alias.qcow2") + if kind == "hardlink" { + if err := os.Link(overlay, alias); err != nil { + t.Fatal(err) + } + } else { + if err := os.Rename(overlay, alias); err != nil { + t.Fatal(err) + } + if err := os.Symlink(alias, overlay); err != nil { + t.Fatal(err) + } + } + before, err := os.ReadFile(alias) + if err != nil { + t.Fatal(err) + } + id := compactFileID(t, alias) + tmp := filepath.Join(filepath.Dir(overlay), userdataCompactTmp) + if err := os.WriteFile(tmp, []byte("leave this scratch"), 0600); err != nil { + t.Fatal(err) + } + calls, err := os.ReadFile(request + ".calls") + if err != nil { + t.Fatal(err) + } + for _, busy := range []bool{false, true} { + var unlock func() + if busy { + unlock, err = m.lockInstanceForCompact(2) + if err != nil { + t.Fatal(err) + } + } + res, err := m.DiskCompact(context.Background(), 2) + if busy { + unlock() + } + if res != nil || errs.CodeOf(err) != errs.Unsupported { + t.Fatalf("link bypassed by skip/lock: %+v %v", res, err) + } + } + if after, err := os.ReadFile(alias); err != nil || !bytes.Equal(before, after) || id != compactFileID(t, alias) { + t.Fatalf("linked data changed: %v", err) + } + if after, err := os.ReadFile(request + ".calls"); err != nil || !bytes.Equal(calls, after) { + t.Fatalf("link invoked qemu-img: %v", err) + } + if b, err := os.ReadFile(tmp); err != nil || string(b) != "leave this scratch" { + t.Fatalf("link cleaned scratch: %v", err) + } + }) + } +} diff --git a/scripts/verify_disk_combined.py b/scripts/verify_disk_combined.py new file mode 100644 index 0000000..dca80e7 --- /dev/null +++ b/scripts/verify_disk_combined.py @@ -0,0 +1,156 @@ +"""Run #95 cross-PR regressions against an explicitly selected integration tree. + +Requires an existing Windows Go toolchain, Node, and SDK qemu-img. Does not fetch, +build an emulator, start adb, or modify the source tree. Keeps all run evidence. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +def replace_once(text: str, old: str, new: str) -> str: + if text.count(old) != 1: + raise ValueError(f"expected one source marker: {old!r}") + return text.replace(old, new, 1) + + +def test_events(text: str) -> list[dict]: + return [json.loads(line) for line in text.splitlines() if line.startswith('{')] + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--source', type=Path, required=True, help='tree containing Manager #56, #59, #60, #63, #64, #65') + p.add_argument('--sdk', type=Path, required=True, help='existing SDK; qemu-img and DLLs are copied') + p.add_argument('--out', type=Path, required=True, help='new evidence directory, never reused or deleted') + p.add_argument('--go', default='go', help='existing go1.26.5 executable, no automatic download') + p.add_argument('--mutations', action='store_true', help='also prove the added tests reject interaction regressions') + args = p.parse_args() + if os.name != 'nt': + p.error('these process, file identity and link checks require Windows') + src, sdk, out = args.source.resolve(), args.sdk.resolve(), args.out.resolve() + if out == src or src in out.parents: + p.error('evidence must be outside the selected source tree') + scripts = Path(__file__).resolve().parent + prerequisites = ['internal/emu/disk_links_windows.go', 'internal/emu/disk_lock.go', + 'internal/emu/qemu_img_lifetime_windows_test.go', 'internal/emu/compact_policy.go', + 'internal/emu/compact_budget_integration_test.go', 'scripts/test_measure_trim.py'] + for name in prerequisites: + if not (src / name).is_file(): + p.error(f'missing PR prerequisite: {name}') + if not (sdk / 'emulator/qemu-img.exe').is_file(): + p.error('SDK must already contain emulator/qemu-img.exe') + env = {**os.environ, 'GOTOOLCHAIN': 'local', 'GOPROXY': 'off', 'GOENV': 'off', + 'GOFLAGS': '', 'GOWORK': 'off', 'GOOS': 'windows', 'GOARCH': 'amd64'} + want = next(line.split()[1] for line in (src / 'go.mod').read_text().splitlines() if line.startswith('toolchain ')) + version = subprocess.check_output([args.go, 'env', 'GOVERSION'], env=env, text=True).strip() + if version != want: + p.error(f'need existing {want}, got {version}; pass --go with its executable path') + out.mkdir(parents=True, exist_ok=False) + emulator = out / 'sdk/emulator' + emulator.mkdir(parents=True) + for f in [sdk / 'emulator/qemu-img.exe', *(sdk / 'emulator').glob('*.dll')]: + shutil.copyfile(f, emulator / f.name) + temp = out / 'temp' + temp.mkdir() + env.update(MODAVD_TEST_SDK_ROOT=str(emulator.parent), TEMP=str(temp), TMP=str(temp), + MODAVD_COMBINED_SOURCE=str(src)) + # Use testing.TempDir, or #51's testutil when present, with a private OS temp + # root. Older opsTestTempDir ignores cleanup errors when MODAVD_TEST_TMP is set. + env.pop('MODAVD_TEST_TMP', None) + env.pop('MODAVD_TEST_COMPACT_SHORT_BUDGET', None) + fixture_dir = scripts / 'testdata/disk_combined' + replacements = {str(src / 'internal/emu' / f.name): str(f) for f in fixture_dir.glob('*.go')} + def overlay(name: str, extra: dict | None = None) -> Path: + path = out / f'{name}-overlay.json' + path.write_text(json.dumps({'Replace': {**replacements, **(extra or {})}}, indent=2), encoding='utf-8') + return path + def run(name: str, cmd: list[str], *, extra_env: dict | None = None, failure: bool = False) -> list[dict]: + log = out / f'{name}.log' + with log.open('w', encoding='utf-8') as stream: + completed = subprocess.run(cmd, cwd=src, env={**env, **(extra_env or {})}, stdout=stream, + stderr=subprocess.STDOUT, timeout=180) + text = log.read_text(encoding='utf-8') + events = test_events(text) + failed_test = any(e.get('Action') == 'fail' and e.get('Test') for e in events) + if failure: + # A compilation error or unrelated directory cleanup error is not a + # successful mutation check. Require an interaction assertion. + markers = ('wanted prompt compact busy', 'link bypassed by skip/lock', + 'parent did not finish', 'child survived parent/cancellation') + if not completed.returncode or not failed_test or not any(m in text for m in markers): + raise RuntimeError(f'{name}: expected an interaction assertion failure; see {log}') + elif completed.returncode: + raise RuntimeError(f'{name}: exit {completed.returncode}; see {log}') + print(f'{name}: {"regression detected" if failure else "passed"}', flush=True) + return events + def go(ov: Path, pattern: str, packages: list[str] | None = None) -> list[str]: + return [args.go, 'test', f'-overlay={ov}', '-gcflags=modavd/internal/win=-d=checkptr=2', + '-count=1', '-json', f'-run={pattern}', *(packages or ['./internal/emu'])] + def git(*command: str) -> str: + return subprocess.check_output(['git', *command], cwd=src, text=True, encoding='utf-8').strip() + manifest = {'source_head': git('rev-parse', 'HEAD'), 'status': git('status', '--porcelain'), + 'go': version, 'qemu_sha256': hashlib.sha256((emulator / 'qemu-img.exe').read_bytes()).hexdigest(), + 'fixtures': {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in fixture_dir.glob('*.go')}} + source_files = [*prerequisites, 'internal/emu/disk.go', 'internal/win/bound_windows.go', + 'internal/ui/web/js/actions.js', 'internal/ui/web/js/panels.js', 'internal/ui/web/js/format.js'] + manifest['sources'] = {name: hashlib.sha256((src / name).read_bytes()).hexdigest() for name in source_files} + (out / 'manifest.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8') + run('qemu-version', [str(emulator / 'qemu-img.exe'), '--version']) + normal = overlay('normal') + pattern = '^TestCombined|^TestCompactRealCompressedSkip$|^TestDiskCompactIntegration$|^TestCompactBudgetMissingAndGrowth$|^TestCompactResult|^TestDiskJobs$|^TestQemuImgLifetime' + events = run('combined', go(normal, pattern, ['./internal/emu', './internal/cli', './internal/mcp', './internal/ui'])) + required = ['TestCombinedCompactInterruption/kill/convert', 'TestCombinedCompactInterruption/kill/compare', + 'TestCombinedCompactInterruption/cancel/convert', 'TestCombinedCompactInterruption/cancel/compare', + 'TestCombinedLinkedLowBenefit/hardlink', 'TestCombinedLinkedLowBenefit/symlink', + 'TestDiskCompactIntegration/BackingData', 'TestCompactRealCompressedSkip', + 'TestCompactResultCLI', 'TestCompactResultMCP', 'TestCompactResultGUI'] + passed = {e.get('Test') for e in events if e.get('Action') == 'pass'} + if set(required) - passed: + raise RuntimeError(f'required tests did not pass (skips do not count): {set(required) - passed}') + policy = src / 'internal/emu/compact_policy.go' + changed = out / 'short-budget.go' + changed.write_text(replace_once(policy.read_text(encoding='utf-8'), 'work := max(virtual, allocated, onDisk)', + 'return 5 * time.Second\n\twork := max(virtual, allocated, onDisk)'), encoding='utf-8') + short = overlay('short-budget', {str(policy): str(changed)}) + timed = run('timeouts', go(short, '^TestCombinedCompactInterruption/timeout'), extra_env={'MODAVD_TEST_COMPACT_SHORT_BUDGET': '1'}) + for stage in ['convert', 'compare']: + if not any(e.get('Action') == 'pass' and e.get('Test') == f'TestCombinedCompactInterruption/timeout/{stage}' for e in timed): + raise RuntimeError(f'timeout/{stage} did not run') + run('ui', ['node', '--test', str(scripts / 'test_disk_combined_ui.mjs')]) + if args.mutations: + disk = src / 'internal/emu/disk.go' + source = disk.read_text(encoding='utf-8') + mutations = [ + ('lost-link-check', disk, replace_once(source, 'if err := checkCompactOverlayLinks(overlay, st); err != nil {', + 'if err := error(nil); err != nil {'), '^TestCombinedLinkedLowBenefit/hardlink'), + ('lost-disk-lock', disk, replace_once(source, '{"disk", "正在整理磁盘"},', ''), '^TestCombinedCompactInterruption/cancel/convert'), + ] + bound = src / 'internal/win/bound_windows.go' + old = bound.read_text(encoding='utf-8') + new, count = re.subn(r'(jobObjectLimitKillOnJobClose\s*=\s*)0x2000', r'\g<1>0', old) + if count != 1: + raise RuntimeError('kill-on-close source marker changed') + mutations.append(('lost-job-lifetime', bound, new, '^TestCombinedCompactInterruption/kill/convert')) + for name, target, body, pattern in mutations: + file = out / f'{name}.go' + file.write_text(body, encoding='utf-8') + run(name, go(overlay(name, {str(target): str(file)}), pattern), failure=True) + (out / 'result.json').write_text(json.dumps({'passed': True, 'required_tests': required, + 'scaled_timeout_stages': ['convert', 'compare'], 'mutations': args.mutations}, indent=2), encoding='utf-8') + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + print(error, file=sys.stderr) + sys.exit(1)