[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:
338
components/KeychainCardLayout.test.tsx
Normal file
338
components/KeychainCardLayout.test.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user