gemiterm 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +2 -0
- package/.github/workflows/manual-release.yml +34 -0
- package/.github/workflows/release.yml +198 -0
- package/.github/workflows/test.yml +29 -0
- package/.opencode/commands/opsx-apply.md +152 -0
- package/.opencode/commands/opsx-archive.md +157 -0
- package/.opencode/commands/opsx-explore.md +169 -0
- package/.opencode/commands/opsx-propose.md +104 -0
- package/.opencode/commands/opsx-sync.md +140 -0
- package/.opencode/commands/search-intelligently.md +9 -0
- package/.opencode/opencode.json +20 -0
- package/.opencode/skills/intellisearch/.version +1 -0
- package/.opencode/skills/intellisearch/SKILL.md +84 -0
- package/.opencode/skills/intellisearch/references/brave-search.md +67 -0
- package/.opencode/skills/intellisearch/references/ddg-search.md +79 -0
- package/.opencode/skills/intellisearch/references/deepwiki-tools.md +246 -0
- package/.opencode/skills/intellisearch/references/examples.md +97 -0
- package/.opencode/skills/intellisearch/references/gh-cli.md +143 -0
- package/.opencode/skills/intellisearch/references/google-search.md +40 -0
- package/.opencode/skills/intellisearch/references/search-workflow.md +143 -0
- package/.opencode/skills/intellisearch/references/workflow.md +115 -0
- package/.opencode/skills/openspec-apply-change/SKILL.md +159 -0
- package/.opencode/skills/openspec-archive-change/SKILL.md +117 -0
- package/.opencode/skills/openspec-explore/SKILL.md +287 -0
- package/.opencode/skills/openspec-propose/SKILL.md +111 -0
- package/.opencode/skills/openspec-sync-specs/SKILL.md +147 -0
- package/.serena/project.yml +133 -0
- package/AGENTS.md +136 -0
- package/CHANGELOG.md +3 -0
- package/README.md +245 -0
- package/bun.lock +105 -0
- package/docs/INSTALL.md +185 -0
- package/docs/PLAYWRIGHT_CLI_API.md +306 -0
- package/docs/python-release-for-reference.md +175 -0
- package/examples/sample_json_export.json +32 -0
- package/examples/sample_markdown_export.md +221 -0
- package/examples/usage_examples.sh +222 -0
- package/install.ps1 +264 -0
- package/install.sh +275 -0
- package/openspec/MAESTRO_MIGRATION.md +180 -0
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/design.md +194 -0
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/proposal.md +52 -0
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/specs/multi-profile-conversations/spec.md +133 -0
- package/openspec/changes/archive/2026-06-08-command-spec-conformance/tasks.md +95 -0
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/design.md +28 -0
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/proposal.md +22 -0
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/specs/interactive-prompt-loop/spec.md +35 -0
- package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/tasks.md +28 -0
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/design.md +323 -0
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/proposal.md +122 -0
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/specs/no-capability-changes/spec.md +29 -0
- package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/tasks.md +172 -0
- package/openspec/changes/archive/2026-06-09-v2-install-migration/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-06-09-v2-install-migration/design.md +241 -0
- package/openspec/changes/archive/2026-06-09-v2-install-migration/proposal.md +41 -0
- package/openspec/changes/archive/2026-06-09-v2-install-migration/specs/v2-installer/spec.md +205 -0
- package/openspec/changes/archive/2026-06-09-v2-install-migration/tasks.md +97 -0
- package/openspec/changes/commander-cli-parser/.openspec.yaml +2 -0
- package/openspec/changes/commander-cli-parser/design.md +107 -0
- package/openspec/changes/commander-cli-parser/proposal.md +31 -0
- package/openspec/changes/commander-cli-parser/specs/cli/spec.md +48 -0
- package/openspec/changes/commander-cli-parser/tasks.md +33 -0
- package/openspec/changes/cross-platform-build-and-ci/.openspec.yaml +2 -0
- package/openspec/changes/cross-platform-build-and-ci/design.md +213 -0
- package/openspec/changes/cross-platform-build-and-ci/proposal.md +50 -0
- package/openspec/changes/cross-platform-build-and-ci/specs/path-and-file-mediation/spec.md +335 -0
- package/openspec/changes/cross-platform-build-and-ci/specs/release-pipeline/spec.md +95 -0
- package/openspec/changes/cross-platform-build-and-ci/tasks.md +164 -0
- package/openspec/config.yaml +20 -0
- package/openspec/specs/auth/spec.md +394 -0
- package/openspec/specs/cli/spec.md +108 -0
- package/openspec/specs/commands/spec.md +409 -0
- package/openspec/specs/configuration/spec.md +130 -0
- package/openspec/specs/conversations/spec.md +139 -0
- package/openspec/specs/domain-model/spec.md +128 -0
- package/openspec/specs/formatters/spec.md +158 -0
- package/openspec/specs/install-browser/spec.md +146 -0
- package/openspec/specs/interactive-prompt-loop/spec.md +41 -0
- package/openspec/specs/logger/spec.md +71 -0
- package/openspec/specs/mediator/spec.md +72 -0
- package/openspec/specs/multi-profile-conversations/spec.md +145 -0
- package/openspec/specs/path-utils/spec.md +101 -0
- package/openspec/specs/profiles/spec.md +94 -0
- package/openspec/specs/storage/spec.md +198 -0
- package/openspec/specs/testing/spec.md +237 -0
- package/openspec/specs/v2-installer/spec.md +205 -0
- package/openspec/specs/validators/spec.md +65 -0
- package/package.json +45 -0
- package/scripts/build.ts +88 -0
- package/scripts/clean-build.ps1 +4 -0
- package/scripts/clean-build.sh +5 -0
- package/scripts/install-browser.ps1 +5 -0
- package/scripts/install-browser.sh +6 -0
- package/scripts/lint-path-mediation.ps1 +65 -0
- package/scripts/lint-path-mediation.sh +40 -0
- package/src/cli/.gitkeep +0 -0
- package/src/cli/command-registry.ts +62 -0
- package/src/cli/commands/auth-command.ts +179 -0
- package/src/cli/commands/continue-command.ts +158 -0
- package/src/cli/commands/delete-command.ts +163 -0
- package/src/cli/commands/export-all-command.ts +243 -0
- package/src/cli/commands/export-command.ts +156 -0
- package/src/cli/commands/fetch-command.ts +176 -0
- package/src/cli/commands/help.ts +45 -0
- package/src/cli/commands/install-browser-command.ts +33 -0
- package/src/cli/commands/list-command.ts +226 -0
- package/src/cli/commands/new-command.ts +147 -0
- package/src/cli/commands/profile-command.ts +226 -0
- package/src/cli/commands/status-command.ts +59 -0
- package/src/cli/index.ts +213 -0
- package/src/cli/utils/interactive-prompt.ts +64 -0
- package/src/core/.gitkeep +0 -0
- package/src/core/command-handlers.ts +240 -0
- package/src/core/errors.ts +41 -0
- package/src/core/mediator.ts +58 -0
- package/src/core/query-handlers.ts +163 -0
- package/src/core/types.ts +43 -0
- package/src/infrastructure/.gitkeep +0 -0
- package/src/infrastructure/cli-table.ts +45 -0
- package/src/infrastructure/config.ts +52 -0
- package/src/infrastructure/formatters.ts +130 -0
- package/src/infrastructure/io.ts +161 -0
- package/src/infrastructure/logger.ts +41 -0
- package/src/infrastructure/path-utils.ts +157 -0
- package/src/infrastructure/storage.ts +209 -0
- package/src/infrastructure/validators.ts +28 -0
- package/src/services/.gitkeep +0 -0
- package/src/services/auth-service.ts +135 -0
- package/src/services/cookie-monitor.ts +159 -0
- package/src/services/cookie-storage-service.ts +74 -0
- package/src/services/gemini-client-wrapper.ts +239 -0
- package/src/services/install-browser-service.ts +205 -0
- package/src/services/playwright-cli-driver.ts +269 -0
- package/src/services/profile-auth-manager.ts +65 -0
- package/src/services/profile-service.ts +121 -0
- package/tests/cli/auth-command.test.ts +220 -0
- package/tests/cli/command-registry.test.ts +67 -0
- package/tests/cli/continue-command.test.ts +100 -0
- package/tests/cli/delete-command.test.ts +128 -0
- package/tests/cli/export-all-command.test.ts +375 -0
- package/tests/cli/install-browser-command.test.ts +48 -0
- package/tests/cli/list-command.test.ts +179 -0
- package/tests/cli/new-command.test.ts +139 -0
- package/tests/cli/status-command.test.ts +106 -0
- package/tests/core/query-handlers.test.ts +187 -0
- package/tests/fixtures/auth-fixtures.ts +98 -0
- package/tests/fixtures/chat-fixtures.ts +87 -0
- package/tests/infrastructure/cli-table.test.ts +107 -0
- package/tests/infrastructure/formatters.test.ts +202 -0
- package/tests/infrastructure/io.test.ts +197 -0
- package/tests/infrastructure/logger.test.ts +74 -0
- package/tests/infrastructure/storage.test.ts +335 -0
- package/tests/infrastructure/validators.test.ts +67 -0
- package/tests/integration/commands/auth.test.ts +186 -0
- package/tests/integration/commands/continue.test.ts +130 -0
- package/tests/integration/commands/delete.test.ts +140 -0
- package/tests/integration/commands/export.test.ts +266 -0
- package/tests/integration/commands/fetch.test.ts +238 -0
- package/tests/integration/commands/list.test.ts +281 -0
- package/tests/integration/commands/profile.test.ts +258 -0
- package/tests/integration/commands/status.test.ts +223 -0
- package/tests/integration/installer-script-shape.test.ts +67 -0
- package/tests/parity/compare-outputs.ts +359 -0
- package/tests/parity/test-commands-parity.ps1 +82 -0
- package/tests/parity/test-commands-parity.sh +61 -0
- package/tests/services/auth-service.test.ts +315 -0
- package/tests/services/cookie-monitor.test.ts +229 -0
- package/tests/services/cookie-storage-service.test.ts +230 -0
- package/tests/services/gemini-client-wrapper.test.ts +688 -0
- package/tests/services/install-browser-service.test.ts +83 -0
- package/tests/services/playwright-cli-driver.test.ts +468 -0
- package/tests/services/profile-auth-manager.test.ts +334 -0
- package/tests/services/profile-service.test.ts +224 -0
- package/tests/setup.ts +95 -0
- package/tests/smoke/smoke.test.ts +52 -0
- package/tests/unit/config.test.ts +159 -0
- package/tests/unit/errors.test.ts +119 -0
- package/tests/unit/formatters.test.ts +316 -0
- package/tests/unit/mediator.test.ts +152 -0
- package/tests/unit/path-utils.test.ts +289 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Cookie } from "../../src/core/types.ts";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { getTestConfigDir, createTestConfigDir } from "../setup.ts";
|
|
6
|
+
|
|
7
|
+
const MOCK_COOKIE_DEFAULTS: Omit<Cookie, "name" | "value" | "expires"> = {
|
|
8
|
+
domain: ".google.com",
|
|
9
|
+
path: "/",
|
|
10
|
+
httpOnly: true,
|
|
11
|
+
secure: true,
|
|
12
|
+
sameSite: "None",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const FUTURE_EXPIRY = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
|
|
16
|
+
const PAST_EXPIRY = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
|
|
17
|
+
|
|
18
|
+
export interface MockCookiesOptions {
|
|
19
|
+
count?: number;
|
|
20
|
+
names?: string[];
|
|
21
|
+
expiry?: number;
|
|
22
|
+
values?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createMockCookies(options: MockCookiesOptions = {}): Cookie[] {
|
|
26
|
+
const {
|
|
27
|
+
count = 4,
|
|
28
|
+
names,
|
|
29
|
+
expiry = FUTURE_EXPIRY,
|
|
30
|
+
values = {},
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
const defaultNames = [
|
|
34
|
+
"__Secure-1PSID",
|
|
35
|
+
"__Secure-1PSIDTS",
|
|
36
|
+
"__Secure-1PSIDCC",
|
|
37
|
+
"NID",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const cookieNames = names ?? defaultNames.slice(0, count);
|
|
41
|
+
|
|
42
|
+
return cookieNames.map((name) => ({
|
|
43
|
+
name,
|
|
44
|
+
value: values[name] ?? `mock-value-${name}`,
|
|
45
|
+
...MOCK_COOKIE_DEFAULTS,
|
|
46
|
+
expires: expiry,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MockStorageStateOptions {
|
|
51
|
+
cookies?: Cookie[];
|
|
52
|
+
expiry?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createMockStorageState(options: MockStorageStateOptions = {}): object {
|
|
56
|
+
const cookies = options.cookies ?? createMockCookies({ expiry: options.expiry ?? FUTURE_EXPIRY });
|
|
57
|
+
return { cookies };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createExpiredStorageState(): object {
|
|
61
|
+
const cookies = createMockCookies({ expiry: PAST_EXPIRY });
|
|
62
|
+
return { cookies };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MockProfileDirOptions {
|
|
66
|
+
profileName?: string;
|
|
67
|
+
configDir?: string;
|
|
68
|
+
cookies?: Cookie[];
|
|
69
|
+
expired?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mockProfileDir(options: MockProfileDirOptions = {}): string {
|
|
73
|
+
const {
|
|
74
|
+
profileName = "test-profile",
|
|
75
|
+
configDir,
|
|
76
|
+
expired = false,
|
|
77
|
+
} = options;
|
|
78
|
+
|
|
79
|
+
let dir: string;
|
|
80
|
+
try {
|
|
81
|
+
dir = configDir ?? getTestConfigDir();
|
|
82
|
+
} catch {
|
|
83
|
+
dir = createTestConfigDir();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const resolvedDir = resolve(dir);
|
|
87
|
+
const profileDir = join(resolvedDir, "profiles", profileName);
|
|
88
|
+
mkdirSync(profileDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
const state = expired
|
|
91
|
+
? createExpiredStorageState()
|
|
92
|
+
: createMockStorageState({ cookies: options.cookies });
|
|
93
|
+
|
|
94
|
+
const filePath = join(profileDir, "storage_state.json");
|
|
95
|
+
writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
|
|
96
|
+
|
|
97
|
+
return profileDir;
|
|
98
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ChatInfo, Message, Conversation } from "../../src/core/types.ts";
|
|
2
|
+
|
|
3
|
+
export interface MockChatInfoOptions {
|
|
4
|
+
count?: number;
|
|
5
|
+
ids?: string[];
|
|
6
|
+
titles?: string[];
|
|
7
|
+
pinnedIndices?: number[];
|
|
8
|
+
baseTimestamp?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createMockChatList(options: MockChatInfoOptions = {}): ChatInfo[] {
|
|
12
|
+
const {
|
|
13
|
+
count = 3,
|
|
14
|
+
ids,
|
|
15
|
+
titles,
|
|
16
|
+
pinnedIndices = [],
|
|
17
|
+
baseTimestamp = Date.now(),
|
|
18
|
+
} = options;
|
|
19
|
+
|
|
20
|
+
const defaultIds = ["conv-abc123", "conv-def456", "conv-ghi789", "conv-jkl012", "conv-mno345"];
|
|
21
|
+
const defaultTitles = [
|
|
22
|
+
"Chat about TypeScript",
|
|
23
|
+
"Pinned conversation",
|
|
24
|
+
"Bun runtime overview",
|
|
25
|
+
"Testing strategies",
|
|
26
|
+
"Deployment options",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
30
|
+
id: ids?.[i] ?? defaultIds[i] ?? `conv-${i}`,
|
|
31
|
+
title: titles?.[i] ?? defaultTitles[i] ?? `Untitled chat ${i + 1}`,
|
|
32
|
+
isPinned: pinnedIndices.includes(i),
|
|
33
|
+
timestamp: baseTimestamp - i * 86400000,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MockMessageOptions {
|
|
38
|
+
count?: number;
|
|
39
|
+
conversationId?: string;
|
|
40
|
+
roles?: Array<"user" | "model">;
|
|
41
|
+
contents?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createMockMessageHistory(options: MockMessageOptions = {}): Message[] {
|
|
45
|
+
const {
|
|
46
|
+
count = 4,
|
|
47
|
+
conversationId,
|
|
48
|
+
roles,
|
|
49
|
+
contents,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
const defaultRoles: Array<"user" | "model"> = ["user", "model", "user", "model"];
|
|
53
|
+
const defaultContents = [
|
|
54
|
+
"Hello, Gemini!",
|
|
55
|
+
"Hi there! How can I help you today?",
|
|
56
|
+
"Tell me about TypeScript.",
|
|
57
|
+
"TypeScript is a typed superset of JavaScript.",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
61
|
+
role: roles?.[i] ?? defaultRoles[i % defaultRoles.length],
|
|
62
|
+
content: contents?.[i] ?? defaultContents[i % defaultContents.length],
|
|
63
|
+
...(conversationId ? { conversationId } : {}),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MockConversationOptions {
|
|
68
|
+
id?: string;
|
|
69
|
+
title?: string;
|
|
70
|
+
messages?: Message[];
|
|
71
|
+
messageCount?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createMockConversation(options: MockConversationOptions = {}): Conversation {
|
|
75
|
+
const {
|
|
76
|
+
id = "conv-abc123",
|
|
77
|
+
title = "Chat about TypeScript",
|
|
78
|
+
messages,
|
|
79
|
+
messageCount = 4,
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
title,
|
|
85
|
+
messages: messages ?? createMockMessageHistory({ count: messageCount, conversationId: id }),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { renderTable, type ColumnDef } from "../../src/infrastructure/cli-table.ts";
|
|
3
|
+
|
|
4
|
+
interface Row {
|
|
5
|
+
name: string;
|
|
6
|
+
age: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("renderTable", () => {
|
|
10
|
+
test("returns dim emptyMessage for empty rows", () => {
|
|
11
|
+
const out = renderTable<Row>({
|
|
12
|
+
columns: [
|
|
13
|
+
{ header: "Name", width: 10, cell: (r) => r.name },
|
|
14
|
+
{ header: "Age", width: 5, cell: (r) => String(r.age) },
|
|
15
|
+
],
|
|
16
|
+
rows: [],
|
|
17
|
+
emptyMessage: "Nothing here",
|
|
18
|
+
});
|
|
19
|
+
expect(out).toContain("Nothing here");
|
|
20
|
+
expect(out).not.toContain("Name");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("renders header row with column names", () => {
|
|
24
|
+
const out = renderTable<Row>({
|
|
25
|
+
columns: [
|
|
26
|
+
{ header: "Name", width: 10, cell: (r) => r.name },
|
|
27
|
+
{ header: "Age", width: 5, cell: (r) => String(r.age) },
|
|
28
|
+
],
|
|
29
|
+
rows: [{ name: "Alice", age: 30 }],
|
|
30
|
+
});
|
|
31
|
+
expect(out).toContain("Name");
|
|
32
|
+
expect(out).toContain("Age");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("renders data rows with cell values", () => {
|
|
36
|
+
const out = renderTable<Row>({
|
|
37
|
+
columns: [
|
|
38
|
+
{ header: "Name", width: 10, cell: (r) => r.name },
|
|
39
|
+
{ header: "Age", width: 5, cell: (r) => String(r.age) },
|
|
40
|
+
],
|
|
41
|
+
rows: [
|
|
42
|
+
{ name: "Alice", age: 30 },
|
|
43
|
+
{ name: "Bob", age: 25 },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
expect(out).toContain("Alice");
|
|
47
|
+
expect(out).toContain("30");
|
|
48
|
+
expect(out).toContain("Bob");
|
|
49
|
+
expect(out).toContain("25");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("includes Unicode box-drawing borders", () => {
|
|
53
|
+
const out = renderTable<Row>({
|
|
54
|
+
columns: [
|
|
55
|
+
{ header: "Name", width: 10, cell: (r) => r.name },
|
|
56
|
+
{ header: "Age", width: 5, cell: (r) => String(r.age) },
|
|
57
|
+
],
|
|
58
|
+
rows: [{ name: "Alice", age: 30 }],
|
|
59
|
+
});
|
|
60
|
+
expect(out).toMatch(/[\u2500-\u257f]/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("appends footer when provided", () => {
|
|
64
|
+
const out = renderTable<Row>({
|
|
65
|
+
columns: [
|
|
66
|
+
{ header: "Name", width: 10, cell: (r) => r.name },
|
|
67
|
+
{ header: "Age", width: 5, cell: (r) => String(r.age) },
|
|
68
|
+
],
|
|
69
|
+
rows: [{ name: "Alice", age: 30 }],
|
|
70
|
+
footer: "Total: 1",
|
|
71
|
+
});
|
|
72
|
+
expect(out).toContain("Total: 1");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("does not crash with zero columns", () => {
|
|
76
|
+
const out = renderTable<Row>({
|
|
77
|
+
columns: [],
|
|
78
|
+
rows: [{ name: "Alice", age: 30 }],
|
|
79
|
+
});
|
|
80
|
+
expect(typeof out).toBe("string");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("handles zero rows with no emptyMessage", () => {
|
|
84
|
+
const out = renderTable<Row>({
|
|
85
|
+
columns: [
|
|
86
|
+
{ header: "Name", width: 10, cell: (r) => r.name },
|
|
87
|
+
{ header: "Age", width: 5, cell: (r) => String(r.age) },
|
|
88
|
+
],
|
|
89
|
+
rows: [],
|
|
90
|
+
});
|
|
91
|
+
expect(out).toContain("Name");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("respects optional PROFILE-style spread-in column", () => {
|
|
95
|
+
const includeExtra = true;
|
|
96
|
+
const columns: ColumnDef<Row & { extra?: string }>[] = [
|
|
97
|
+
{ header: "Name", width: 10, cell: (r) => r.name },
|
|
98
|
+
...(includeExtra ? [{ header: "Extra", width: 8, cell: (r: Row & { extra?: string }) => r.extra ?? "" }] : []),
|
|
99
|
+
];
|
|
100
|
+
const out = renderTable({
|
|
101
|
+
columns,
|
|
102
|
+
rows: [{ name: "Alice", age: 30, extra: "yes" }],
|
|
103
|
+
});
|
|
104
|
+
expect(out).toContain("Extra");
|
|
105
|
+
expect(out).toContain("yes");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
formatChatAsMarkdown,
|
|
4
|
+
formatChatAsJson,
|
|
5
|
+
formatProfileTable,
|
|
6
|
+
formatChatList,
|
|
7
|
+
} from "../../src/infrastructure/formatters.ts";
|
|
8
|
+
import type { Message, ChatInfo, ProfileStatus } from "../../src/core/types.ts";
|
|
9
|
+
|
|
10
|
+
function makeMessages(): Message[] {
|
|
11
|
+
return [
|
|
12
|
+
{ role: "user", content: "Hello, Gemini!" },
|
|
13
|
+
{ role: "model", content: "Hi there! How can I help you today?" },
|
|
14
|
+
{ role: "user", content: "Tell me about TypeScript." },
|
|
15
|
+
{ role: "model", content: "TypeScript is a typed superset of JavaScript." },
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeChats(): ChatInfo[] {
|
|
20
|
+
return [
|
|
21
|
+
{ id: "abc123", title: "Chat about TypeScript", isPinned: false, timestamp: Date.now() },
|
|
22
|
+
{ id: "def456", title: "Pinned conversation", isPinned: true, timestamp: Date.now() - 86400000 },
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeStatuses(): ProfileStatus[] {
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
name: "default",
|
|
30
|
+
exists: true,
|
|
31
|
+
isActive: true,
|
|
32
|
+
expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(),
|
|
33
|
+
isDefault: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "expired-profile",
|
|
37
|
+
exists: true,
|
|
38
|
+
isActive: false,
|
|
39
|
+
expiresAt: new Date(Date.now() - 86400000).toISOString(),
|
|
40
|
+
isDefault: false,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "missing-cookies",
|
|
44
|
+
exists: true,
|
|
45
|
+
isActive: false,
|
|
46
|
+
expiresAt: null,
|
|
47
|
+
isDefault: false,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("formatChatAsMarkdown", () => {
|
|
53
|
+
test("renders messages with proper markdown headers", () => {
|
|
54
|
+
const messages = makeMessages();
|
|
55
|
+
const result = formatChatAsMarkdown(messages, "Test Chat");
|
|
56
|
+
|
|
57
|
+
expect(result).toContain("# Test Chat");
|
|
58
|
+
expect(result).toContain("**You:**");
|
|
59
|
+
expect(result).toContain("**Gemini:**");
|
|
60
|
+
expect(result).toContain("Hello, Gemini!");
|
|
61
|
+
expect(result).toContain("TypeScript is a typed superset of JavaScript.");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("includes separator lines between messages", () => {
|
|
65
|
+
const result = formatChatAsMarkdown(makeMessages(), "Title");
|
|
66
|
+
const separatorCount = (result.match(/^---$/gm) ?? []).length;
|
|
67
|
+
expect(separatorCount).toBe(4);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("includes metadata when includeMetadata is true and conversationId provided", () => {
|
|
71
|
+
const result = formatChatAsMarkdown(
|
|
72
|
+
makeMessages(),
|
|
73
|
+
"Meta Chat",
|
|
74
|
+
"conv-xyz",
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(result).toContain("> Conversation ID: conv-xyz");
|
|
79
|
+
expect(result).toContain("> Messages: 4");
|
|
80
|
+
expect(result).toContain("> Exported:");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("omits metadata when includeMetadata is false", () => {
|
|
84
|
+
const result = formatChatAsMarkdown(
|
|
85
|
+
makeMessages(),
|
|
86
|
+
"No Meta",
|
|
87
|
+
"conv-xyz",
|
|
88
|
+
false,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(result).not.toContain("> Conversation ID:");
|
|
92
|
+
expect(result).not.toContain("> Messages:");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("handles empty messages array", () => {
|
|
96
|
+
const result = formatChatAsMarkdown([], "Empty Chat");
|
|
97
|
+
expect(result).toContain("# Empty Chat");
|
|
98
|
+
expect(result).not.toContain("**You:**");
|
|
99
|
+
expect(result).not.toContain("**Gemini:**");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("formatChatAsJson", () => {
|
|
104
|
+
test("returns valid JSON with conversationId and messages", () => {
|
|
105
|
+
const messages = makeMessages();
|
|
106
|
+
const result = formatChatAsJson(messages, "conv-123");
|
|
107
|
+
|
|
108
|
+
const parsed = JSON.parse(result);
|
|
109
|
+
expect(parsed.conversationId).toBe("conv-123");
|
|
110
|
+
expect(parsed.messages).toHaveLength(4);
|
|
111
|
+
expect(parsed.messages[0].role).toBe("user");
|
|
112
|
+
expect(parsed.messages[0].content).toBe("Hello, Gemini!");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("handles empty messages array", () => {
|
|
116
|
+
const result = formatChatAsJson([], "conv-empty");
|
|
117
|
+
const parsed = JSON.parse(result);
|
|
118
|
+
expect(parsed.conversationId).toBe("conv-empty");
|
|
119
|
+
expect(parsed.messages).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("formatProfileTable", () => {
|
|
124
|
+
test("renders profile statuses in table format", () => {
|
|
125
|
+
const statuses = makeStatuses();
|
|
126
|
+
const result = formatProfileTable(statuses);
|
|
127
|
+
|
|
128
|
+
expect(result).toContain("NAME");
|
|
129
|
+
expect(result).toContain("ACTIVE");
|
|
130
|
+
expect(result).toContain("EXPIRES");
|
|
131
|
+
expect(result).toContain("DEFAULT");
|
|
132
|
+
expect(result).toContain("default");
|
|
133
|
+
expect(result).toContain("expired-profile");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("shows active checkmark for active profile", () => {
|
|
137
|
+
const statuses = makeStatuses();
|
|
138
|
+
const result = formatProfileTable(statuses);
|
|
139
|
+
expect(result).toContain("\u2713");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("shows inactive X for inactive profile", () => {
|
|
143
|
+
const statuses = makeStatuses();
|
|
144
|
+
const result = formatProfileTable(statuses);
|
|
145
|
+
expect(result).toContain("\u2717");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("shows default marker note", () => {
|
|
149
|
+
const result = formatProfileTable(makeStatuses());
|
|
150
|
+
expect(result).toContain("default profile");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("returns message when no profiles exist", () => {
|
|
154
|
+
const result = formatProfileTable([]);
|
|
155
|
+
expect(result).toContain("No profiles found");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("shows N/A for missing expiry date", () => {
|
|
159
|
+
const statuses: ProfileStatus[] = [
|
|
160
|
+
{
|
|
161
|
+
name: "no-expiry",
|
|
162
|
+
exists: true,
|
|
163
|
+
isActive: true,
|
|
164
|
+
expiresAt: null,
|
|
165
|
+
isDefault: false,
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
const result = formatProfileTable(statuses);
|
|
169
|
+
expect(result).toContain("no-expiry");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("formatChatList", () => {
|
|
174
|
+
test("renders chat list in table format", () => {
|
|
175
|
+
const chats = makeChats();
|
|
176
|
+
const result = formatChatList(chats);
|
|
177
|
+
|
|
178
|
+
expect(result).toContain("ID");
|
|
179
|
+
expect(result).toContain("TITLE");
|
|
180
|
+
expect(result).toContain("DATE");
|
|
181
|
+
expect(result).toContain("abc123");
|
|
182
|
+
expect(result).toContain("Chat about TypeScript");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("shows total count footer", () => {
|
|
186
|
+
const result = formatChatList(makeChats());
|
|
187
|
+
expect(result).toContain("Total: 2 conversations");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("shows singular when only one chat", () => {
|
|
191
|
+
const chats: ChatInfo[] = [
|
|
192
|
+
{ id: "solo", title: "Solo chat", isPinned: false, timestamp: Date.now() },
|
|
193
|
+
];
|
|
194
|
+
const result = formatChatList(chats);
|
|
195
|
+
expect(result).toContain("Total: 1 conversation");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("returns message when no chats exist", () => {
|
|
199
|
+
const result = formatChatList([]);
|
|
200
|
+
expect(result).toContain("No conversations found");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
ensureDir,
|
|
6
|
+
existsFile,
|
|
7
|
+
readTextFile,
|
|
8
|
+
safeReadTextFile,
|
|
9
|
+
writeTextFile,
|
|
10
|
+
readJsonFile,
|
|
11
|
+
writeJsonFile,
|
|
12
|
+
removeDir,
|
|
13
|
+
renameDir,
|
|
14
|
+
isDirectory,
|
|
15
|
+
listSubdirectories,
|
|
16
|
+
IOError,
|
|
17
|
+
} from "../../src/infrastructure/io.ts";
|
|
18
|
+
|
|
19
|
+
const TEST_ROOT = join(tmpdir(), `gemiterm-io-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
ensureDir(TEST_ROOT);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
try {
|
|
27
|
+
removeDir(TEST_ROOT);
|
|
28
|
+
} catch {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("IOError", () => {
|
|
34
|
+
test("has correct name and message", () => {
|
|
35
|
+
const err = new IOError("boom");
|
|
36
|
+
expect(err.name).toBe("IOError");
|
|
37
|
+
expect(err.message).toBe("boom");
|
|
38
|
+
expect(err.cause).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("preserves cause", () => {
|
|
42
|
+
const cause = new Error("underlying");
|
|
43
|
+
const err = new IOError("wrapper", cause);
|
|
44
|
+
expect(err.cause).toBe(cause);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("ensureDir", () => {
|
|
49
|
+
test("creates a directory that does not exist", () => {
|
|
50
|
+
const dir = join(TEST_ROOT, "new-dir");
|
|
51
|
+
expect(existsFile(dir)).toBe(false);
|
|
52
|
+
ensureDir(dir);
|
|
53
|
+
expect(existsFile(dir)).toBe(true);
|
|
54
|
+
expect(isDirectory(dir)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("creates nested directories recursively", () => {
|
|
58
|
+
const dir = join(TEST_ROOT, "a", "b", "c");
|
|
59
|
+
expect(existsFile(dir)).toBe(false);
|
|
60
|
+
ensureDir(dir);
|
|
61
|
+
expect(isDirectory(dir)).toBe(true);
|
|
62
|
+
expect(isDirectory(join(TEST_ROOT, "a", "b"))).toBe(true);
|
|
63
|
+
expect(isDirectory(join(TEST_ROOT, "a"))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("is a no-op on an existing directory", () => {
|
|
67
|
+
ensureDir(TEST_ROOT);
|
|
68
|
+
expect(() => ensureDir(TEST_ROOT)).not.toThrow();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("existsFile", () => {
|
|
73
|
+
test("returns true for an existing file", () => {
|
|
74
|
+
const file = join(TEST_ROOT, "marker.txt");
|
|
75
|
+
ensureDir(TEST_ROOT);
|
|
76
|
+
writeTextFile(file, "x");
|
|
77
|
+
expect(existsFile(file)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("returns false for a missing file", () => {
|
|
81
|
+
expect(existsFile(join(TEST_ROOT, "nope.txt"))).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("readTextFile", () => {
|
|
86
|
+
test("reads an existing file as UTF-8", () => {
|
|
87
|
+
const file = join(TEST_ROOT, "read.txt");
|
|
88
|
+
writeTextFile(file, "hello");
|
|
89
|
+
expect(readTextFile(file)).toBe("hello");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("throws IOError on a missing file", () => {
|
|
93
|
+
expect(() => readTextFile(join(TEST_ROOT, "nope.txt"))).toThrow(IOError);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("safeReadTextFile", () => {
|
|
98
|
+
test("returns the content of an existing file", () => {
|
|
99
|
+
const file = join(TEST_ROOT, "safe.txt");
|
|
100
|
+
writeTextFile(file, "content");
|
|
101
|
+
expect(safeReadTextFile(file)).toBe("content");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns the empty string on a missing file", () => {
|
|
105
|
+
expect(safeReadTextFile(join(TEST_ROOT, "nope.txt"))).toBe("");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("writeTextFile", () => {
|
|
110
|
+
test("writes a new file", () => {
|
|
111
|
+
const file = join(TEST_ROOT, "out.txt");
|
|
112
|
+
writeTextFile(file, "data");
|
|
113
|
+
expect(existsFile(file)).toBe(true);
|
|
114
|
+
expect(readTextFile(file)).toBe("data");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("creates nested directories that do not exist", () => {
|
|
118
|
+
const file = join(TEST_ROOT, "nested", "deep", "out.txt");
|
|
119
|
+
writeTextFile(file, "data");
|
|
120
|
+
expect(readTextFile(file)).toBe("data");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("readJsonFile and writeJsonFile", () => {
|
|
125
|
+
test("round-trips a JSON object", () => {
|
|
126
|
+
const file = join(TEST_ROOT, "obj.json");
|
|
127
|
+
const original = { a: 1, b: ["x", "y"], c: { nested: true } };
|
|
128
|
+
writeJsonFile(file, original);
|
|
129
|
+
const read = readJsonFile<typeof original>(file);
|
|
130
|
+
expect(read).toEqual(original);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("readJsonFile throws on missing file", () => {
|
|
134
|
+
expect(() => readJsonFile(join(TEST_ROOT, "missing.json"))).toThrow(IOError);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("removeDir", () => {
|
|
139
|
+
test("removes a directory recursively", () => {
|
|
140
|
+
const dir = join(TEST_ROOT, "rm");
|
|
141
|
+
ensureDir(dir);
|
|
142
|
+
writeTextFile(join(dir, "f.txt"), "x");
|
|
143
|
+
expect(existsFile(dir)).toBe(true);
|
|
144
|
+
removeDir(dir);
|
|
145
|
+
expect(existsFile(dir)).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("is a no-op on a missing path", () => {
|
|
149
|
+
expect(() => removeDir(join(TEST_ROOT, "nope"))).not.toThrow();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("renameDir", () => {
|
|
154
|
+
test("renames a directory", () => {
|
|
155
|
+
const src = join(TEST_ROOT, "src");
|
|
156
|
+
const dest = join(TEST_ROOT, "dest");
|
|
157
|
+
ensureDir(src);
|
|
158
|
+
expect(isDirectory(src)).toBe(true);
|
|
159
|
+
renameDir(src, dest);
|
|
160
|
+
expect(isDirectory(src)).toBe(false);
|
|
161
|
+
expect(isDirectory(dest)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("throws when source is missing", () => {
|
|
165
|
+
expect(() => renameDir(join(TEST_ROOT, "nope"), join(TEST_ROOT, "dest"))).toThrow(IOError);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("isDirectory", () => {
|
|
170
|
+
test("returns true for a directory", () => {
|
|
171
|
+
expect(isDirectory(TEST_ROOT)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("returns false for a file", () => {
|
|
175
|
+
const file = join(TEST_ROOT, "f.txt");
|
|
176
|
+
writeTextFile(file, "x");
|
|
177
|
+
expect(isDirectory(file)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("returns false for a missing path", () => {
|
|
181
|
+
expect(isDirectory(join(TEST_ROOT, "nope"))).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("listSubdirectories", () => {
|
|
186
|
+
test("returns only subdirectory names", () => {
|
|
187
|
+
ensureDir(join(TEST_ROOT, "a"));
|
|
188
|
+
ensureDir(join(TEST_ROOT, "b"));
|
|
189
|
+
writeTextFile(join(TEST_ROOT, "not-a-dir.txt"), "x");
|
|
190
|
+
const subs = listSubdirectories(TEST_ROOT).sort();
|
|
191
|
+
expect(subs).toEqual(["a", "b"]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("returns an empty array for a missing path", () => {
|
|
195
|
+
expect(listSubdirectories(join(TEST_ROOT, "nope"))).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
});
|