fix: refine regex patterns and improve utility functions for mention handling
- Updated regex patterns in chipUtils and mentionLinkify to enhance boundary detection for mentions. - Refactored taskChangeRequest to simplify earliest date calculation using array destructuring. - Improved taskReferenceUtils by replacing character boundary checks with a more concise regex. - Enhanced teamMessageFiltering to ensure boolean checks for message filtering conditions. - Adjusted urlMatchUtils to refine URL matching regex for better accuracy. - Updated crossTeam constants to include comments for regex patterns, improving code clarity. - Removed unused CommentAttachmentPayload type from api.ts to clean up type definitions. - Introduced McpInstallScope type for better type safety in mcp.ts. - Enhanced extensionNormalizers to improve URL normalization and added tests for parseGitHubOwnerRepo function. - Cleaned up pricing.ts by removing unnecessary eslint disable comments. - Added tests for new functionality in chipUtils and crossTeam constants, ensuring robust coverage.
This commit is contained in:
parent
08f22121c6
commit
bec8a6184a
20 changed files with 558 additions and 23 deletions
105
electron.vite.config.1773920150269.mjs
Normal file
105
electron.vite.config.1773920150269.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// electron.vite.config.ts
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team";
|
||||
var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8"));
|
||||
var prodDeps = Object.keys(pkg.dependencies || {});
|
||||
var bundledDeps = prodDeps.filter((d) => d !== "node-pty" && d !== "agent-teams-controller");
|
||||
function nativeModuleStub() {
|
||||
const STUB_ID = "\0native-stub";
|
||||
return {
|
||||
name: "native-module-stub",
|
||||
resolveId(source) {
|
||||
if (source.endsWith(".node")) return STUB_ID;
|
||||
return null;
|
||||
},
|
||||
load(id) {
|
||||
if (id === STUB_ID) return "export default {}";
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
var electron_vite_config_default = defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
externalizeDepsPlugin({
|
||||
exclude: bundledDeps
|
||||
}),
|
||||
nativeModuleStub()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
|
||||
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||
"@preload": resolve(__electron_vite_injected_dirname, "src/preload")
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "dist-electron/main",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"),
|
||||
"team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts")
|
||||
},
|
||||
output: {
|
||||
// CJS format so bundled deps can use __dirname/require.
|
||||
// Use .cjs extension since package.json has "type": "module".
|
||||
format: "cjs",
|
||||
entryFileNames: "[name].cjs",
|
||||
// Set UV_THREADPOOL_SIZE before any module code runs.
|
||||
// Must be in the banner because ESM→CJS hoists imports above top-level code.
|
||||
// On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher;
|
||||
// with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock.
|
||||
banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@preload": resolve(__electron_vite_injected_dirname, "src/preload"),
|
||||
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||
"@main": resolve(__electron_vite_injected_dirname, "src/main")
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "dist-electron/preload",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts")
|
||||
},
|
||||
output: {
|
||||
format: "cjs",
|
||||
entryFileNames: "[name].js"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
optimizeDeps: {
|
||||
include: ["@codemirror/language-data"]
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"),
|
||||
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||
"@main": resolve(__electron_vite_injected_dirname, "src/main")
|
||||
}
|
||||
},
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
export {
|
||||
electron_vite_config_default as default
|
||||
};
|
||||
|
|
@ -317,8 +317,7 @@ export function calculateMentionPositions(
|
|||
// Character after name must be boundary
|
||||
if (end < text.length) {
|
||||
const after = text[end];
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue;
|
||||
if (!/[\s,.:;!?)\]}-]/.test(after)) continue;
|
||||
}
|
||||
matches.push({ item: suggestion, start: i, end, token: text.slice(i, end) });
|
||||
i = end;
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ export function linkifyMentionsInMarkdown(
|
|||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(
|
||||
// eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp
|
||||
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
||||
return text.replace(pattern, (_match: string, prefix: string, name: string) => {
|
||||
// Find the canonical name (case-insensitive lookup)
|
||||
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
const color = memberColorMap.get(canonical) ?? '';
|
||||
|
|
@ -49,18 +50,19 @@ export function linkifyTeamMentionsInMarkdown(
|
|||
text: string,
|
||||
teamNames: ReadonlySet<string> | readonly string[]
|
||||
): string {
|
||||
const names = Array.isArray(teamNames) ? teamNames : [...teamNames];
|
||||
const names: readonly string[] = Array.isArray(teamNames) ? teamNames : [...teamNames];
|
||||
if (names.length === 0) return text;
|
||||
|
||||
// Sort by name length descending for greedy matching
|
||||
const sorted = [...names].sort((a, b) => b.length - a.length);
|
||||
const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(
|
||||
// eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp
|
||||
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
return text.replace(pattern, (_match, prefix: string, name: string) => {
|
||||
return text.replace(pattern, (_match: string, prefix: string, name: string) => {
|
||||
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undef
|
|||
}
|
||||
if (sources.length === 0) return undefined;
|
||||
|
||||
const earliest = sources.reduce((a, b) => (a < b ? a : b));
|
||||
const [first, ...rest] = sources;
|
||||
const earliest = rest.reduce((a, b) => (a < b ? a : b), first);
|
||||
const date = new Date(earliest);
|
||||
date.setTime(date.getTime() - TASK_SINCE_GRACE_MS);
|
||||
return date.toISOString();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const ZERO_WIDTH_TO_BITS = new Map(
|
|||
|
||||
function isAllowedTaskRefBoundary(char: string | undefined): boolean {
|
||||
if (!char) return true;
|
||||
return !/[A-Za-z0-9_]/.test(char);
|
||||
return !/\w/.test(char);
|
||||
}
|
||||
|
||||
function buildSuggestionsByRef(
|
||||
|
|
|
|||
|
|
@ -33,14 +33,14 @@ export function filterTeamMessages(
|
|||
const hasTo = filter.to.size > 0;
|
||||
if (hasFrom && hasTo) {
|
||||
list = list.filter((m) => {
|
||||
const fromMatch = m.from?.trim() && filter.from.has(m.from.trim());
|
||||
const toMatch = m.to?.trim() && filter.to.has(m.to.trim());
|
||||
const fromMatch = Boolean(m.from?.trim() && filter.from.has(m.from.trim()));
|
||||
const toMatch = Boolean(m.to?.trim() && filter.to.has(m.to.trim()));
|
||||
return fromMatch && toMatch;
|
||||
});
|
||||
} else if (hasFrom || hasTo) {
|
||||
list = list.filter((m) => {
|
||||
if (hasFrom) return m.from?.trim() && filter.from.has(m.from.trim());
|
||||
if (hasTo) return m.to?.trim() && filter.to.has(m.to.trim());
|
||||
if (hasFrom) return Boolean(m.from?.trim() && filter.from.has(m.from.trim()));
|
||||
if (hasTo) return Boolean(m.to?.trim() && filter.to.has(m.to.trim()));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ export interface TextMatch {
|
|||
value: string;
|
||||
}
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||
const URL_REGEX = /https?:\/\/\S+/g;
|
||||
|
||||
function trimUrlMatch(rawUrl: string): string {
|
||||
// eslint-disable-next-line sonarjs/slow-regex -- trailing punctuation only, input bounded
|
||||
return rawUrl.replace(/[),.!?;:]+$/g, '');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ function unescapeCrossTeamAttribute(value: string): string {
|
|||
|
||||
function parseCrossTeamAttributes(raw: string): Map<string, string> {
|
||||
const attrs = new Map<string, string>();
|
||||
const matches = raw.matchAll(/([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g);
|
||||
const matches = raw.matchAll(
|
||||
/* eslint-disable-next-line sonarjs/slow-regex -- attr values bounded by quotes, trusted prefix input */
|
||||
/([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g
|
||||
);
|
||||
for (const match of matches) {
|
||||
const key = match[1]?.trim();
|
||||
const value = match[2];
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import type {
|
|||
AddMemberRequest,
|
||||
AddTaskCommentRequest,
|
||||
AttachmentFileData,
|
||||
CommentAttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
CrossTeamMessage,
|
||||
CrossTeamSendRequest,
|
||||
|
|
|
|||
|
|
@ -100,10 +100,12 @@ export interface McpServerDiagnostic {
|
|||
|
||||
// ── Install request (renderer → main, minimal trusted data) ────────────────
|
||||
|
||||
export type McpInstallScope = 'local' | 'user' | 'project';
|
||||
|
||||
export interface McpInstallRequest {
|
||||
registryId: string; // server ID from registry (NOT full catalog item)
|
||||
serverName: string; // user-chosen name for `claude mcp add`
|
||||
scope: 'local' | 'user' | 'project';
|
||||
scope: McpInstallScope;
|
||||
projectPath?: string; // required for 'project' scope
|
||||
envValues: Record<string, string>;
|
||||
headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag)
|
||||
|
|
@ -113,7 +115,7 @@ export interface McpInstallRequest {
|
|||
|
||||
export interface McpCustomInstallRequest {
|
||||
serverName: string;
|
||||
scope: 'local' | 'user' | 'project';
|
||||
scope: McpInstallScope;
|
||||
projectPath?: string;
|
||||
installSpec: McpInstallSpec; // user provides directly
|
||||
envValues: Record<string, string>;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ export function normalizeRepoUrl(url: string): string {
|
|||
return url
|
||||
.toLowerCase()
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/\/+$/, '');
|
||||
.replace(
|
||||
/* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, URL length bounded */
|
||||
/\/+$/,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -112,7 +116,11 @@ export function parseGitHubOwnerRepo(url: string): { owner: string; repo: string
|
|||
const parts = parsed.pathname
|
||||
.replace(/^\//, '')
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/\/+$/, '')
|
||||
.replace(
|
||||
/* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, pathname bounded */
|
||||
/\/+$/,
|
||||
''
|
||||
)
|
||||
.split('/');
|
||||
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
||||
return { owner: parts[0], repo: parts[1] };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// eslint-disable-next-line no-restricted-imports -- resources/ is outside src/, no alias available
|
||||
import pricingData from '../../../resources/pricing.json';
|
||||
|
||||
export interface LiteLLMPricing {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export function parseToolSummary(summary: string | undefined): ToolSummaryData |
|
|||
if (!match) return null;
|
||||
const byName: Record<string, number> = {};
|
||||
for (const part of match[2].split(', ')) {
|
||||
const m = /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part);
|
||||
const m =
|
||||
// eslint-disable-next-line security/detect-unsafe-regex -- part from split, bounded by summary
|
||||
/^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part);
|
||||
if (m) {
|
||||
byName[m[2]] = parseInt(m[1], 10);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import { chipToken } from '@renderer/types/inlineChip';
|
||||
import {
|
||||
calculateMentionPositions,
|
||||
createChipFromSelection,
|
||||
findChipBoundary,
|
||||
isInsideChip,
|
||||
|
|
@ -251,3 +252,54 @@ describe('removeChipTokenFromText', () => {
|
|||
expect(removeChipTokenFromText(text, chip)).toBe('A\nB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateMentionPositions boundary regex', () => {
|
||||
function makeTextarea(): HTMLTextAreaElement {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.style.cssText = 'font:16px monospace;width:400px;height:100px';
|
||||
document.body.appendChild(ta);
|
||||
return ta;
|
||||
}
|
||||
|
||||
function makeMemberSuggestion(name: string) {
|
||||
return { id: name, name, type: 'member' as const };
|
||||
}
|
||||
|
||||
it('matches @mention when char after is boundary: space, comma, dot', () => {
|
||||
const ta = makeTextarea();
|
||||
const suggestions = [makeMemberSuggestion('Alice')];
|
||||
expect(calculateMentionPositions(ta, '@Alice ', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice,', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice.', suggestions)).toHaveLength(1);
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
|
||||
it('matches @mention when char after is boundary: colon, semicolon, bang, question', () => {
|
||||
const ta = makeTextarea();
|
||||
const suggestions = [makeMemberSuggestion('Alice')];
|
||||
expect(calculateMentionPositions(ta, '@Alice:', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice;', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice!', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice?', suggestions)).toHaveLength(1);
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
|
||||
it('matches @mention when char after is boundary: ), ], }, -', () => {
|
||||
const ta = makeTextarea();
|
||||
const suggestions = [makeMemberSuggestion('Alice')];
|
||||
expect(calculateMentionPositions(ta, '@Alice)', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice]', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice}', suggestions)).toHaveLength(1);
|
||||
expect(calculateMentionPositions(ta, '@Alice-', suggestions)).toHaveLength(1);
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
|
||||
it('does NOT match @mention when char after is word char (letter, digit)', () => {
|
||||
const ta = makeTextarea();
|
||||
const suggestions = [makeMemberSuggestion('Alice')];
|
||||
expect(calculateMentionPositions(ta, '@Alicex', suggestions)).toHaveLength(0);
|
||||
expect(calculateMentionPositions(ta, '@Alice1', suggestions)).toHaveLength(0);
|
||||
expect(calculateMentionPositions(ta, '@Alice_', suggestions)).toHaveLength(0);
|
||||
document.body.removeChild(ta);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
65
test/renderer/utils/mentionLinkify.test.ts
Normal file
65
test/renderer/utils/mentionLinkify.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
linkifyAllMentionsInMarkdown,
|
||||
linkifyMentionsInMarkdown,
|
||||
linkifyTeamMentionsInMarkdown,
|
||||
} from '@renderer/utils/mentionLinkify';
|
||||
|
||||
describe('mentionLinkify', () => {
|
||||
it('linkifies @member after space', () => {
|
||||
const m = new Map([['Alice', 'blue']]);
|
||||
const r = linkifyMentionsInMarkdown('hello @Alice world', m);
|
||||
expect(r).toContain('mention://');
|
||||
expect(r).toContain('Alice');
|
||||
expect(r).not.toBe('hello @Alice world');
|
||||
});
|
||||
|
||||
it('does NOT linkify @ in email', () => {
|
||||
const m = new Map([['Alice', 'blue']]);
|
||||
const r = linkifyMentionsInMarkdown('email@test.com', m);
|
||||
expect(r).toBe('email@test.com');
|
||||
});
|
||||
|
||||
it('linkifies @team after (', () => {
|
||||
const r = linkifyTeamMentionsInMarkdown('(@TeamAlpha)', ['TeamAlpha']);
|
||||
expect(r).toContain('team://');
|
||||
expect(r).toContain('TeamAlpha');
|
||||
});
|
||||
|
||||
it('linkifyAll applies both member and team', () => {
|
||||
const m = new Map([['Alice', 'blue']]);
|
||||
const r = linkifyAllMentionsInMarkdown('Hi @Alice from @TeamX', m, ['TeamX']);
|
||||
expect(r).toContain('mention://');
|
||||
expect(r).toContain('team://');
|
||||
});
|
||||
|
||||
it('linkifies @ after start of string', () => {
|
||||
const m = new Map([['Alice', 'blue']]);
|
||||
const r = linkifyMentionsInMarkdown('@Alice hello', m);
|
||||
expect(r).toContain('mention://');
|
||||
});
|
||||
|
||||
it('linkifies @ after [ { (', () => {
|
||||
const m = new Map([['Bob', 'red']]);
|
||||
expect(linkifyMentionsInMarkdown('[@Bob]', m)).toContain('mention://');
|
||||
expect(linkifyMentionsInMarkdown('{@Bob}', m)).toContain('mention://');
|
||||
expect(linkifyMentionsInMarkdown('(@Bob)', m)).toContain('mention://');
|
||||
});
|
||||
|
||||
it('does NOT linkify @ when followed by word char', () => {
|
||||
const m = new Map([['Alice', 'blue']]);
|
||||
expect(linkifyMentionsInMarkdown('@AliceX', m)).toBe('@AliceX');
|
||||
expect(linkifyMentionsInMarkdown('@Alice123', m)).toBe('@Alice123');
|
||||
});
|
||||
|
||||
it('linkifies when followed by boundary: space, comma, dot, ), ], }', () => {
|
||||
const m = new Map([['Alice', 'blue']]);
|
||||
expect(linkifyMentionsInMarkdown('@Alice ', m)).toContain('mention://');
|
||||
expect(linkifyMentionsInMarkdown('@Alice,', m)).toContain('mention://');
|
||||
expect(linkifyMentionsInMarkdown('@Alice.', m)).toContain('mention://');
|
||||
expect(linkifyMentionsInMarkdown('@Alice)', m)).toContain('mention://');
|
||||
expect(linkifyMentionsInMarkdown('@Alice]', m)).toContain('mention://');
|
||||
expect(linkifyMentionsInMarkdown('@Alice}', m)).toContain('mention://');
|
||||
});
|
||||
});
|
||||
70
test/renderer/utils/taskReferenceUtils.test.ts
Normal file
70
test/renderer/utils/taskReferenceUtils.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTaskLinkHref,
|
||||
linkifyTaskIdsInMarkdown,
|
||||
parseTaskLinkHref,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
|
||||
import type { TaskRef } from '@shared/types';
|
||||
|
||||
describe('taskReferenceUtils', () => {
|
||||
describe('TASK_REF_REGEX and isAllowedTaskRefBoundary', () => {
|
||||
it('linkifies #ref when preceded by boundary (space, start)', () => {
|
||||
const taskRef: TaskRef = {
|
||||
taskId: 't1',
|
||||
displayId: 'task-1',
|
||||
teamName: 'my-team',
|
||||
};
|
||||
const r = linkifyTaskIdsInMarkdown('see #task-1 done', [taskRef]);
|
||||
expect(r).toContain('task://');
|
||||
expect(r).toContain('[#task-1]');
|
||||
});
|
||||
|
||||
it('does NOT linkify #ref when preceded by word char', () => {
|
||||
const taskRef: TaskRef = {
|
||||
taskId: 't1',
|
||||
displayId: 'task1',
|
||||
teamName: 'my-team',
|
||||
};
|
||||
const r = linkifyTaskIdsInMarkdown('x#task1', [taskRef]);
|
||||
expect(r).toBe('x#task1');
|
||||
});
|
||||
|
||||
it('linkifies #ref with hyphen in id', () => {
|
||||
const r = linkifyTaskIdsInMarkdown(' #abc-123 ');
|
||||
expect(r).toContain('task://');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTaskLinkHref and parseTaskLinkHref', () => {
|
||||
it('roundtrips task ref', () => {
|
||||
const ref: TaskRef = {
|
||||
taskId: 'tid-1',
|
||||
displayId: 'T-1',
|
||||
teamName: 'team-a',
|
||||
};
|
||||
const href = buildTaskLinkHref(ref);
|
||||
expect(href).toContain('task://');
|
||||
expect(href).toContain('team=');
|
||||
expect(href).toContain('display=');
|
||||
|
||||
const parsed = parseTaskLinkHref(href);
|
||||
expect(parsed).toEqual({
|
||||
taskId: 'tid-1',
|
||||
teamName: 'team-a',
|
||||
displayId: 'T-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('parseTaskLinkHref returns null for non-task URL', () => {
|
||||
expect(parseTaskLinkHref('https://example.com')).toBeNull();
|
||||
expect(parseTaskLinkHref('mention://x')).toBeNull();
|
||||
});
|
||||
|
||||
it('parseTaskLinkHref handles task:// without query', () => {
|
||||
const r = parseTaskLinkHref('task://tid-1');
|
||||
expect(r).toEqual({ taskId: 'tid-1' });
|
||||
});
|
||||
});
|
||||
});
|
||||
84
test/renderer/utils/urlMatchUtils.test.ts
Normal file
84
test/renderer/utils/urlMatchUtils.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
findUrlBoundary,
|
||||
findUrlMatches,
|
||||
removeUrlMatchFromText,
|
||||
} from '@renderer/utils/urlMatchUtils';
|
||||
|
||||
describe('urlMatchUtils', () => {
|
||||
describe('findUrlMatches URL_REGEX', () => {
|
||||
it('matches http and https URLs', () => {
|
||||
const m1 = findUrlMatches('see https://example.com');
|
||||
expect(m1).toHaveLength(1);
|
||||
expect(m1[0].value).toBe('https://example.com');
|
||||
|
||||
const m2 = findUrlMatches('see http://foo.bar/path');
|
||||
expect(m2).toHaveLength(1);
|
||||
expect(m2[0].value).toBe('http://foo.bar/path');
|
||||
});
|
||||
|
||||
it('matches URL with query and hash', () => {
|
||||
const m = findUrlMatches('https://x.com?a=1#anchor');
|
||||
expect(m).toHaveLength(1);
|
||||
expect(m[0].value).toBe('https://x.com?a=1#anchor');
|
||||
});
|
||||
|
||||
it('returns empty for text without URLs', () => {
|
||||
expect(findUrlMatches('no url here')).toEqual([]);
|
||||
expect(findUrlMatches('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches multiple URLs', () => {
|
||||
const m = findUrlMatches('a https://a.com b https://b.com c');
|
||||
expect(m).toHaveLength(2);
|
||||
expect(m[0].value).toBe('https://a.com');
|
||||
expect(m[1].value).toBe('https://b.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trimUrlMatch trailing punctuation regex', () => {
|
||||
it('strips trailing ), . ! ? ; :', () => {
|
||||
const m = findUrlMatches('check (https://example.com).');
|
||||
expect(m).toHaveLength(1);
|
||||
expect(m[0].value).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('strips trailing comma', () => {
|
||||
const m = findUrlMatches('see https://x.com, and more');
|
||||
expect(m).toHaveLength(1);
|
||||
expect(m[0].value).toBe('https://x.com');
|
||||
});
|
||||
|
||||
it('strips multiple trailing punctuation', () => {
|
||||
const m = findUrlMatches('(https://x.com)...');
|
||||
expect(m).toHaveLength(1);
|
||||
expect(m[0].value).toBe('https://x.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUrlBoundary', () => {
|
||||
it('returns match when cursor inside URL', () => {
|
||||
const text = 'go to https://example.com now';
|
||||
const m = findUrlBoundary(text, 12);
|
||||
expect(m).not.toBeNull();
|
||||
expect(m!.value).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('returns null when cursor outside URL', () => {
|
||||
const text = 'go to https://example.com now';
|
||||
expect(findUrlBoundary(text, 0)).toBeNull();
|
||||
expect(findUrlBoundary(text, 100)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUrlMatchFromText', () => {
|
||||
it('removes URL from text', () => {
|
||||
const text = 'see https://x.com here';
|
||||
const matches = findUrlMatches(text);
|
||||
expect(matches).toHaveLength(1);
|
||||
const result = removeUrlMatchFromText(text, matches[0]);
|
||||
expect(result).toBe('see here');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseCrossTeamPrefix, stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
||||
import {
|
||||
formatCrossTeamPrefix,
|
||||
parseCrossTeamPrefix,
|
||||
stripCrossTeamPrefix,
|
||||
} from '@shared/constants/crossTeam';
|
||||
|
||||
describe('crossTeam protocol helpers', () => {
|
||||
it('parses canonical cross-team prefix metadata', () => {
|
||||
|
|
@ -19,8 +23,25 @@ describe('crossTeam protocol helpers', () => {
|
|||
it('strips canonical prefix from UI text', () => {
|
||||
expect(
|
||||
stripCrossTeamPrefix('<cross-team from="a.b" depth="0" conversationId="conv-1" />\nHello')
|
||||
).toBe(
|
||||
'Hello'
|
||||
).toBe('Hello');
|
||||
});
|
||||
|
||||
it('parseCrossTeamAttributes regex: parses attr="value" pairs', () => {
|
||||
const text = formatCrossTeamPrefix('team.user', 0, {
|
||||
conversationId: 'c1',
|
||||
replyToConversationId: 'c0',
|
||||
});
|
||||
const parsed = parseCrossTeamPrefix(text + '\nbody');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.from).toBe('team.user');
|
||||
expect(parsed!.conversationId).toBe('c1');
|
||||
expect(parsed!.replyToConversationId).toBe('c0');
|
||||
});
|
||||
|
||||
it('handles depth attribute', () => {
|
||||
const parsed = parseCrossTeamPrefix(
|
||||
'<cross-team from="a.b" depth="2" />\nHi'
|
||||
);
|
||||
expect(parsed?.chainDepth).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
inferCapabilities,
|
||||
normalizeCategory,
|
||||
normalizeRepoUrl,
|
||||
parseGitHubOwnerRepo,
|
||||
sanitizeMcpServerName,
|
||||
} from '@shared/utils/extensionNormalizers';
|
||||
|
||||
|
|
@ -168,3 +169,35 @@ describe('sanitizeMcpServerName', () => {
|
|||
expect(sanitizeMcpServerName('Context7')).toBe('context7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGitHubOwnerRepo', () => {
|
||||
it('extracts owner/repo from https URL', () => {
|
||||
expect(parseGitHubOwnerRepo('https://github.com/owner/repo')).toEqual({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips .git suffix', () => {
|
||||
expect(parseGitHubOwnerRepo('https://github.com/owner/repo.git')).toEqual({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
expect(parseGitHubOwnerRepo('https://github.com/owner/repo/')).toEqual({
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-GitHub URLs', () => {
|
||||
expect(parseGitHubOwnerRepo('https://gitlab.com/owner/repo')).toBeNull();
|
||||
expect(parseGitHubOwnerRepo('https://example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid URL', () => {
|
||||
expect(parseGitHubOwnerRepo('not-a-url')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
89
test/shared/utils/toolSummary.test.ts
Normal file
89
test/shared/utils/toolSummary.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildToolSummary,
|
||||
formatToolSummary,
|
||||
formatToolSummaryFromMap,
|
||||
parseToolSummary,
|
||||
} from '@shared/utils/toolSummary';
|
||||
|
||||
describe('toolSummary', () => {
|
||||
describe('parseToolSummary simple format regex', () => {
|
||||
it('parses "3 tools"', () => {
|
||||
const r = parseToolSummary('3 tools');
|
||||
expect(r).toEqual({ total: 3, byName: {} });
|
||||
});
|
||||
|
||||
it('parses "1 tool"', () => {
|
||||
const r = parseToolSummary('1 tool');
|
||||
expect(r).toEqual({ total: 1, byName: {} });
|
||||
});
|
||||
|
||||
it('returns null for invalid format', () => {
|
||||
expect(parseToolSummary('invalid')).toBeNull();
|
||||
expect(parseToolSummary('')).toBeNull();
|
||||
expect(parseToolSummary(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolSummary legacy format regex', () => {
|
||||
it('parses "3 tools (Read, 2 Edit)"', () => {
|
||||
const r = parseToolSummary('3 tools (Read, 2 Edit)');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.total).toBe(3);
|
||||
expect(r!.byName).toEqual({ Read: 1, Edit: 2 });
|
||||
});
|
||||
|
||||
it('parses "1 tool (Bash)"', () => {
|
||||
const r = parseToolSummary('1 tool (Bash)');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.total).toBe(1);
|
||||
expect(r!.byName).toEqual({ Bash: 1 });
|
||||
});
|
||||
|
||||
it('parses tool names with spaces "2 tools (2 Web Search)"', () => {
|
||||
const r = parseToolSummary('2 tools (2 Web Search)');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.total).toBe(2);
|
||||
expect(r!.byName['Web Search']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolSummary', () => {
|
||||
it('returns "1 tool" for single tool_use', () => {
|
||||
const content = [{ type: 'tool_use', name: 'Read', input: {} }];
|
||||
expect(buildToolSummary(content)).toBe('1 tool');
|
||||
});
|
||||
|
||||
it('returns "3 tools" for multiple', () => {
|
||||
const content = [
|
||||
{ type: 'tool_use', name: 'Read', input: {} },
|
||||
{ type: 'tool_use', name: 'Edit', input: {} },
|
||||
{ type: 'tool_use', name: 'Read', input: {} },
|
||||
];
|
||||
expect(buildToolSummary(content)).toBe('3 tools');
|
||||
});
|
||||
|
||||
it('returns undefined for empty', () => {
|
||||
expect(buildToolSummary([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatToolSummary', () => {
|
||||
it('formats singular and plural', () => {
|
||||
expect(formatToolSummary({ total: 1, byName: {} })).toBe('1 tool');
|
||||
expect(formatToolSummary({ total: 2, byName: {} })).toBe('2 tools');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatToolSummaryFromMap', () => {
|
||||
it('returns undefined for empty map', () => {
|
||||
expect(formatToolSummaryFromMap(new Map())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('formats from map', () => {
|
||||
const m = new Map([['Read', 2], ['Edit', 1]]);
|
||||
expect(formatToolSummaryFromMap(m)).toBe('3 tools');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue