Skip to content
Merged
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
8 changes: 7 additions & 1 deletion docs/development/project-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,10 @@ Application-directory removal is the applicable removal mechanism for the unsign

`tests/packaged/cleanup.spec.ts` starts the extracted Windows ZIP executable directly in cleanup mode with OS-only discovery. `tests/support/installed-cleanup-windows.ps1` uses Windows UI Automation, scoped to that process ID, to inspect the real OS dialogs and operate their confirmation checkboxes. It clicks the uniquely matched visible action label through Windows mouse input after verifying the foreground dialog, process ID, and window under the target point; the provider exposes these labels without an Invoke pattern. No Electron dialog adapter is replaced. The three journeys exercise unchecked category selection, final cancellation, and confirmed deletion of only the Model Store category. Each verifies default-unchecked confirmation, graceful process exit, byte-identical Project Library and Offline Mode settings, and preservation of unknown and external files. Seeded model bytes test deletion scope, not valid pack installation.

Native execution is exclusively in GitHub CI; local validation lists the scenarios without launching an application. These tests add Windows native-dialog coverage but do not claim macOS cleanup interaction, consumer first-launch acceptance or screen-reader usability. CI results must be recorded before any native acceptance claim.
Native execution is exclusively in GitHub CI; local validation lists the scenarios without launching an application. CI results must be recorded before any native acceptance claim. Native-dialog automation does not demonstrate consumer first-launch acceptance or screen-reader usability.

## Installed macOS cleanup confirmation

The same three scenarios also select the macOS ZIP executable. `tests/support/installed-cleanup-macos.jxa` uses the OS `osascript` executable and System Events to find the exact application PID, inspect bounded native accessibility trees, verify default-unchecked checkboxes, and press uniquely named native buttons. It emits only fixed category/action names and observed confirmation state. The shared assertions require byte-identical Library, Offline Mode settings and unchecked Offline Media Cache preservation, while only the confirmed Model Store category may be deleted.

The [GitHub runner image provisions Accessibility and System Events permissions for osascript](https://github.com/actions/runner-images/blob/main/images/macos/scripts/build/configure-tccdb-macos.sh). The test does not change privacy permissions or replace application dialog APIs. Unavailable accessibility access fails the journey instead of recording a skipped native success. Local validation compiles the JXA script and lists tests; actual macOS interaction requires the current-head GitHub CI result.
88 changes: 59 additions & 29 deletions tests/packaged/cleanup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import { openNetworkMode } from "../../apps/desktop/src/main/network-mode.ts";
import { openProjectLibrary } from "../../apps/desktop/src/main/project-library.ts";
import { goldenRecords } from "../support/editor-fixture.ts";

// UI Automation drives actual Windows OS dialogs; macOS native interaction remains a gate.
test.skip(process.platform !== "win32", "Windows installed native cleanup dialogs");
// System accessibility clients drive the installed native dialogs without replacing Electron APIs.
test.skip(
process.platform !== "win32" && process.platform !== "darwin",
"Installed native desktop cleanup dialogs",
);
const execute = promisify(execFile);
let installation: string;

Expand All @@ -25,9 +28,10 @@ test.beforeAll(async () => {
await extractZip(
join(
process.cwd(),
"out/make/zip/win32",
"out/make/zip",
process.platform,
process.arch,
`Open Chords-win32-${process.arch}-0.0.0.zip`,
`Open Chords-${process.platform}-${process.arch}-0.0.0.zip`,
),
{ dir: installation },
);
Expand Down Expand Up @@ -55,7 +59,7 @@ async function fingerprints(root: string) {
}

for (const scenario of ["unchecked", "cancel-final", "confirm-models"] as const) {
test(`installed Windows cleanup native confirmation: ${scenario}`, async () => {
test(`installed ${process.platform} cleanup native confirmation: ${scenario}`, async () => {
test.setTimeout(180000);
const root = await realpath(await mkdtemp(join(tmpdir(), "oc-cleanup-data-")));
const state = join(root, "state");
Expand All @@ -82,25 +86,30 @@ for (const scenario of ["unchecked", "cancel-final", "confirm-models"] as const)
const originalLibrary = await fingerprints(join(state, "project-library"));
const settings = await readFile(join(state, "network-mode.json"));
const windowsRoot = process.env.SystemRoot ?? process.env.WINDIR;
if (!windowsRoot) throw new Error("Windows system directory unavailable");
if (process.platform === "win32" && !windowsRoot)
throw new Error("Windows system directory unavailable");
const env: Record<string, string> = {};
for (const key of [
"SystemRoot",
"WINDIR",
"TEMP",
"TMP",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
])
if (process.env[key] !== undefined) env[key] = process.env[key];
env.PATH = [
join(windowsRoot, "System32", "WindowsPowerShell", "v1.0"),
join(windowsRoot, "System32"),
windowsRoot,
].join(";");
if (process.platform === "win32")
env.PATH = [
join(windowsRoot!, "System32", "WindowsPowerShell", "v1.0"),
join(windowsRoot!, "System32"),
windowsRoot!,
].join(";");
child = spawn(
join(installation, "Open Chords.exe"),
process.platform === "darwin"
? join(installation, "Open Chords.app/Contents/MacOS/Open Chords")
: join(installation, "Open Chords.exe"),
["--open-chords-cleanup", `--user-data-dir=${state}`],
{ env, stdio: "ignore" },
);
Expand All @@ -109,23 +118,44 @@ for (const scenario of ["unchecked", "cancel-final", "confirm-models"] as const)
child!.once("exit", resolve);
});
if (!child.pid) throw new Error("Installed cleanup process unavailable");
const { stdout } = await execute(
join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
join(process.cwd(), "tests/support/installed-cleanup-windows.ps1"),
"-ApplicationPid",
String(child.pid),
"-Scenario",
scenario,
],
{ timeout: 100000, maxBuffer: 16384 },
);
const driver =
process.platform === "darwin"
? {
command: "/usr/bin/osascript",
args: [
"-l",
"JavaScript",
join(process.cwd(), "tests/support/installed-cleanup-macos.jxa"),
String(child.pid),
scenario,
],
}
: {
command: join(
windowsRoot!,
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
),
args: [
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
join(process.cwd(), "tests/support/installed-cleanup-windows.ps1"),
"-ApplicationPid",
String(child.pid),
"-Scenario",
scenario,
],
};
const { stdout } = await execute(driver.command, driver.args, {
timeout: 100000,
maxBuffer: 16384,
});
const events = z
.array(z.object({ category: z.string(), checked: z.boolean(), action: z.string() }))
.parse(JSON.parse(stdout));
Expand Down
107 changes: 107 additions & 0 deletions tests/support/installed-cleanup-macos.jxa
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
function run(argv) {
var applicationPid = Number(argv[0]);
var scenario = argv[1];
if (!Number.isInteger(applicationPid) || applicationPid <= 0 ||
['unchecked', 'cancel-final', 'confirm-models'].indexOf(scenario) === -1) {
throw new Error('Invalid native cleanup driver arguments');
}
var systemEvents = Application('System Events');
var labels = {
projects: 'Projects, revisions, Library Trash and Source records (irreplaceable)',
models: 'Installed language packs and models (can be downloaded again)',
offline_media: 'Offline Media Cache (offline playback copies will be lost)',
settings: 'Settings and completed acquisition history',
application_state: 'Application caches, logs and browser state',
final: 'Permanently delete the selected data?',
result: 'Selected cleanup completed'
};
var seen = {};
var events = [];
var deadline = Date.now() + 90000;
function attribute(element, name) {
try { return element[name](); } catch (_) { return null; }
}
function waitForAttribute(element, name, expected) {
var waitDeadline = Math.min(deadline, Date.now() + 2000);
while (Date.now() < waitDeadline) {
if (attribute(element, name) === expected) return true;
delay(0.1);
}
return attribute(element, name) === expected;
}
while (Date.now() < deadline) {
var processes;
try {
processes = systemEvents.processes.whose({ unixId: applicationPid })();
} catch (_) {
throw new Error('Native cleanup accessibility process discovery unavailable');
}
if (processes.length === 0) {
if (events.length > 0) return JSON.stringify(events);
delay(0.1);
continue;
}
if (processes.length !== 1) throw new Error('Native cleanup process was ambiguous');
var process = processes[0];
var windows;
try { windows = process.windows(); } catch (_) {
throw new Error('Native cleanup accessibility window discovery unavailable');
}
if (windows.length > 16) throw new Error('Native cleanup window budget exceeded');
for (var w = 0; w < windows.length; w++) {
var elements;
try { elements = windows[w].entireContents(); } catch (_) {
throw new Error('Native cleanup accessibility element discovery unavailable');
}
if (elements.length > 256) throw new Error('Native cleanup element budget exceeded');
var category = null;
var keys = Object.keys(labels);
for (var k = 0; k < keys.length && category === null; k++) {
for (var e = 0; e < elements.length; e++) {
if (attribute(elements[e], 'value') === labels[keys[k]] ||
attribute(elements[e], 'name') === labels[keys[k]]) {
category = keys[k];
break;
}
}
}
if (category === null || seen[category]) continue;
var selectModels = category === 'models' && scenario !== 'unchecked';
var confirmFinal = category === 'final' && scenario === 'confirm-models';
var buttonName = category === 'result' ? 'Close' : category === 'final' ?
(confirmFinal ? 'Permanently delete' : 'Cancel') :
category === 'models' ? 'Select for deletion' : 'Preserve category';
var checkboxes = [];
var buttons = [];
for (var i = 0; i < elements.length; i++) {
var role = attribute(elements[i], 'role');
if (role === 'AXCheckBox') checkboxes.push(elements[i]);
if (role === 'AXButton' && attribute(elements[i], 'name') === buttonName &&
attribute(elements[i], 'enabled') === true) buttons.push(elements[i]);
}
if (buttons.length === 0) continue;
if (buttons.length !== 1) throw new Error('Native cleanup action was ambiguous');
if (category !== 'result') {
if (checkboxes.length !== 1) throw new Error('Native confirmation checkbox unavailable');
if (attribute(checkboxes[0], 'value') !== 0) {
throw new Error('Native confirmation was not unchecked by default');
}
if (selectModels || confirmFinal) {
try { checkboxes[0].click(); } catch (_) {
throw new Error('Native confirmation could not toggle');
}
if (!waitForAttribute(checkboxes[0], 'value', 1)) {
throw new Error('Native confirmation did not toggle');
}
}
}
try { buttons[0].click(); } catch (_) {
throw new Error('Native cleanup action could not be pressed');
}
events.push({ category: category, checked: selectModels || confirmFinal, action: buttonName });
seen[category] = true;
}
delay(0.1);
}
throw new Error('Installed cleanup native dialog driver timed out');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
5 changes: 5 additions & 0 deletions tests/support/installed-cleanup-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ while ([DateTime]::UtcNow -lt $deadline) {
if ($toggle.Current.ToggleState -ne [System.Windows.Automation.ToggleState]::Off) { throw 'Native confirmation was not unchecked by default' }
if ($selectModels -or $confirmFinal) {
$toggle.Toggle()
$toggleDeadline = [DateTime]::UtcNow.AddSeconds(2)
if ($toggleDeadline -gt $deadline) { $toggleDeadline = $deadline }
while ($toggle.Current.ToggleState -ne [System.Windows.Automation.ToggleState]::On -and [DateTime]::UtcNow -lt $toggleDeadline) {
Start-Sleep -Milliseconds 100
}
if ($toggle.Current.ToggleState -ne [System.Windows.Automation.ToggleState]::On) { throw 'Native confirmation did not toggle' }
}
}
Expand Down
Loading