c98749fbff
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
161 lines
7.0 KiB
TypeScript
161 lines
7.0 KiB
TypeScript
/**
|
|
* file-path.ts unit tests: whether inline code in message text looks like a file path,
|
|
* all branches of normalizing body paths to a Workspace-relative path (toWorkspaceRelative),
|
|
* and the directory-browser navigation join (joinWorkspacePath).
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { isFilePathLike, joinWorkspacePath, toWorkspaceRelative } from "../src/lib/file-path";
|
|
|
|
describe("isFilePathLike", () => {
|
|
it("relative path + image extension → match", () => {
|
|
expect(isFilePathLike("./foo/bar.png")).toBe(true);
|
|
});
|
|
|
|
it("bare filename without slashes + known extension → match", () => {
|
|
expect(isFilePathLike("report.pdf")).toBe(true);
|
|
});
|
|
|
|
it("multi-level relative path + code extension → match", () => {
|
|
expect(isFilePathLike("src/lib/format.ts")).toBe(true);
|
|
});
|
|
|
|
it("surrounding whitespace is trimmed and does not affect matching", () => {
|
|
expect(isFilePathLike(" ./agentmind-architecture-minimal.png ")).toBe(true);
|
|
});
|
|
|
|
it("bare word (no extension) → no match", () => {
|
|
expect(isFilePathLike("hello")).toBe(false);
|
|
});
|
|
|
|
it("unknown extension → no match", () => {
|
|
expect(isFilePathLike("archive.xyz")).toBe(false);
|
|
});
|
|
|
|
it("version number (numeric extension) → no match", () => {
|
|
expect(isFilePathLike("v1.2")).toBe(false);
|
|
});
|
|
|
|
it("URL (with scheme) → no match, even when the tail looks like an extension", () => {
|
|
expect(isFilePathLike("https://example.com/report.pdf")).toBe(false);
|
|
});
|
|
|
|
it("bare domain (www prefix) → no match", () => {
|
|
expect(isFilePathLike("www.example.com")).toBe(false);
|
|
});
|
|
|
|
it("long sentence with spaces (dot mid-sentence) → no match", () => {
|
|
expect(isFilePathLike("See the report.pdf attached in this message.")).toBe(false);
|
|
});
|
|
|
|
it("empty string → no match", () => {
|
|
expect(isFilePathLike("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("toWorkspaceRelative", () => {
|
|
const WS = "/home/user/workspaces/tmp-1";
|
|
|
|
it("absolute path matching the Workspace prefix → strips it, returning the relative path", () => {
|
|
expect(toWorkspaceRelative(`${WS}/report.pdf`, WS)).toBe("report.pdf");
|
|
expect(toWorkspaceRelative(`${WS}/sub/dir/a.txt`, WS)).toBe("sub/dir/a.txt");
|
|
});
|
|
|
|
it("absolute path missing the prefix / equal to the Workspace itself / no Workspace → null", () => {
|
|
expect(toWorkspaceRelative("/etc/passwd.txt", WS)).toBe(null);
|
|
// Sibling directory shares the prefix string but differs by a separator: not a match.
|
|
expect(toWorkspaceRelative(`${WS}-other/a.txt`, WS)).toBe(null);
|
|
expect(toWorkspaceRelative(WS, WS)).toBe(null);
|
|
expect(toWorkspaceRelative("/a/b.txt", null)).toBe(null);
|
|
expect(toWorkspaceRelative("/a/b.txt", "")).toBe(null);
|
|
});
|
|
|
|
it("absolute paths keep normalizing lexically after the prefix is stripped (.. escaping the root → null)", () => {
|
|
expect(toWorkspaceRelative(`${WS}/sub/../a.txt`, WS)).toBe("a.txt");
|
|
expect(toWorkspaceRelative(`${WS}/../escape.txt`, WS)).toBe(null);
|
|
});
|
|
|
|
it("Windows-style Workspace: drive-letter/backslash absolute paths strip the prefix and normalize to '/'", () => {
|
|
const win = "C:\\Users\\me\\ws";
|
|
expect(toWorkspaceRelative("C:\\Users\\me\\ws\\sub\\a.txt", win)).toBe("sub/a.txt");
|
|
expect(toWorkspaceRelative("D:\\other\\a.txt", win)).toBe(null);
|
|
expect(toWorkspaceRelative("C:\\Users\\me\\ws", win)).toBe(null);
|
|
});
|
|
|
|
it("Windows Workspace: forward-slash and mixed spellings of the same path also match", () => {
|
|
const win = "C:\\Users\\me\\ws";
|
|
// Core's model-visible spelling uses forward slashes on Windows; the card must still strip.
|
|
expect(toWorkspaceRelative("C:/Users/me/ws/sub/a.txt", win)).toBe("sub/a.txt");
|
|
expect(toWorkspaceRelative("C:/Users/me/ws\\sub/a.txt", win)).toBe("sub/a.txt");
|
|
// Drive letters are case-insensitive on Windows; the rest of the path is not.
|
|
expect(toWorkspaceRelative("c:/Users/me/ws/sub/a.txt", win)).toBe("sub/a.txt");
|
|
expect(toWorkspaceRelative("C:/Users/ME/ws/sub/a.txt", win)).toBe(null);
|
|
// A forward-slash Workspace value matches a backslash assistant path too.
|
|
expect(toWorkspaceRelative("C:\\Users\\me\\ws\\sub\\a.txt", "C:/Users/me/ws")).toBe(
|
|
"sub/a.txt",
|
|
);
|
|
});
|
|
|
|
it("Windows Workspace: relative backslash paths split into segments", () => {
|
|
expect(toWorkspaceRelative("dir\\name.txt", "C:\\Users\\me\\ws")).toBe("dir/name.txt");
|
|
});
|
|
|
|
it("backslashes in POSIX filenames are not globally replaced (converted only on a Windows prefix match)", () => {
|
|
expect(toWorkspaceRelative("dir\\name.txt", WS)).toBe("dir\\name.txt");
|
|
});
|
|
|
|
it("relative-path lexical normalization: ./ and empty segments removed, .. pops the stack", () => {
|
|
expect(toWorkspaceRelative("./foo/bar.png", WS)).toBe("foo/bar.png");
|
|
expect(toWorkspaceRelative("a/./b//c.txt", WS)).toBe("a/b/c.txt");
|
|
expect(toWorkspaceRelative("a/../b.txt", WS)).toBe("b.txt");
|
|
});
|
|
|
|
it("relative .. escaping the Workspace root → null", () => {
|
|
expect(toWorkspaceRelative("../a.txt", WS)).toBe(null);
|
|
expect(toWorkspaceRelative("a/../../b.txt", WS)).toBe(null);
|
|
});
|
|
|
|
it("leading ~ (home directory) → null", () => {
|
|
expect(toWorkspaceRelative("~/docs/a.txt", WS)).toBe(null);
|
|
});
|
|
|
|
it("a plain filename returns unchanged; relative paths normalize as usual with a null Workspace", () => {
|
|
expect(toWorkspaceRelative("report.pdf", WS)).toBe("report.pdf");
|
|
expect(toWorkspaceRelative("sub/a.txt", null)).toBe("sub/a.txt");
|
|
});
|
|
|
|
it("empty / pure whitespace / empty after normalization ('.') / overlong (>512) → null", () => {
|
|
expect(toWorkspaceRelative("", WS)).toBe(null);
|
|
expect(toWorkspaceRelative(" ", WS)).toBe(null);
|
|
expect(toWorkspaceRelative(".", WS)).toBe(null);
|
|
expect(toWorkspaceRelative(`a/${"x".repeat(512)}.txt`, WS)).toBe(null);
|
|
});
|
|
|
|
it("surrounding whitespace is trimmed before normalization", () => {
|
|
expect(toWorkspaceRelative(` ${WS}/a.txt `, WS)).toBe("a.txt");
|
|
});
|
|
});
|
|
|
|
describe("joinWorkspacePath", () => {
|
|
it("joins an entry name onto the Workspace root (empty base)", () => {
|
|
expect(joinWorkspacePath("", "home")).toBe("home");
|
|
});
|
|
|
|
it("joins an entry name onto a nested base", () => {
|
|
expect(joinWorkspacePath("home/user", "docs")).toBe("home/user/docs");
|
|
});
|
|
|
|
it("two clicks on the same row from the same listing generation are idempotent (regression: compounded descent)", () => {
|
|
// The files-panel race: while the navigation fetch is in flight, the listing for `base`
|
|
// stays rendered but the path state has already advanced. Click #2 on the same stale row
|
|
// must recompute the SAME target from the listing's own base…
|
|
const base = "";
|
|
const first = joinWorkspacePath(base, "home");
|
|
const pathStateAfterFirstClick = first;
|
|
const second = joinWorkspacePath(base, "home");
|
|
expect(second).toBe(first);
|
|
// …whereas joining against the advanced path state — the old behavior — compounds the
|
|
// segment into a directory that does not exist ("home/home", then "home/home/home", …).
|
|
expect(joinWorkspacePath(pathStateAfterFirstClick, "home")).toBe("home/home");
|
|
});
|
|
});
|