Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions scripts/test_disk_combined_ui.mjs
Original file line number Diff line number Diff line change
@@ -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, '完成');
});
61 changes: 61 additions & 0 deletions scripts/testdata/disk_combined/README.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 67 additions & 0 deletions scripts/testdata/disk_combined/compact_compressed_windows_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading