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
339 lines
9.8 KiB
TypeScript
339 lines
9.8 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
|
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
|
|
import type { Identity, SSHKey } from "../types.ts";
|
|
import KeychainManager from "./KeychainManager.tsx";
|
|
import { IdentityCard } from "./keychain/IdentityCard.tsx";
|
|
import { KeyCard } from "./keychain/KeyCard.tsx";
|
|
import {
|
|
shouldShowIdentitySection,
|
|
shouldShowKeySection,
|
|
shouldShowSearchNoResults,
|
|
} from "./keychain/utils.ts";
|
|
|
|
const longLabel =
|
|
"sdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijssdakdjkasjakjskajskaijs";
|
|
|
|
const renderWithI18n = (node: React.ReactElement) =>
|
|
renderToStaticMarkup(
|
|
React.createElement(I18nProvider, { locale: "en" }, node),
|
|
);
|
|
|
|
const installStorageStub = (viewMode: string | null = null) => {
|
|
const values = new Map<string, string>();
|
|
if (viewMode) {
|
|
values.set(STORAGE_KEY_VAULT_KEYS_VIEW_MODE, viewMode);
|
|
}
|
|
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: {
|
|
getItem: (key: string) => values.get(key) ?? null,
|
|
setItem: (key: string, value: string) => {
|
|
values.set(key, value);
|
|
},
|
|
removeItem: (key: string) => {
|
|
values.delete(key);
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
const installNavigatorStub = () => {
|
|
const currentNavigator = globalThis.navigator;
|
|
if (
|
|
typeof currentNavigator?.platform === "string" &&
|
|
typeof currentNavigator?.userAgent === "string"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
Object.defineProperty(globalThis, "navigator", {
|
|
configurable: true,
|
|
value: {
|
|
platform: "MacIntel",
|
|
userAgent: "Mac OS",
|
|
},
|
|
});
|
|
};
|
|
|
|
const identity: Identity = {
|
|
id: "identity-1",
|
|
label: longLabel,
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "pw",
|
|
created: 1,
|
|
};
|
|
|
|
const keyItem: SSHKey = {
|
|
id: "key-1",
|
|
label: longLabel,
|
|
type: "ED25519",
|
|
privateKey: "",
|
|
publicKey: "",
|
|
source: "imported",
|
|
category: "key",
|
|
created: 1,
|
|
};
|
|
|
|
test("IdentityCard list layout constrains long labels", () => {
|
|
const markup = renderWithI18n(
|
|
React.createElement(IdentityCard, {
|
|
identity,
|
|
viewMode: "list",
|
|
isSelected: false,
|
|
onClick: () => {},
|
|
}),
|
|
);
|
|
|
|
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
|
|
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
|
|
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
|
|
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
|
|
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
|
|
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
|
|
});
|
|
|
|
test("KeyCard list layout constrains long labels", () => {
|
|
const markup = renderWithI18n(
|
|
React.createElement(KeyCard, {
|
|
keyItem,
|
|
viewMode: "list",
|
|
isSelected: false,
|
|
isMac: false,
|
|
onClick: () => {},
|
|
onEdit: () => {},
|
|
onExport: () => {},
|
|
onCopyPublicKey: () => {},
|
|
onDelete: () => {},
|
|
}),
|
|
);
|
|
|
|
assert.match(markup, /group cursor-pointer min-w-0 w-full max-w-full/);
|
|
assert.doesNotMatch(markup, /group cursor-pointer min-w-0 w-full max-w-full overflow-hidden/);
|
|
assert.match(markup, /flex items-center gap-3 h-full min-w-0/);
|
|
assert.doesNotMatch(markup, /flex items-center gap-3 h-full min-w-0 overflow-hidden/);
|
|
assert.match(markup, /min-w-0 flex-1 basis-0 overflow-hidden/);
|
|
assert.match(markup, /block max-w-full truncate text-sm font-semibold/);
|
|
});
|
|
|
|
test("KeychainManager shows keys and identities on the same page", () => {
|
|
installNavigatorStub();
|
|
installStorageStub("list");
|
|
|
|
const markup = renderWithI18n(
|
|
React.createElement(KeychainManager, {
|
|
keys: [keyItem],
|
|
identities: [identity],
|
|
hosts: [],
|
|
proxyProfiles: [],
|
|
customGroups: [],
|
|
groupConfigs: [],
|
|
managedSources: [],
|
|
onSave: () => {},
|
|
onUpdate: () => {},
|
|
onReorderKeys: () => {},
|
|
onDelete: () => {},
|
|
onSaveIdentity: () => {},
|
|
onReorderIdentities: () => {},
|
|
onDeleteIdentity: () => {},
|
|
}),
|
|
);
|
|
|
|
assert.match(markup, /h-full min-w-0 w-full overflow-hidden flex relative/);
|
|
assert.match(markup, /flex-1 min-w-0 w-full overflow-y-auto/);
|
|
assert.doesNotMatch(markup, /flex-1 min-w-0 w-full overflow-y-auto overflow-x-hidden/);
|
|
assert.match(markup, /flex min-w-0 w-full max-w-full flex-col gap-0/);
|
|
assert.match(markup, /block min-w-0 w-full max-w-full/);
|
|
assert.match(markup, /data-section="keychain-identities"/);
|
|
assert.match(markup, /data-section="keychain-keys"/);
|
|
assert.doesNotMatch(markup, /data-section="keychain-empty"/);
|
|
assert.doesNotMatch(markup, />Identities<\/button>/);
|
|
});
|
|
|
|
test("KeychainManager shows keys without the identities section when no identities exist", () => {
|
|
installNavigatorStub();
|
|
installStorageStub();
|
|
|
|
const markup = renderWithI18n(
|
|
React.createElement(KeychainManager, {
|
|
keys: [keyItem],
|
|
identities: [],
|
|
onSave: () => {},
|
|
onUpdate: () => {},
|
|
onDelete: () => {},
|
|
onSaveIdentity: () => {},
|
|
}),
|
|
);
|
|
|
|
assert.match(markup, /data-section="keychain-keys"/);
|
|
assert.doesNotMatch(markup, /data-section="keychain-identities"/);
|
|
assert.doesNotMatch(markup, /data-section="keychain-empty"/);
|
|
});
|
|
|
|
test("KeychainManager shows the empty prompt only when keys and identities are absent", () => {
|
|
installNavigatorStub();
|
|
installStorageStub();
|
|
|
|
const markup = renderWithI18n(
|
|
React.createElement(KeychainManager, {
|
|
keys: [],
|
|
identities: [],
|
|
onSave: () => {},
|
|
onUpdate: () => {},
|
|
onDelete: () => {},
|
|
onSaveIdentity: () => {},
|
|
}),
|
|
);
|
|
|
|
assert.match(markup, /data-section="keychain-keys"/);
|
|
assert.match(markup, /data-section="keychain-empty"/);
|
|
assert.doesNotMatch(markup, /data-section="keychain-identities"/);
|
|
});
|
|
|
|
test("KeychainManager hides the empty key CTA when identities exist without keys", () => {
|
|
installNavigatorStub();
|
|
installStorageStub();
|
|
|
|
const markup = renderWithI18n(
|
|
React.createElement(KeychainManager, {
|
|
keys: [],
|
|
identities: [identity],
|
|
onSave: () => {},
|
|
onUpdate: () => {},
|
|
onDelete: () => {},
|
|
onSaveIdentity: () => {},
|
|
}),
|
|
);
|
|
|
|
assert.match(markup, /data-section="keychain-identities"/);
|
|
assert.doesNotMatch(markup, /data-section="keychain-keys"/);
|
|
assert.doesNotMatch(markup, /data-section="keychain-empty"/);
|
|
});
|
|
|
|
test("KeychainManager exposes new-key, import-certificate, and new-identity header actions", () => {
|
|
installNavigatorStub();
|
|
installStorageStub();
|
|
|
|
const markup = renderWithI18n(
|
|
React.createElement(KeychainManager, {
|
|
keys: [],
|
|
identities: [],
|
|
onSave: () => {},
|
|
onUpdate: () => {},
|
|
onDelete: () => {},
|
|
onSaveIdentity: () => {},
|
|
}),
|
|
);
|
|
|
|
// Header split "New Key" uses the same secondary chrome as sibling actions
|
|
assert.match(
|
|
markup,
|
|
/rounded-md shrink-0 bg-foreground\/5 text-foreground[\s\S]*?>New Key<\/button>/,
|
|
);
|
|
assert.match(markup, />Import Certificate<\/button>/);
|
|
assert.match(markup, />New Identity<\/button>/);
|
|
assert.doesNotMatch(markup, />KEY<\/button>/);
|
|
assert.doesNotMatch(markup, />CERTIFICATE<\/button>/);
|
|
assert.doesNotMatch(markup, />Identities<\/button>/);
|
|
});
|
|
|
|
test("keychain search reveals matching keys when identities do not match", () => {
|
|
assert.equal(shouldShowIdentitySection({
|
|
identityCount: 1,
|
|
filteredIdentityCount: 0,
|
|
filteredKeyCount: 1,
|
|
search: "matching-key",
|
|
}), false);
|
|
});
|
|
|
|
test("keychain search keeps identities for identity matches or no results", () => {
|
|
assert.equal(shouldShowIdentitySection({
|
|
identityCount: 1,
|
|
filteredIdentityCount: 1,
|
|
filteredKeyCount: 0,
|
|
search: "matching-identity",
|
|
}), true);
|
|
assert.equal(shouldShowIdentitySection({
|
|
identityCount: 1,
|
|
filteredIdentityCount: 0,
|
|
filteredKeyCount: 0,
|
|
search: "no-results",
|
|
}), true);
|
|
});
|
|
|
|
test("keychain search shows both sections when both kinds match", () => {
|
|
const state = {
|
|
identityCount: 1,
|
|
filteredIdentityCount: 1,
|
|
filteredKeyCount: 1,
|
|
search: "shared-label",
|
|
};
|
|
|
|
assert.equal(shouldShowIdentitySection(state), true);
|
|
assert.equal(shouldShowKeySection(state), true);
|
|
});
|
|
|
|
test("keychain browsing shows keys and identities together", () => {
|
|
assert.equal(shouldShowKeySection({
|
|
identityCount: 1,
|
|
filteredKeyCount: 1,
|
|
search: "",
|
|
}), true);
|
|
assert.equal(shouldShowIdentitySection({
|
|
identityCount: 1,
|
|
filteredIdentityCount: 1,
|
|
filteredKeyCount: 1,
|
|
search: "",
|
|
}), true);
|
|
assert.equal(shouldShowIdentitySection({
|
|
identityCount: 0,
|
|
filteredIdentityCount: 0,
|
|
filteredKeyCount: 1,
|
|
search: "",
|
|
}), false);
|
|
});
|
|
|
|
test("keychain browsing hides keys when only identities exist", () => {
|
|
assert.equal(shouldShowKeySection({
|
|
identityCount: 1,
|
|
filteredKeyCount: 0,
|
|
search: "",
|
|
}), false);
|
|
});
|
|
|
|
test("keychain search hides the keys section when only identities match", () => {
|
|
assert.equal(shouldShowKeySection({
|
|
identityCount: 1,
|
|
filteredKeyCount: 0,
|
|
search: "identity-only",
|
|
}), false);
|
|
});
|
|
|
|
test("keychain search keeps keys visible for empty key searches without identities", () => {
|
|
assert.equal(shouldShowKeySection({
|
|
identityCount: 0,
|
|
filteredKeyCount: 0,
|
|
search: "no-results",
|
|
}), true);
|
|
});
|
|
|
|
test("keychain search hides keys when identities cover the empty-search state", () => {
|
|
assert.equal(shouldShowKeySection({
|
|
identityCount: 1,
|
|
filteredKeyCount: 0,
|
|
search: "no-results",
|
|
}), false);
|
|
});
|
|
|
|
test("keychain distinguishes search misses from an empty vault", () => {
|
|
assert.equal(shouldShowSearchNoResults("missing", 0, 1), true);
|
|
assert.equal(shouldShowSearchNoResults("", 0, 1), false);
|
|
assert.equal(shouldShowSearchNoResults("missing", 0, 0), false);
|
|
});
|