Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions src/__tests__/e2e/mention-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { test, expect } from '@playwright/test';
import { goToChat } from '../helpers';

test.describe('@mention UI/UX', () => {
test('typing @ keeps input shadow consistent with slash mode', async ({ page }) => {
await goToChat(page);

const input = page.locator('textarea[name="message"]').first();
if ((await input.count()) === 0) {
test.skip(true, 'Chat message input is unavailable in current test environment');
}
await expect(input).toBeVisible();

await input.fill('@');
await expect(input).not.toHaveClass(/bg-primary\/5/);
await expect(input).not.toHaveClass(/border-primary\/20/);
});

test('@mentions send structured files/mentions without dumping directory contents', async ({ page }) => {
let chatRequestBody: Record<string, unknown> | null = null;
let sessionCounter = 0;

await page.route('**/api/files/suggest**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{ path: 'src/components', display: 'src/components/', type: 'directory' },
{ path: 'src/app/page.tsx', display: 'src/app/page.tsx', type: 'file' },
],
}),
});
});

await page.route('**/api/files/serve**', async (route) => {
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/plain', 'content-length': '38' },
body: 'export const page = () => "hello mention";\n',
});
});

await page.route('**/api/files?**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
tree: [
{ name: 'Button.tsx', path: '/tmp/src/components/Button.tsx', type: 'file' },
{ name: 'Dialog.tsx', path: '/tmp/src/components/Dialog.tsx', type: 'file' },
{ name: 'forms', path: '/tmp/src/components/forms', type: 'directory', children: [] },
],
}),
});
});

await page.route('**/api/chat/sessions', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
sessionCounter += 1;
const id = `mock-session-${sessionCounter}`;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
session: {
id,
title: 'Mock Session',
model: 'sonnet',
mode: 'code',
provider_id: 'mock',
working_directory: '/tmp',
},
}),
});
});

await page.route('**/api/chat', async (route) => {
const req = route.request();
if (req.method() !== 'POST') {
await route.continue();
return;
}
try {
chatRequestBody = req.postDataJSON() as Record<string, unknown>;
} catch {
chatRequestBody = null;
}
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ type: 'text', data: 'ok' })}\n\ndata: ${JSON.stringify({ type: 'done' })}\n\n`,
});
});

await goToChat(page);

const input = page.locator('textarea[name="message"]').first();
if ((await input.count()) === 0) {
test.skip(true, 'Chat message input is unavailable in current test environment');
}
await expect(input).toBeVisible();

await input.fill('@src/com');
const dirOption = page.locator('button:has-text("src/components/")').first();
if ((await dirOption.count()) > 0) {
await dirOption.click();
await input.type(' and @src/app/page.tsx');
} else {
test.skip(true, 'Directory mention option is unavailable in current test environment');
}
await input.press('Enter');

await expect.poll(() => chatRequestBody !== null).toBeTruthy();

const payload = (chatRequestBody ?? {}) as { files?: unknown; mentions?: unknown; content?: unknown };
const files = Array.isArray(payload.files) ? payload.files : [];
const mentions = Array.isArray(payload.mentions) ? payload.mentions : [];
const content = typeof payload.content === 'string' ? payload.content : '';

expect(files.length).toBeGreaterThanOrEqual(1);
expect(mentions.length).toBeGreaterThanOrEqual(2);
expect(content).toContain('[Referenced Directories]');
expect(content).toContain('Directory reference @src/components/');
expect(content).toContain('- Button.tsx');
expect(content).not.toContain('export const page = () => "hello mention"');
});

test('removing one mention keeps others and chip order follows selection order', async ({ page }) => {
await page.route('**/api/files/suggest**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{ path: 'src/alpha.ts', display: 'src/alpha.ts', type: 'file' },
{ path: 'src/beta.ts', display: 'src/beta.ts', type: 'file' },
],
}),
});
});

await goToChat(page);

const input = page.locator('textarea[name="message"]').first();
if ((await input.count()) === 0) {
test.skip(true, 'Chat message input is unavailable in current test environment');
}
await expect(input).toBeVisible();

// Create a slash badge first so we can verify mixed chip ordering.
await input.fill('/doctor');
await input.press('Enter');

// Insert two file mentions in order: alpha then beta.
await input.fill('@src/al');
await page.locator('button:has-text("src/alpha.ts")').first().click();
await input.type('@src/be');
await page.locator('button:has-text("src/beta.ts")').first().click();

// Selection order should be preserved in chip row: /doctor -> @alpha -> @beta.
const chipsBefore = (await page.locator('span.font-mono').allTextContents()).map((t) => t.trim()).filter(Boolean);
const doctorIdx = chipsBefore.findIndex((t) => t === '/doctor');
const alphaIdx = chipsBefore.findIndex((t) => t.includes('@src/alpha.ts'));
const betaIdx = chipsBefore.findIndex((t) => t.includes('@src/beta.ts'));
expect(doctorIdx).toBeGreaterThanOrEqual(0);
expect(alphaIdx).toBeGreaterThan(doctorIdx);
expect(betaIdx).toBeGreaterThan(alphaIdx);

// Remove one mention chip explicitly; the other mention should remain.
await page
.locator('span:has-text("@src/alpha.ts")')
.first()
.locator('button')
.click();

const after = await input.inputValue();
expect(after).not.toContain('@src/alpha.ts');
expect(after).toContain('@src/beta.ts');

const chipsAfter = (await page.locator('span.font-mono').allTextContents()).map((t) => t.trim()).filter(Boolean);
expect(chipsAfter.some((t) => t === '/doctor')).toBeTruthy();
expect(chipsAfter.some((t) => t.includes('@src/alpha.ts'))).toBeFalsy();
expect(chipsAfter.some((t) => t.includes('@src/beta.ts'))).toBeTruthy();

// Then Backspace should clear the remaining @file token as one unit.
await input.evaluate((el) => {
const ta = el as HTMLTextAreaElement;
const len = ta.value.length;
ta.focus();
ta.setSelectionRange(len, len);
});
await input.press('Backspace');
const afterBackspace = await input.inputValue();
expect(afterBackspace).not.toContain('@src/beta.ts');
});
});
72 changes: 72 additions & 0 deletions src/__tests__/unit/files-suggest-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import { NextRequest } from 'next/server';
import { GET } from '../../app/api/files/suggest/route';

const testRoot = path.join(os.homedir(), '.codepilot-test-files-suggest-' + randomUUID());

function req(url: string) {
return new NextRequest(url);
}

after(() => {
try {
fs.rmSync(testRoot, { recursive: true, force: true });
} catch {
// ignore cleanup errors in CI
}
});

describe('/api/files/suggest route', () => {
it('returns 400 when sessionId and workingDirectory are both missing', async () => {
const res = await GET(req('http://localhost/api/files/suggest'));
assert.equal(res.status, 400);
});

it('rejects filesystem root workingDirectory', async () => {
const rootPath = path.parse(process.cwd()).root;
const res = await GET(
req(`http://localhost/api/files/suggest?workingDirectory=${encodeURIComponent(rootPath)}`),
);
assert.equal(res.status, 403);
});

it('rejects workingDirectory outside home when sessionId is not provided', async () => {
const outsideHome = process.platform === 'win32' ? 'C:\\Windows' : '/tmp';
const res = await GET(
req(`http://localhost/api/files/suggest?workingDirectory=${encodeURIComponent(outsideHome)}`),
);
assert.equal(res.status, 403);
});

it('returns relative paths with node type and respects limit', async () => {
fs.mkdirSync(path.join(testRoot, 'src', 'components'), { recursive: true });
fs.writeFileSync(path.join(testRoot, 'src', 'app.ts'), 'export const app = 1;\n');
fs.writeFileSync(path.join(testRoot, 'src', 'components', 'Card.tsx'), 'export default function Card(){}\n');
fs.writeFileSync(path.join(testRoot, 'README.md'), '# test\n');

const url = `http://localhost/api/files/suggest?workingDirectory=${encodeURIComponent(testRoot)}&q=src&limit=2`;
const res = await GET(req(url));
assert.equal(res.status, 200);

const data = await res.json() as {
items: Array<{ path: string; display: string; type: 'file' | 'directory'; nodeType: 'file' | 'directory' }>;
};
assert.ok(Array.isArray(data.items));
assert.ok(data.items.length <= 2);
assert.ok(data.items.length > 0);

for (const item of data.items) {
assert.ok(!path.isAbsolute(item.path), `expected relative path, got ${item.path}`);
assert.ok(item.type === 'file' || item.type === 'directory');
assert.equal(item.nodeType, item.type);
if (item.type === 'directory') {
assert.ok(item.display.endsWith('/'));
}
}
});
});
43 changes: 43 additions & 0 deletions src/__tests__/unit/message-input-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
resolveKeyAction,
resolveDirectSlash,
buildCliAppend,
parseMentionRefs,
dedupeMentionsByPath,
} from '../../lib/message-input-logic';

// =====================================================================
Expand Down Expand Up @@ -678,6 +680,47 @@ describe('CLI badge behavior', () => {
});
});

describe('@mention parsing and dedupe', () => {
it('parses file mention with source range', () => {
const refs = parseMentionRefs('Please check @src/app/page.tsx now');
assert.equal(refs.length, 1);
assert.equal(refs[0].path, 'src/app/page.tsx');
assert.equal(refs[0].nodeType, 'file');
assert.ok(refs[0].sourceRange.start >= 0);
assert.ok(refs[0].sourceRange.end > refs[0].sourceRange.start);
});

it('parses multiple mentions and keeps order', () => {
const refs = parseMentionRefs('Compare @a.ts and @b.ts');
assert.equal(refs.length, 2);
assert.equal(refs[0].path, 'a.ts');
assert.equal(refs[1].path, 'b.ts');
});

it('respects node type lookup for directory mentions', () => {
const refs = parseMentionRefs(
'Open @src/components',
{ 'src/components': 'directory' },
);
assert.equal(refs.length, 1);
assert.equal(refs[0].nodeType, 'directory');
});

it('strips trailing punctuation from mention path', () => {
const refs = parseMentionRefs('See @src/index.ts, please.');
assert.equal(refs.length, 1);
assert.equal(refs[0].path, 'src/index.ts');
});

it('dedupes mentions by path', () => {
const refs = parseMentionRefs('@src/a.ts @src/a.ts @src/b.ts');
const deduped = dedupeMentionsByPath(refs);
assert.equal(deduped.length, 2);
assert.equal(deduped[0].path, 'src/a.ts');
assert.equal(deduped[1].path, 'src/b.ts');
});
});

// --- Cross-cutting: full keyboard interaction scenarios ---------------

describe('Full keyboard interaction scenarios', () => {
Expand Down
Loading
Loading