From 7e1d6dccdc14ea73a28ec196a4f78f831dcfa0b7 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:37:21 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=9C=A8=E5=B8=B8=E8=A7=84=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=B8=AD=E8=A6=86=E7=9B=96=E7=A3=81=E7=9B=98=E6=95=B4?= =?UTF-8?q?=E7=90=86=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 + .../emu/compact_compressed_windows_test.go | 67 ++++ internal/emu/disk_combined_windows_test.go | 367 ++++++++++++++++++ scripts/test_compact_format.mjs | 61 ++- 4 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 internal/emu/compact_compressed_windows_test.go create mode 100644 internal/emu/disk_combined_windows_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40eb72b..c96a302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,8 @@ jobs: node-version: 24 - name: display coordinate tests run: node --test scripts/test_display_tap.mjs + - name: disk UI tests + run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs - name: gofmt shell: pwsh run: | diff --git a/internal/emu/compact_compressed_windows_test.go b/internal/emu/compact_compressed_windows_test.go new file mode 100644 index 0000000..2847e7a --- /dev/null +++ b/internal/emu/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/internal/emu/disk_combined_windows_test.go b/internal/emu/disk_combined_windows_test.go new file mode 100644 index 0000000..ef7039a --- /dev/null +++ b/internal/emu/disk_combined_windows_test.go @@ -0,0 +1,367 @@ +//go:build windows + +package emu + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "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.tmp", []byte(strconv.Itoa(os.Getpid())), 0600); err != nil { + return 95 + } + if err := os.Rename(path+".ready.tmp", path+".ready"); 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 == "" { + t.Skip("subprocess helper requires MODAVD_TEST_COMBINED_ROOT") + } + r, err := root.New(dir) + if err != nil { + t.Fatal(err) + } + var ctx context.Context + var cancel context.CancelFunc + if os.Getenv("MODAVD_TEST_COMBINED_MODE") == "deadline" { + ctx, cancel = context.WithTimeout(context.Background(), 20*time.Second) + } else { + 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) + } + if errs.CodeOf(err) != errs.Canceled { + t.Fatalf("want canceled: %v", err) + } + if os.Getenv("MODAVD_TEST_COMBINED_MODE") == "deadline" && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("want caller deadline: %v", 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", "deadline"} { + 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=45s") + 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(30 * 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/deadline: %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 { + if errors.Is(err, syscall.ERROR_PRIVILEGE_NOT_HELD) { + t.Skipf("account cannot create file symlinks: %v", err) + } + 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/test_compact_format.mjs b/scripts/test_compact_format.mjs index 0873a2f..630a047 100644 --- a/scripts/test_compact_format.mjs +++ b/scripts/test_compact_format.mjs @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; +import vm from 'node:vm'; const source = await readFile(new URL('../internal/ui/web/js/format.js', import.meta.url), 'utf8'); -const { diskCompactText } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); +const fmt = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); +const { diskCompactText } = fmt; +const read = (name) => readFile(new URL(`../internal/ui/web/js/${name}`, import.meta.url), 'utf8'); +const moduleBody = (s) => s.replace(/^import .*;\r?\n/gm, '').replace(/^export /gm, ''); test('compact text reports small growth without claiming a saving', () => { const text = diskCompactText({ before_on_disk_bytes: 2 ** 30, after_on_disk_bytes: 2 ** 30 + 1, freed_bytes: 0 }); @@ -23,3 +27,58 @@ test('compact text distinguishes skip from rewrite', () => { assert.match(text, /不足 16 MB,已跳过重写/); assert.match(text, /占用 4 KB/); }); + +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, '完成'); +});