fix(web): keep trailing punctuation out of bare chat URLs (#2678)

* fix(web): keep trailing punctuation out of bare chat URLs (#2675)

* test(web): broaden bare URL punctuation regression coverage

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* test(web): broaden bare URL punctuation regression coverage

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* test(e2e): harden smoke suite tools-dev port startup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)
This commit is contained in:
shangxinyu1 2026-05-22 15:49:05 +08:00 committed by GitHub
parent aa0616062d
commit 7fd0169888
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 126 additions and 14 deletions

View file

@ -350,17 +350,19 @@ function renderInline(text: string): ReactNode {
} else if (m[6]) {
// Bare URL — autolink with the URL as both href and visible text,
// matching the Markdown `<https://…>` autolink convention.
const [href, suffix] = splitTrailingAutolinkPunctuation(m[6]);
out.push(
<a
key={key++}
className="md-link md-link-bare"
href={m[6]}
href={href}
target="_blank"
rel="noreferrer noopener"
>
{m[6]}
{href}
</a>,
);
if (suffix) pushText(out, suffix, key++);
} else if (m[7]) {
out.push(<strong key={key++}>{m[7].slice(2, -2)}</strong>);
} else if (m[8]) {
@ -393,17 +395,21 @@ function pushText(out: ReactNode[], text: string, baseKey: number): void {
if (m.index > lastIndex) {
segments.push(...withBreaks(text.slice(lastIndex, m.index), `${baseKey}-${k++}`));
}
const [href, suffix] = splitTrailingAutolinkPunctuation(m[1]!);
segments.push(
<a
key={`${baseKey}-${k++}`}
className="md-link"
href={m[1]}
href={href}
target="_blank"
rel="noreferrer noopener"
>
{m[1]}
{href}
</a>,
);
if (suffix) {
segments.push(...withBreaks(suffix, `${baseKey}-${k++}`));
}
lastIndex = urlRe.lastIndex;
}
if (lastIndex < text.length) {
@ -412,6 +418,13 @@ function pushText(out: ReactNode[], text: string, baseKey: number): void {
out.push(<Fragment key={baseKey}>{segments}</Fragment>);
}
function splitTrailingAutolinkPunctuation(url: string): [string, string] {
const match = /([.,!?;:,。!?;:、'"」』】》〉)]+)$/.exec(url);
if (!match || !match[1]) return [url, ''];
const trimmed = url.slice(0, -match[1].length);
return trimmed ? [trimmed, match[1]] : [url, ''];
}
function withBreaks(text: string, baseKey: string): ReactNode[] {
const parts = text.split('\n');
const out: ReactNode[] = [];

View file

@ -40,6 +40,62 @@ describe('renderMarkdown', () => {
expect(out).toContain('md-link-bare');
});
it.each([
{
name: 'ascii comma',
input: 'Open https://example.com/demo, please.',
href: 'https://example.com/demo',
rendered: '>https://example.com/demo</a>,',
excludedHref: 'https://example.com/demo,',
},
{
name: 'fullwidth full stop',
input: 'Visit https://example.com/final。',
href: 'https://example.com/final',
rendered: '>https://example.com/final</a>。',
excludedHref: 'https://example.com/final。',
},
{
name: 'wrapped in fullwidth parens',
input: 'https://example.com/a',
href: 'https://example.com/a',
rendered: '<a class="md-link md-link-bare" href="https://example.com/a"',
trailing: '>https://example.com/a</a>',
excludedHref: 'https://example.com/a',
},
{
name: 'lone trailing CJK quote',
input: 'https://example.com/b」',
href: 'https://example.com/b',
rendered: '>https://example.com/b</a>」',
excludedHref: 'https://example.com/b」',
},
{
name: 'stacked CJK punctuation',
input: 'https://example.com/c。',
href: 'https://example.com/c',
rendered: '>https://example.com/c</a>。)',
excludedHref: 'https://example.com/c。',
},
{
name: 'no trailing punctuation',
input: 'https://example.com/path',
href: 'https://example.com/path',
rendered: '>https://example.com/path</a>',
excludedHref: '',
},
])('keeps bare autolink punctuation handling stable: $name', ({ input, href, rendered, excludedHref, trailing }) => {
const out = html(input);
expect(out).toContain(`href="${href}"`);
expect(out).toContain(rendered);
if (trailing) {
expect(out).toContain(trailing);
}
if (excludedHref) {
expect(out).not.toContain(`href="${excludedHref}"`);
}
});
it('does not autolink inside inline code spans', () => {
const out = html('Use `https://example.com/x` literally.');
// The URL should appear inside a <code> tag, not turned into an anchor.

View file

@ -128,14 +128,24 @@ async function runToolsDevSuite(
options: ToolsDevSuiteOptions = {},
): Promise<string> {
const toolsDev = await import('./tools-dev.ts');
const runtime = await toolsDev.allocateToolsDevRuntime();
let runtime = await toolsDev.allocateToolsDevRuntime();
let context: ToolsDevSuiteContext | null = null;
let diagnostics: unknown = null;
let caughtError: unknown = null;
let success = false;
try {
const start = await toolsDev.startToolsDevWeb(suite, runtime);
let start: ToolsDevStartResult | null = null;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
start = await toolsDev.startToolsDevWeb(suite, runtime);
break;
} catch (error) {
if (attempt === 3 || !toolsDev.isToolsDevPortConflict(error)) throw error;
runtime = await toolsDev.allocateToolsDevRuntime();
}
}
if (start == null) throw new Error('tools-dev start did not return a result');
const webUrl = assertRuntimeUrl(start.web?.status.url, 'web');
const status = await toolsDev.inspectToolsDevStatus(suite);
assertToolsDevStatus(suite, status);

View file

@ -56,16 +56,32 @@ export type ToolsDevCheckResult = {
export type ToolsDevRuntime = {
daemonPort: number;
release: () => Promise<void>;
webPort: number;
};
export async function allocateToolsDevRuntime(): Promise<ToolsDevRuntime> {
const [daemonPort, webPort] = await Promise.all([findFreePort(), findFreePort()]);
if (daemonPort === webPort) return await allocateToolsDevRuntime();
return { daemonPort, webPort };
const [daemonPort, webPort] = await Promise.all([reserveFreePort(), reserveFreePort()]);
if (daemonPort.port === webPort.port) {
await Promise.all([daemonPort.release(), webPort.release()]);
return await allocateToolsDevRuntime();
}
let released = false;
return {
daemonPort: daemonPort.port,
webPort: webPort.port,
async release() {
if (released) return;
released = true;
await Promise.all([daemonPort.release(), webPort.release()]);
},
};
}
export async function startToolsDevWeb(suite: SmokeSuite, runtime: ToolsDevRuntime): Promise<ToolsDevStartResult> {
// Keep both ports reserved until immediately before tools-dev starts so
// parallel Vitest workers do not race each other for the same "free" port.
await runtime.release();
return await runToolsDevJson<ToolsDevStartResult>(
suite,
[
@ -141,6 +157,13 @@ export async function readToolsDevLogs(suite: SmokeSuite): Promise<Record<string
);
}
export function isToolsDevPortConflict(error: unknown): boolean {
const text = error instanceof Error
? `${error.message}\n${error.stack ?? ''}`
: String(error);
return text.includes('EADDRINUSE');
}
async function runToolsDevJson<T>(suite: SmokeSuite, args: string[]): Promise<T> {
const useNpmExecPathWithNode = process.env.OD_E2E_PNPM_COMMAND == null
&& pnpmExecPath != null
@ -179,18 +202,28 @@ function parseJsonOutput<T>(stdout: string): T {
return JSON.parse(stdout.slice(jsonStart + 1)) as T;
}
async function findFreePort(): Promise<number> {
async function reserveFreePort(): Promise<{ port: number; release: () => Promise<void> }> {
const server = createServer();
await new Promise<void>((resolveListen, rejectListen) => {
server.once('error', rejectListen);
server.listen(0, '127.0.0.1', () => resolveListen());
});
const address = server.address();
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
if (address == null || typeof address === 'string') {
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
throw new Error('failed to allocate a local TCP port');
}
return address.port;
let released = false;
return {
port: address.port,
async release() {
if (released) return;
released = true;
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
},
};
}