Files
Netcatty/components/KeychainCardLayout.test.tsx
T
bincxz b62f6b044f fix(ui): match keychain New Key button to sibling actions
Drop the primary accent styling on the header New Key split control so it
uses the same secondary chrome as Import Certificate and New Identity.
2026-07-24 11:40:16 +08:00

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);
});