nexu-io--open-design
214 行
8.1 KiB
TypeScript
214 行
8.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { asInProjectFilePath } from '../../src/runtime/in-project-link';
|
|
|
|
describe('asInProjectFilePath', () => {
|
|
describe('intercepts (returns normalized path)', () => {
|
|
it('bare filename → unchanged', () => {
|
|
expect(asInProjectFilePath('template.html')).toBe('template.html');
|
|
});
|
|
|
|
it('strips a leading ./ prefix', () => {
|
|
expect(asInProjectFilePath('./template.html')).toBe('template.html');
|
|
});
|
|
|
|
it('keeps subdirectory paths intact', () => {
|
|
expect(asInProjectFilePath('subdir/hero.html')).toBe('subdir/hero.html');
|
|
});
|
|
|
|
it('strips ./ in front of a subdirectory path', () => {
|
|
expect(asInProjectFilePath('./subdir/hero.html')).toBe('subdir/hero.html');
|
|
});
|
|
|
|
it('drops a trailing query string', () => {
|
|
expect(asInProjectFilePath('template.html?v=2')).toBe('template.html');
|
|
});
|
|
|
|
it('drops a trailing fragment', () => {
|
|
expect(asInProjectFilePath('template.html#section')).toBe('template.html');
|
|
});
|
|
|
|
it('drops both query and fragment together', () => {
|
|
expect(asInProjectFilePath('template.html?v=2#section')).toBe('template.html');
|
|
});
|
|
|
|
it('trims surrounding whitespace from the href', () => {
|
|
expect(asInProjectFilePath(' template.html ')).toBe('template.html');
|
|
});
|
|
|
|
it('handles the exact long filename shape from the issue screenshot', () => {
|
|
expect(asInProjectFilePath('orbit-daily-digest-general-2026-05-11.html'))
|
|
.toBe('orbit-daily-digest-general-2026-05-11.html');
|
|
});
|
|
|
|
it('decodes percent-encoded filenames so the workspace tab opener matches the on-disk file', () => {
|
|
// Chat markdown frequently emits links like
|
|
// `[Mock page](Mock%20Page.html)` because the autolink path
|
|
// percent-encodes spaces. The workspace tab opener
|
|
// (`requestOpenFile` → `FileWorkspace`) matches by literal
|
|
// on-disk file name, so handing it the raw `Mock%20Page.html`
|
|
// would silently miss the existing tab. Decode the result
|
|
// before returning. Earlier draft PR #1255 hit this exact
|
|
// miss in review (mrcfps / lefarcen P2).
|
|
expect(asInProjectFilePath('Mock%20Page.html')).toBe('Mock Page.html');
|
|
});
|
|
|
|
it('decodes percent-encoded subdirectory paths', () => {
|
|
expect(asInProjectFilePath('Visual%20Direction/hero%20alt.html')).toBe(
|
|
'Visual Direction/hero alt.html',
|
|
);
|
|
});
|
|
|
|
it('decodes non-ASCII (UTF-8 percent-encoded) filenames', () => {
|
|
// Chinese / Cyrillic / accented filenames percent-encode into
|
|
// multi-byte sequences. `decodeURIComponent` handles them
|
|
// correctly; the catch arm below keeps malformed encodings
|
|
// from throwing.
|
|
expect(asInProjectFilePath('%E9%A6%96%E9%A1%B5.html')).toBe('首页.html');
|
|
});
|
|
|
|
it('extracts project raw file URLs produced by assistant file links', () => {
|
|
expect(asInProjectFilePath('/api/projects/project-1/raw/mutuals-v2.html')).toBe('mutuals-v2.html');
|
|
});
|
|
|
|
it('extracts project raw file URLs only when the route project matches the current project', () => {
|
|
expect(asInProjectFilePath('/api/projects/project-1/raw/mutuals-v2.html', undefined, 'project-1')).toBe(
|
|
'mutuals-v2.html',
|
|
);
|
|
expect(asInProjectFilePath('/api/projects/other-project/raw/index.html', new Set(['index.html']), 'project-1'))
|
|
.toBeNull();
|
|
});
|
|
|
|
it('extracts same-origin absolute project raw file URLs', () => {
|
|
expect(asInProjectFilePath(`${window.location.origin}/api/projects/project-1/raw/mutuals-v2.html`)).toBe(
|
|
'mutuals-v2.html',
|
|
);
|
|
});
|
|
|
|
it('matches local absolute paths against known project files', () => {
|
|
expect(
|
|
asInProjectFilePath(
|
|
'/Users/mac/open-design/open-design-preview-0.10.0/projects/Web%20Prototype/index.html',
|
|
new Set(['index.html']),
|
|
),
|
|
).toBe('index.html');
|
|
});
|
|
|
|
it('keeps unknown local absolute paths as normal links', () => {
|
|
expect(
|
|
asInProjectFilePath(
|
|
'/Users/mac/open-design/open-design-preview-0.10.0/projects/Web%20Prototype/index.html',
|
|
new Set(['summary.html']),
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('extracts encoded project raw file paths with nested folders', () => {
|
|
expect(asInProjectFilePath('/api/projects/project-1/raw/Web%20Prototype/mutuals-v2.html?v=2')).toBe(
|
|
'Web Prototype/mutuals-v2.html',
|
|
);
|
|
});
|
|
|
|
it('extracts project file routes from workspace links', () => {
|
|
expect(asInProjectFilePath('/projects/project-1/files/mutuals-v2.html')).toBe('mutuals-v2.html');
|
|
expect(asInProjectFilePath('/projects/project-1/conversations/conv-1/files/mutuals-v2.html')).toBe(
|
|
'mutuals-v2.html',
|
|
);
|
|
});
|
|
|
|
it('extracts workspace file routes only when the route project matches the current project', () => {
|
|
expect(asInProjectFilePath('/projects/project-1/files/mutuals-v2.html', undefined, 'project-1')).toBe(
|
|
'mutuals-v2.html',
|
|
);
|
|
expect(asInProjectFilePath('/projects/other-project/files/index.html', new Set(['index.html']), 'project-1'))
|
|
.toBeNull();
|
|
});
|
|
|
|
it('returns null for malformed percent-encoding rather than throwing', () => {
|
|
// A stray `%` (e.g. `Read%this.html` where the user meant a
|
|
// literal percent) makes decodeURIComponent throw a URIError.
|
|
// We never want a chat link to crash the renderer — fall
|
|
// through to the default browser behavior instead.
|
|
expect(asInProjectFilePath('Read%this.html')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('passes through (returns null) — external schemes', () => {
|
|
it('http://', () => {
|
|
expect(asInProjectFilePath('http://example.com/x')).toBeNull();
|
|
});
|
|
|
|
it('https://', () => {
|
|
expect(asInProjectFilePath('https://example.com/x')).toBeNull();
|
|
});
|
|
|
|
it('mailto:', () => {
|
|
expect(asInProjectFilePath('mailto:foo@bar.com')).toBeNull();
|
|
});
|
|
|
|
it('Electron od: protocol', () => {
|
|
expect(asInProjectFilePath('od://app/projects/123')).toBeNull();
|
|
});
|
|
|
|
it('blob: URLs', () => {
|
|
expect(asInProjectFilePath('blob:https://example.com/abc')).toBeNull();
|
|
});
|
|
|
|
it('file:// URLs (NOT in-project relative paths)', () => {
|
|
expect(asInProjectFilePath('file:///etc/passwd')).toBeNull();
|
|
});
|
|
|
|
it('javascript: scheme is refused even though it matches the RFC grammar', () => {
|
|
expect(asInProjectFilePath('javascript:alert(1)')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('passes through (returns null) — non-link or unsafe shapes', () => {
|
|
it('null', () => {
|
|
expect(asInProjectFilePath(null)).toBeNull();
|
|
});
|
|
|
|
it('undefined', () => {
|
|
expect(asInProjectFilePath(undefined)).toBeNull();
|
|
});
|
|
|
|
it('empty string', () => {
|
|
expect(asInProjectFilePath('')).toBeNull();
|
|
});
|
|
|
|
it('whitespace-only string', () => {
|
|
expect(asInProjectFilePath(' ')).toBeNull();
|
|
});
|
|
|
|
it('#fragment-only — anchor within the same document', () => {
|
|
expect(asInProjectFilePath('#section')).toBeNull();
|
|
});
|
|
|
|
it('absolute path starting with / — could mean filesystem root in Electron', () => {
|
|
expect(asInProjectFilePath('/abs/path.html')).toBeNull();
|
|
});
|
|
|
|
it('parent-traversal `..` — refuses to climb out of the project root', () => {
|
|
expect(asInProjectFilePath('..')).toBeNull();
|
|
});
|
|
|
|
it('relative path that walks up via .. — refused', () => {
|
|
expect(asInProjectFilePath('../sibling.html')).toBeNull();
|
|
});
|
|
|
|
it('mid-path .. segment is still refused', () => {
|
|
expect(asInProjectFilePath('a/../b.html')).toBeNull();
|
|
});
|
|
|
|
it('refuses a `..` segment smuggled in via percent-encoding (`%2E%2E`)', () => {
|
|
// Decoding happens after the literal-`..` check; without an
|
|
// additional post-decode check a hostile chat link could
|
|
// bypass the traversal guard by writing `%2E%2E/secret.html`
|
|
// and the workspace opener would receive `../secret.html`.
|
|
expect(asInProjectFilePath('%2E%2E/secret.html')).toBeNull();
|
|
expect(asInProjectFilePath('a/%2E%2E/b.html')).toBeNull();
|
|
});
|
|
});
|
|
});
|