[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
902
components/AppLockOverlay.runtime.test.tsx
Normal file
902
components/AppLockOverlay.runtime.test.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { APP_LOCK_AUTO_PROMPT_DELAY_MS, AppLockOverlay } from "./AppLockOverlay.tsx";
|
||||
import {
|
||||
createDomRenderer,
|
||||
dispatchDomEvent,
|
||||
flushEffects,
|
||||
installDomEnvironment,
|
||||
runWithAct,
|
||||
} from "./test-support/renderReactDom.tsx";
|
||||
|
||||
async function waitForAutoPrompt(dom: ReturnType<typeof installDomEnvironment>) {
|
||||
await runWithAct(async () => {
|
||||
await new Promise((resolve) => dom.window.setTimeout(resolve, APP_LOCK_AUTO_PROMPT_DELAY_MS + 50));
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
test("AppLockOverlay shows incorrect-password error and clears it after editing", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: false, error: "incorrect" as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
const passwordLabel = dom.document.querySelector('label[for="app-lock-password"]');
|
||||
const lockIcon = dom.document.querySelector('[data-testid="app-lock-password-lock-icon"]');
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
assert.ok(passwordLabel);
|
||||
assert.ok(lockIcon);
|
||||
assert.equal(input.getAttribute("placeholder"), "Enter password to unlock");
|
||||
assert.match(passwordLabel.getAttribute("class") ?? "", /sr-only/);
|
||||
assert.equal(lockIcon.getAttribute("aria-hidden"), "true");
|
||||
assert.doesNotMatch(lockIcon.getAttribute("class") ?? "", /border-l/);
|
||||
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "wrong");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, ["wrong"]);
|
||||
assert.match(dom.document.body.textContent ?? "", /Incorrect lock password/i);
|
||||
|
||||
setInputValue.call(input, "wrong-again");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Incorrect lock password/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay submits a whitespace-only password unchanged", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, " ");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, [" "]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reveals reset action after clicking Netcatty logo five times", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
|
||||
const resetButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
||||
assert.ok(resetButton);
|
||||
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay only reveals reset after five quick logo clicks", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await new Promise((resolve) => dom.window.setTimeout(resolve, 1600));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Reset App Lock/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reset controls do not submit the unlock form", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let unlockCount = 0;
|
||||
const resetAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => {
|
||||
unlockCount += 1;
|
||||
return { ok: false, error: "incorrect" as const };
|
||||
},
|
||||
onResetAppLock: async (currentPassword) => {
|
||||
resetAttempts.push(currentPassword);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
assert.ok(input);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /forgot password/i);
|
||||
const logoButton = dom.document.querySelector("[data-testid='app-lock-logo-easter-egg']");
|
||||
assert.ok(logoButton);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
await flushEffects();
|
||||
|
||||
const cancelButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Cancel/i.test(button.textContent ?? ""));
|
||||
assert.ok(cancelButton);
|
||||
await dispatchDomEvent(cancelButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.deepEqual(resetAttempts, []);
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await dispatchDomEvent(logoButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
await flushEffects();
|
||||
const resetButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((button) => /Reset App Lock/i.test(button.textContent ?? ""));
|
||||
assert.ok(resetButton);
|
||||
await dispatchDomEvent(resetButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(unlockCount, 0);
|
||||
assert.deepEqual(resetAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay renders platform-specific system unlock button", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "win32",
|
||||
label: "Windows Hello",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-windows-icon"]'));
|
||||
|
||||
const button = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Windows Hello/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay localizes the system unlock button label", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "zh-CN" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: true as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /使用 Touch ID 解锁/i);
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-touch-id-icon"]'));
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay does not automatically request system unlock by default", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
const props = {
|
||||
locked: true,
|
||||
reason: "manual" as const,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
};
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.match(dom.document.body.textContent ?? "", /Use password instead/i);
|
||||
|
||||
const button = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay reveals password fallback and cancels a delayed automatic prompt", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
autoPromptSystemUnlock: true,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
const usePasswordButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Use password instead/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(usePasswordButton);
|
||||
await dispatchDomEvent(usePasswordButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.ok(dom.document.getElementById("app-lock-password"));
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay shows animated feedback while system unlock is in flight", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let resolveSystemUnlock: ((result: { ok: true }) => void) | null = null;
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => new Promise<{ ok: true }>((resolve) => {
|
||||
resolveSystemUnlock = resolve;
|
||||
}),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const systemButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(systemButton);
|
||||
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.match(dom.document.body.textContent ?? "", /Waiting for Touch ID/i);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
|
||||
assert.ok(resolveSystemUnlock);
|
||||
await runWithAct(async () => {
|
||||
resolveSystemUnlock?.({ ok: true });
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay automatically requests system unlock once when auto prompt is enabled", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
const props = {
|
||||
locked: true,
|
||||
reason: "manual" as const,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
};
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
assert.ok(dom.document.querySelector('[data-testid="app-lock-system-unlock-loading"]'));
|
||||
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, props),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay waits until the document is visible before auto system unlock", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
try {
|
||||
Object.defineProperty(dom.document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "hidden",
|
||||
});
|
||||
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
|
||||
Object.defineProperty(dom.document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: "visible",
|
||||
});
|
||||
await dispatchDomEvent(dom.document, new dom.window.Event("visibilitychange"));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay waits for window focus before auto system unlock", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let focused = false;
|
||||
let systemUnlockCount = 0;
|
||||
|
||||
Object.defineProperty(dom.document, "hasFocus", {
|
||||
configurable: true,
|
||||
value: () => focused,
|
||||
});
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
autoPromptSystemUnlock: true,
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
return { ok: true as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 0);
|
||||
|
||||
focused = true;
|
||||
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? "", /Preparing Touch ID/i);
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await dispatchDomEvent(dom.window, new dom.window.FocusEvent("focus"));
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay retries a reopen presentation after an in-flight auto prompt finishes", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let systemUnlockCount = 0;
|
||||
let resolveFirstUnlock: ((result: { ok: false; error: "cancelled" }) => void) | null = null;
|
||||
|
||||
try {
|
||||
const createOverlay = (reopenSignal: number) => React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "background",
|
||||
reopenSignal,
|
||||
autoPromptSystemUnlock: true,
|
||||
onUnlock: async () => ({ ok: false as const, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin" as const,
|
||||
label: "Touch ID" as const,
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => {
|
||||
systemUnlockCount += 1;
|
||||
if (systemUnlockCount === 1) {
|
||||
return new Promise<{ ok: false; error: "cancelled" }>((resolve) => {
|
||||
resolveFirstUnlock = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: false as const, error: "cancelled" as const };
|
||||
},
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
await renderer.render(createOverlay(1));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
await renderer.render(createOverlay(2));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.equal(systemUnlockCount, 1);
|
||||
|
||||
assert.ok(resolveFirstUnlock);
|
||||
await runWithAct(async () => {
|
||||
resolveFirstUnlock?.({ ok: false, error: "cancelled" });
|
||||
});
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
await waitForAutoPrompt(dom);
|
||||
|
||||
assert.equal(systemUnlockCount, 2);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay hides system unlock when unavailable", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: false,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: "unavailable",
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: true as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? "", /Unlock with Touch ID/i);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay keeps password fallback after system unlock failure", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const unlockAttempts: string[] = [];
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async (password) => {
|
||||
unlockAttempts.push(password);
|
||||
return { ok: true as const };
|
||||
},
|
||||
systemUnlockStatus: {
|
||||
supported: true,
|
||||
available: true,
|
||||
enabled: true,
|
||||
platform: "darwin",
|
||||
label: "Touch ID",
|
||||
reason: null,
|
||||
},
|
||||
onSystemUnlock: async () => ({ ok: false as const, error: "cancelled" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
assert.equal(dom.document.getElementById("app-lock-password"), null);
|
||||
const systemButton = [...dom.document.querySelectorAll("button")]
|
||||
.find((candidate) => /Unlock with Touch ID/i.test(candidate.textContent ?? ""));
|
||||
assert.ok(systemButton);
|
||||
await dispatchDomEvent(systemButton, new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
assert.match(dom.document.body.textContent ?? "", /System unlock was not completed/i);
|
||||
|
||||
const input = dom.document.getElementById("app-lock-password") as HTMLInputElement | null;
|
||||
const form = dom.document.querySelector("form");
|
||||
assert.ok(input);
|
||||
assert.ok(form);
|
||||
const setInputValue = Object.getOwnPropertyDescriptor(
|
||||
dom.window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
assert.ok(setInputValue);
|
||||
setInputValue.call(input, "secret");
|
||||
await dispatchDomEvent(input, new dom.window.Event("input", { bubbles: true }));
|
||||
await dispatchDomEvent(form, new dom.window.Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushEffects();
|
||||
|
||||
assert.deepEqual(unlockAttempts, ["secret"]);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("AppLockOverlay keeps a window drag region while lock controls stay interactive", async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
|
||||
try {
|
||||
await renderer.render(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(AppLockOverlay, {
|
||||
locked: true,
|
||||
reason: "manual",
|
||||
onUnlock: async () => ({ ok: false, error: "incorrect" as const }),
|
||||
onResetAppLock: async () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await flushEffects();
|
||||
|
||||
const overlay = dom.document.querySelector("[data-app-lock-overlay]");
|
||||
const form = overlay?.querySelector("form");
|
||||
assert.ok(overlay);
|
||||
assert.ok(form);
|
||||
assert.match(overlay.getAttribute("class") ?? "", /\bapp-drag\b/);
|
||||
assert.match(form.getAttribute("class") ?? "", /\bapp-no-drag\b/);
|
||||
} finally {
|
||||
await renderer.unmount();
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user