mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test: retire copied oracles and implementation inventories (#32206)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -1,153 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MIN_NATIVE_BYTE_LENGTH_CODE_UNITS,
|
||||
measureTerminalStreamByteLength,
|
||||
terminalStreamByteLength,
|
||||
terminalStreamByteLengthExceeds
|
||||
} from './terminal-stream-byte-length'
|
||||
|
||||
// Copy of the pre-change implementation (shared/clipboard-text.ts
|
||||
// measureClipboardTextByteLength), kept here so equivalence is checked against the
|
||||
// ACTUAL old code path rather than a paraphrase of it. The one deliberate deviation is
|
||||
// `legacyCodePointAt`: raw `String.prototype.codePointAt` reads one code unit past the end
|
||||
// of a sliced string once V8 optimizes its caller, so the naive copy is not a stable
|
||||
// reference. See src/shared/utf8-byte-limits.ts (readUtf8CodePointAt).
|
||||
function legacyCodePointAt(text: string, index: number): number {
|
||||
const leadUnit = text.charCodeAt(index)
|
||||
if (leadUnit < 0xd800 || leadUnit > 0xdbff || index + 1 >= text.length) {
|
||||
return leadUnit
|
||||
}
|
||||
const trailUnit = text.charCodeAt(index + 1)
|
||||
if (trailUnit < 0xdc00 || trailUnit > 0xdfff) {
|
||||
return leadUnit
|
||||
}
|
||||
return (leadUnit - 0xd800) * 0x400 + (trailUnit - 0xdc00) + 0x10000
|
||||
}
|
||||
|
||||
function legacyUtf8ByteLengthForCodePoint(codePoint: number): number {
|
||||
if (codePoint <= 0x7f) {
|
||||
return 1
|
||||
}
|
||||
if (codePoint <= 0x7ff) {
|
||||
return 2
|
||||
}
|
||||
if (codePoint <= 0xffff) {
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
function legacyMeasure(
|
||||
text: string,
|
||||
options: { stopAfterBytes?: number } = {}
|
||||
): { byteLength: number; exceededLimit: boolean } {
|
||||
const stopAfterBytes = options.stopAfterBytes
|
||||
let byteLength = 0
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const codePoint = legacyCodePointAt(text, index)
|
||||
byteLength += legacyUtf8ByteLengthForCodePoint(codePoint)
|
||||
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
|
||||
return { byteLength, exceededLimit: true }
|
||||
}
|
||||
if (codePoint > 0xffff) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return { byteLength, exceededLimit: false }
|
||||
}
|
||||
|
||||
function legacyByteLength(data: string): number {
|
||||
return legacyMeasure(data).byteLength
|
||||
}
|
||||
|
||||
function legacyExceeds(data: string, maxBytes: number): boolean {
|
||||
return legacyMeasure(data, { stopAfterBytes: maxBytes }).exceededLimit
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed generator: ASCII, 2-byte, 3-byte, astral pairs, and LONE surrogates, so the
|
||||
// fixtures exercise every branch of the legacy code-point scan.
|
||||
function randomUnit(random: () => number): string {
|
||||
const roll = random()
|
||||
if (roll < 0.4) {
|
||||
return String.fromCharCode(Math.floor(random() * 0x80))
|
||||
}
|
||||
if (roll < 0.55) {
|
||||
return String.fromCharCode(0x80 + Math.floor(random() * 0x780))
|
||||
}
|
||||
if (roll < 0.72) {
|
||||
return String.fromCharCode(0x800 + Math.floor(random() * 0xd000))
|
||||
}
|
||||
if (roll < 0.88) {
|
||||
return String.fromCodePoint(0x10000 + Math.floor(random() * 0x100000))
|
||||
}
|
||||
return String.fromCharCode(0xd800 + Math.floor(random() * 0x800))
|
||||
}
|
||||
|
||||
function randomString(random: () => number, maxUnits: number): string {
|
||||
const count = Math.floor(random() * maxUnits)
|
||||
let text = ''
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
text += randomUnit(random)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// Raw UTF-16 with no code-point discipline at all: catches anything that assumes
|
||||
// well-formedness (unpaired high after high, low before high, etc).
|
||||
function rawUtf16(random: () => number, maxUnits: number): string {
|
||||
const count = Math.floor(random() * maxUnits)
|
||||
let text = ''
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
text += String.fromCharCode(Math.floor(random() * 0x11000))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const EDGE_STRINGS = [
|
||||
'',
|
||||
'a',
|
||||
'\u0000',
|
||||
'\u007f',
|
||||
'',
|
||||
'߿',
|
||||
'ࠀ',
|
||||
'',
|
||||
'�',
|
||||
'\u{10000}',
|
||||
'\u{10ffff}',
|
||||
'\ud800',
|
||||
'\udfff',
|
||||
'\ud800\ud800',
|
||||
'\udc00\ud800',
|
||||
'😀',
|
||||
'😀\ud800',
|
||||
'\ud800😀',
|
||||
'a\ud800b',
|
||||
'\r\n\u001b[0m',
|
||||
'é́',
|
||||
'\u{1f469}\u{1f4bb}',
|
||||
'x'.repeat(1000),
|
||||
'\u{1f600}'.repeat(300),
|
||||
`${'é'.repeat(500)}\ud800`
|
||||
]
|
||||
|
||||
// Regression for the intermittent "measurement diverged at 13 units" failure: the fuzzers
|
||||
// build a rope and cut it at a fixed code-unit count, which can split a surrogate pair and
|
||||
// leave the low half in the parent just past the slice. Optimized `codePointAt` pairs across
|
||||
// that boundary, so the scan measured one byte too many, but only after the enclosing function
|
||||
// tiered up, which made the failure look load-dependent. 13 code units is V8's minimum length
|
||||
// for a sliced string, which is why the divergence started exactly there.
|
||||
describe('measuring a prefix slice that cuts a surrogate pair in half', () => {
|
||||
// Kept first in the file so the scan is still specializing on this shape when it tiers up.
|
||||
it('measures the slice like the encoder does in every JIT tier', () => {
|
||||
@@ -175,165 +32,70 @@ describe('measuring a prefix slice that cuts a surrogate pair in half', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal stream byte length equivalence with the legacy code-point scan', () => {
|
||||
it('matches the legacy total byte length on edge strings', () => {
|
||||
for (const text of EDGE_STRINGS) {
|
||||
expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the legacy total byte length over every Unicode code point', () => {
|
||||
for (let codePoint = 0; codePoint <= 0x10ffff; codePoint += 1) {
|
||||
const text = String.fromCodePoint(codePoint)
|
||||
if (terminalStreamByteLength(text) !== legacyByteLength(text)) {
|
||||
throw new Error(`byte length diverged at code point U+${codePoint.toString(16)}`)
|
||||
describe('terminal stream UTF-8 byte accounting', () => {
|
||||
it('matches the encoder across Unicode widths and the native-call boundary', () => {
|
||||
const samples = [
|
||||
'',
|
||||
'\u0000\u007f',
|
||||
'\u0080\u07ff',
|
||||
'\u0800\uffff',
|
||||
'\u{10000}\u{10ffff}',
|
||||
'\ud800',
|
||||
'\udfff',
|
||||
'\ud800\ud800',
|
||||
'\udc00\ud800',
|
||||
'😀\ud800',
|
||||
'\ud800😀',
|
||||
'e\u0301',
|
||||
'👩💻',
|
||||
'\r\n\u001b[0m'
|
||||
]
|
||||
for (const units of [15, 16, 17, 32]) {
|
||||
for (const unit of ['a', 'é', '走', '😀', '\ud800']) {
|
||||
samples.push(unit.repeat(units).slice(0, units))
|
||||
}
|
||||
}
|
||||
expect(terminalStreamByteLength('\u{10ffff}')).toBe(4)
|
||||
})
|
||||
|
||||
it('matches the legacy total byte length over every lone surrogate', () => {
|
||||
for (let unit = 0xd800; unit <= 0xdfff; unit += 1) {
|
||||
const text = `a${String.fromCharCode(unit)}b`
|
||||
expect(terminalStreamByteLength(text)).toBe(legacyByteLength(text))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the legacy total byte length over fuzzed mixed and raw UTF-16 input', () => {
|
||||
const random = mulberry32(0x5eed01)
|
||||
for (let iteration = 0; iteration < 20000; iteration += 1) {
|
||||
const text = iteration % 2 === 0 ? randomString(random, 24) : rawUtf16(random, 24)
|
||||
if (terminalStreamByteLength(text) !== legacyByteLength(text)) {
|
||||
throw new Error(`byte length diverged for ${JSON.stringify(text)}`)
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the legacy exceededLimit decision across a dense (string, limit) sweep', () => {
|
||||
const random = mulberry32(0xc0ffee)
|
||||
let compared = 0
|
||||
for (let iteration = 0; iteration < 10000; iteration += 1) {
|
||||
const text = iteration % 2 === 0 ? randomString(random, 16) : rawUtf16(random, 16)
|
||||
const total = legacyByteLength(text)
|
||||
// Sweep every limit from below zero to past the true total so the boundary is hit exactly.
|
||||
for (let limit = -2; limit <= total + 2; limit += 1) {
|
||||
if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) {
|
||||
throw new Error(`exceededLimit diverged for ${JSON.stringify(text)} at limit ${limit}`)
|
||||
}
|
||||
compared += 1
|
||||
}
|
||||
}
|
||||
expect(compared).toBeGreaterThan(100000)
|
||||
})
|
||||
|
||||
it('matches the legacy exceededLimit decision for non-finite and fractional limits', () => {
|
||||
const random = mulberry32(0xfeed42)
|
||||
for (const limit of [
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
0.5,
|
||||
2.5,
|
||||
-0.5,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
]) {
|
||||
for (let iteration = 0; iteration < 400; iteration += 1) {
|
||||
const text = rawUtf16(random, 12)
|
||||
expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit))
|
||||
}
|
||||
for (const text of EDGE_STRINGS) {
|
||||
expect(terminalStreamByteLengthExceeds(text, limit)).toBe(legacyExceeds(text, limit))
|
||||
for (const text of samples) {
|
||||
const byteLength = Buffer.byteLength(text, 'utf8')
|
||||
expect(terminalStreamByteLength(text), JSON.stringify(text)).toBe(byteLength)
|
||||
expect(measureTerminalStreamByteLength(text)).toEqual({
|
||||
byteLength,
|
||||
exceededLimit: false
|
||||
})
|
||||
for (const limit of [-1, 0, 0.5, byteLength - 1, byteLength, byteLength + 1]) {
|
||||
expect(
|
||||
terminalStreamByteLengthExceeds(text, limit),
|
||||
`${JSON.stringify(text)} at ${limit}`
|
||||
).toBe(text.length > 0 && byteLength > limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the legacy measurement pair, byteLength included, across a stopAfterBytes sweep', () => {
|
||||
const random = mulberry32(0xa11ce)
|
||||
for (let iteration = 0; iteration < 3000; iteration += 1) {
|
||||
const text = iteration % 2 === 0 ? randomString(random, 20) : rawUtf16(random, 20)
|
||||
const total = legacyByteLength(text)
|
||||
for (let stopAfterBytes = -1; stopAfterBytes <= total + 2; stopAfterBytes += 1) {
|
||||
const actual = measureTerminalStreamByteLength(text, { stopAfterBytes })
|
||||
const expected = legacyMeasure(text, { stopAfterBytes })
|
||||
if (
|
||||
actual.byteLength !== expected.byteLength ||
|
||||
actual.exceededLimit !== expected.exceededLimit
|
||||
) {
|
||||
throw new Error(
|
||||
`measurement diverged for ${JSON.stringify(text)} at stopAfterBytes ${stopAfterBytes}`
|
||||
)
|
||||
}
|
||||
}
|
||||
it.each([
|
||||
{ text: '', limit: -1, byteLength: 0, exceededLimit: false },
|
||||
{ text: 'abc', limit: 0, byteLength: 1, exceededLimit: true },
|
||||
{ text: 'éé', limit: 2, byteLength: 4, exceededLimit: true },
|
||||
{ text: '😀😀😀', limit: 5, byteLength: 8, exceededLimit: true },
|
||||
{ text: '\ud800z', limit: 2, byteLength: 3, exceededLimit: true },
|
||||
{ text: '😀'.repeat(9), limit: 5, byteLength: 8, exceededLimit: true },
|
||||
{ text: 'é'.repeat(16), limit: 32, byteLength: 32, exceededLimit: false },
|
||||
{ text: '走'.repeat(16), limit: 48, byteLength: 48, exceededLimit: false }
|
||||
])(
|
||||
'measures only through the first code point over $limit bytes',
|
||||
({ text, limit, ...expected }) => {
|
||||
expect(measureTerminalStreamByteLength(text, { stopAfterBytes: limit })).toEqual(expected)
|
||||
}
|
||||
expect(
|
||||
measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 })
|
||||
).toEqual(legacyMeasure('\u{1f600}\u{1f600}\u{1f600}', { stopAfterBytes: 5 }))
|
||||
})
|
||||
)
|
||||
|
||||
it('keeps the partial byteLength the legacy scan returned when the limit is exceeded', () => {
|
||||
// A drop-in Buffer.byteLength would report 12 here; the legacy scan stops at 8.
|
||||
const measurement = measureTerminalStreamByteLength('\u{1f600}\u{1f600}\u{1f600}', {
|
||||
stopAfterBytes: 5
|
||||
})
|
||||
expect(measurement).toEqual({ byteLength: 8, exceededLimit: true })
|
||||
})
|
||||
|
||||
it('matches the legacy measurement with no stopAfterBytes and with an undefined option bag', () => {
|
||||
const random = mulberry32(0xb0b)
|
||||
for (let iteration = 0; iteration < 2000; iteration += 1) {
|
||||
const text = rawUtf16(random, 32)
|
||||
expect(measureTerminalStreamByteLength(text)).toEqual(legacyMeasure(text))
|
||||
expect(measureTerminalStreamByteLength(text, {})).toEqual(legacyMeasure(text, {}))
|
||||
expect(measureTerminalStreamByteLength(text, { stopAfterBytes: undefined })).toEqual(
|
||||
legacyMeasure(text, { stopAfterBytes: undefined })
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// The code-unit floor routes short inputs back through the scan. Both sides of that
|
||||
// boundary must stay legacy-identical, so sweep it exhaustively rather than by sampling.
|
||||
it('matches the legacy result on both sides of the native-call floor', () => {
|
||||
const random = mulberry32(0xf100a)
|
||||
for (let units = 0; units <= MIN_NATIVE_BYTE_LENGTH_CODE_UNITS * 2; units += 1) {
|
||||
for (let iteration = 0; iteration < 60; iteration += 1) {
|
||||
let text = ''
|
||||
while (text.length < units) {
|
||||
text +=
|
||||
iteration % 2 === 0
|
||||
? randomUnit(random)
|
||||
: String.fromCharCode(Math.floor(random() * 0x11000))
|
||||
}
|
||||
text = text.slice(0, units)
|
||||
const total = legacyByteLength(text)
|
||||
expect(terminalStreamByteLength(text)).toBe(total)
|
||||
for (let limit = -1; limit <= total + 2; limit += 1) {
|
||||
if (terminalStreamByteLengthExceeds(text, limit) !== legacyExceeds(text, limit)) {
|
||||
throw new Error(`exceeds diverged at ${units} units, limit ${limit}`)
|
||||
}
|
||||
const actual = measureTerminalStreamByteLength(text, { stopAfterBytes: limit })
|
||||
const expected = legacyMeasure(text, { stopAfterBytes: limit })
|
||||
if (
|
||||
actual.byteLength !== expected.byteLength ||
|
||||
actual.exceededLimit !== expected.exceededLimit
|
||||
) {
|
||||
throw new Error(`measurement diverged at ${units} units, limit ${limit}`)
|
||||
}
|
||||
}
|
||||
it('treats non-finite limits as unbounded for both scan and native inputs', () => {
|
||||
for (const text of ['', '😀é走a', '😀é走a'.repeat(8)]) {
|
||||
for (const limit of [Number.NaN, Infinity, -Infinity]) {
|
||||
expect(terminalStreamByteLengthExceeds(text, limit)).toBe(false)
|
||||
expect(measureTerminalStreamByteLength(text, { stopAfterBytes: limit })).toEqual({
|
||||
byteLength: Buffer.byteLength(text, 'utf8'),
|
||||
exceededLimit: false
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// trimPendingOutputCoveredBySnapshot re-measures a sliced chunk with terminalStreamByteLength.
|
||||
// The RPC suites never reach that slice branch (a `data.length` mutant there survives on
|
||||
// unmodified HEAD too), so pin the byte accounting the branch depends on here.
|
||||
describe('resync trim byte accounting for a snapshot-sliced chunk', () => {
|
||||
it('re-measures a sliced chunk in UTF-8 bytes, not UTF-16 code units', () => {
|
||||
const data = '\u{1f600}é走a'
|
||||
const sliced = data.slice(2)
|
||||
// Guards the mutant: code-unit length would be 4 here, UTF-8 is 6.
|
||||
expect(terminalStreamByteLength(sliced)).toBe(6)
|
||||
expect(terminalStreamByteLength(sliced)).not.toBe(sliced.length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import { GIT_METHODS } from './rpc/methods/git'
|
||||
import { RuntimeGitCommands } from './orca-runtime-git'
|
||||
|
||||
const RPC_TO_RUNTIME_COMMAND = {
|
||||
'git.status': 'getRuntimeGitStatus',
|
||||
'git.checkIgnored': 'checkRuntimeGitIgnoredPaths',
|
||||
'git.submoduleStatus': 'getRuntimeGitSubmoduleStatus',
|
||||
'git.history': 'getRuntimeGitHistory',
|
||||
'git.conflictOperation': 'getRuntimeGitConflictOperation',
|
||||
'git.mutationInFlight': 'getRuntimeGitMutationInFlight',
|
||||
'git.abortMerge': 'abortRuntimeGitMerge',
|
||||
'git.abortRebase': 'abortRuntimeGitRebase',
|
||||
'git.checkout': 'checkoutRuntimeGitBranch',
|
||||
'git.localBranches': 'listRuntimeGitLocalBranches',
|
||||
'git.diff': 'getRuntimeGitDiff',
|
||||
'git.branchDiff': 'getRuntimeGitBranchDiff',
|
||||
'git.commitDiff': 'getRuntimeGitCommitDiff',
|
||||
'git.branchCompare': 'getRuntimeGitBranchCompare',
|
||||
'git.commitCompare': 'getRuntimeGitCommitCompare',
|
||||
'git.upstreamStatus': 'getRuntimeGitUpstreamStatus',
|
||||
'git.fetch': 'fetchRuntimeGit',
|
||||
'git.forkSync': 'syncRuntimeGitForkDefaultBranch',
|
||||
'git.pull': 'pullRuntimeGit',
|
||||
'git.fastForward': 'fastForwardRuntimeGit',
|
||||
'git.rebaseFromBase': 'rebaseRuntimeGitFromBase',
|
||||
'git.push': 'pushRuntimeGit',
|
||||
'git.commit': 'commitRuntimeGit',
|
||||
'git.generateCommitMessage': 'generateRuntimeCommitMessage',
|
||||
'git.discoverCommitMessageModels': 'discoverRuntimeCommitMessageModels',
|
||||
'git.cancelGenerateCommitMessage': 'cancelRuntimeGenerateCommitMessage',
|
||||
'git.generatePullRequestFields': 'generateRuntimePullRequestFields',
|
||||
'git.cancelGeneratePullRequestFields': 'cancelRuntimeGeneratePullRequestFields',
|
||||
'git.stage': 'stageRuntimeGitPath',
|
||||
'git.bulkStage': 'bulkStageRuntimeGitPaths',
|
||||
'git.unstage': 'unstageRuntimeGitPath',
|
||||
'git.bulkUnstage': 'bulkUnstageRuntimeGitPaths',
|
||||
'git.discard': 'discardRuntimeGitPath',
|
||||
'git.bulkDiscard': 'bulkDiscardRuntimeGitPaths',
|
||||
'git.remoteFileUrl': 'getRuntimeGitRemoteFileUrl',
|
||||
'git.remoteCommitUrl': 'getRuntimeGitRemoteCommitUrl'
|
||||
} as const satisfies Record<string, keyof RuntimeGitCommands>
|
||||
|
||||
describe('runtime Git API contract', () => {
|
||||
it('keeps every registered Git RPC paired with one public runtime command', () => {
|
||||
const commands = new RuntimeGitCommands({
|
||||
resolveRuntimeGitTarget: async () => {
|
||||
throw new Error('not called')
|
||||
},
|
||||
getRuntimeSettings: () => ({}) as GlobalSettings
|
||||
})
|
||||
const registeredMethods = GIT_METHODS.map((method) => method.name).sort()
|
||||
|
||||
expect(registeredMethods).toEqual(Object.keys(RPC_TO_RUNTIME_COMMAND).sort())
|
||||
for (const commandName of Object.values(RPC_TO_RUNTIME_COMMAND)) {
|
||||
expect(commands[commandName]).toBeTypeOf('function')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const EDITOR_DIR = __dirname
|
||||
const RAW_WRITE = /\b(?:writeRuntimeFile|fs\.writeFile)\s*\(/
|
||||
const EDITOR_WRITE = /\bwriteEditorSave\s*\(/
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name)
|
||||
if (statSync(path).isDirectory()) {
|
||||
return sourceFiles(path)
|
||||
}
|
||||
return /\.tsx?$/.test(name) && !/\.test\.tsx?$|fixture/.test(name) ? [path] : []
|
||||
})
|
||||
}
|
||||
|
||||
function filesMatching(dir: string, pattern: RegExp): string[] {
|
||||
return sourceFiles(dir)
|
||||
.filter((path) => pattern.test(readFileSync(path, 'utf8')))
|
||||
.map((path) => relative(EDITOR_DIR, path).split('\\').join('/'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
function functionBody(source: string, name: string): string {
|
||||
const body = source.slice(source.indexOf(`export function ${name}`))
|
||||
return body.slice(0, body.indexOf('\n}\n'))
|
||||
}
|
||||
|
||||
describe('editor save paths', () => {
|
||||
it('make the raw file write only inside writeEditorSave', () => {
|
||||
expect(filesMatching(EDITOR_DIR, RAW_WRITE)).toEqual(['editor-save-disk-precondition.ts'])
|
||||
const source = readFileSync(join(EDITOR_DIR, 'editor-save-disk-precondition.ts'), 'utf8')
|
||||
expect(source.split('writeRuntimeFile(')).toHaveLength(2)
|
||||
const writer = functionBody(source, 'writeEditorSave')
|
||||
expect(writer).toContain('writeRuntimeFile(')
|
||||
// Only the explicit Overwrite reaches the writer without a precondition.
|
||||
expect(writer).toContain("write.kind === 'guarded'")
|
||||
})
|
||||
|
||||
it('route every editor surface that saves a file through writeEditorSave', () => {
|
||||
expect(filesMatching(EDITOR_DIR, EDITOR_WRITE)).toEqual([
|
||||
'combined-diff/review-controls/use-combined-diff-section-actions.ts',
|
||||
'editor-save-disk-precondition.ts',
|
||||
'editor-save-queue.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('never turn a missing or disputed baseline into a write', () => {
|
||||
const source = readFileSync(join(EDITOR_DIR, 'editor-save-disk-precondition.ts'), 'utf8')
|
||||
const builder = functionBody(source, 'editorSavePrecondition')
|
||||
expect(builder).not.toMatch(/return null|return undefined/)
|
||||
expect(builder.match(/kind: 'explicit-overwrite'/g)).toHaveLength(1)
|
||||
expect(builder).toMatch(
|
||||
/if \(trigger === 'overwrite'\) \{\n\s*return \{ kind: 'explicit-overwrite' \}/
|
||||
)
|
||||
expect(builder).not.toContain('mode')
|
||||
})
|
||||
|
||||
it('grant overwrite consent only from the conflict UI', () => {
|
||||
const grants = filesMatching(
|
||||
join(EDITOR_DIR, '..', '..'),
|
||||
/overwrite: true|\? 'overwrite'|, 'overwrite'\)/
|
||||
)
|
||||
expect(grants).toEqual([
|
||||
'DiffSectionItem.tsx',
|
||||
'ExternalFileChangeBanner.tsx',
|
||||
'editor-autosave-controller.ts'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -9,11 +9,8 @@ import { readFileSync } from 'node:fs'
|
||||
// event sequences match.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects'
|
||||
import { createPtyOutputProcessor } from './pty-output-processor'
|
||||
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
|
||||
const ESC = '\x1b'
|
||||
const BEL = '\x07'
|
||||
@@ -336,187 +333,3 @@ describe('main title tracker parity with the renderer transport processor', () =
|
||||
expect(paths.main.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// Why: slice 3 moves the renderer's OSC 133;D and PR-link byte parsing into
|
||||
// main's tracker for local/SSH PTYs. Both must derive identical fact
|
||||
// sequences from the same chunk boundaries, or flipping the kill switch
|
||||
// changes which commands/links are observed.
|
||||
type LifecycleFactEvent = ['command-finished', number | null] | ['pr-link', string, number]
|
||||
|
||||
type LifecycleFactPath = {
|
||||
events: LifecycleFactEvent[]
|
||||
feed: (chunk: string) => void
|
||||
}
|
||||
|
||||
function createRendererLifecyclePath(): LifecycleFactPath {
|
||||
const events: LifecycleFactEvent[] = []
|
||||
// Why: mirrors pty-connection's dataCallback wiring — the transport
|
||||
// processor strips OSC 9999 before the lifecycle/PR-link byte scans run.
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const lifecycle = createTerminalCommandLifecycle({
|
||||
onCommandFinished: (exitCode) => events.push(['command-finished', exitCode])
|
||||
})
|
||||
const detectPRLinks = createTerminalGitHubPRLinkDetector()
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
const clean = processAgentStatusChunk(chunk).cleanData
|
||||
lifecycle.handlePtyData(clean)
|
||||
for (const link of detectPRLinks(clean)) {
|
||||
events.push(['pr-link', link.url, link.number])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMainLifecyclePath(): LifecycleFactPath {
|
||||
const events: LifecycleFactEvent[] = []
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const tracker = createTerminalTitleTracker({
|
||||
onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]),
|
||||
onPrLink: (link) => events.push(['pr-link', link.url, link.number])
|
||||
})
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
tracker.handleChunk(processAgentStatusChunk(chunk).cleanData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('main tracker parity with renderer 133;D and PR-link byte parsers', () => {
|
||||
let paths: { renderer: LifecycleFactPath; main: LifecycleFactPath }
|
||||
|
||||
beforeEach(() => {
|
||||
paths = { renderer: createRendererLifecyclePath(), main: createMainLifecyclePath() }
|
||||
})
|
||||
|
||||
it('derives identical command-finished facts from split OSC 133;D chunks', () => {
|
||||
feedBoth(paths, `output${ESC}]133`)
|
||||
feedBoth(paths, ';D;13')
|
||||
feedBoth(paths, `0${BEL}prompt $ `)
|
||||
feedBoth(paths, `${ESC}]133;D;0${BEL}`)
|
||||
feedBoth(paths, `${ESC}]133;D${ST}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
['command-finished', 130],
|
||||
['command-finished', 0],
|
||||
['command-finished', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('derives identical pr-link facts from split and repeated URLs', () => {
|
||||
feedBoth(paths, 'Created https://github.com/acme/orca/pull/4')
|
||||
feedBoth(paths, '2\r\nAlso https://github.com/acme/orca/pull/43 merged\r\n')
|
||||
feedBoth(paths, 'again https://github.com/acme/orca/pull/42\r\n')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
['pr-link', 'https://github.com/acme/orca/pull/42', 42],
|
||||
['pr-link', 'https://github.com/acme/orca/pull/43', 43]
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores 133;D and PR URLs inside stripped OSC 9999 payloads in both paths', () => {
|
||||
feedBoth(
|
||||
paths,
|
||||
`${ESC}]9999;{"state":"done","prompt":"https://github.com/acme/orca/pull/9"}${BEL}\r\n`
|
||||
)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// Why: slice 4 moves the Command Code output scrape into main for local/SSH
|
||||
// PTYs. The renderer byte path observes raw transport data; main observes the
|
||||
// OSC 9999-stripped cleanData. Both must derive identical working/done
|
||||
// sequences from the same chunk boundaries, or flipping the kill switch
|
||||
// changes Command Code status rows.
|
||||
type CommandCodeFactEvent = ['working' | 'done', string]
|
||||
|
||||
type CommandCodeFactPath = {
|
||||
events: CommandCodeFactEvent[]
|
||||
feed: (chunk: string) => void
|
||||
}
|
||||
|
||||
function createCommandCodePath(options: { stripStatusPayloads: boolean }): CommandCodeFactPath {
|
||||
const events: CommandCodeFactEvent[] = []
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const detector = createCommandCodeOutputStatusDetector({
|
||||
startupCommand: null,
|
||||
onWorking: (prompt) => events.push(['working', prompt]),
|
||||
onDone: (prompt) => events.push(['done', prompt])
|
||||
})
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
detector.observe(
|
||||
options.stripStatusPayloads ? processAgentStatusChunk(chunk).cleanData : chunk
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('main Command Code scrape parity with the renderer byte detector', () => {
|
||||
let paths: { renderer: CommandCodeFactPath; main: CommandCodeFactPath }
|
||||
|
||||
beforeEach(() => {
|
||||
paths = {
|
||||
renderer: createCommandCodePath({ stripStatusPayloads: false }),
|
||||
main: createCommandCodePath({ stripStatusPayloads: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('derives identical working facts after the banner arms across chunks', () => {
|
||||
feedBoth(paths, '# Command')
|
||||
feedBoth(paths, ' Code v0.27.3\r\n')
|
||||
feedBoth(paths, '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([['working', 'Fix the spinner']])
|
||||
})
|
||||
|
||||
it('derives identical done facts for a no-tool turn in both paths', () => {
|
||||
feedBoth(paths, '# Command Code v0.27.3\r\n')
|
||||
feedBoth(paths, '❯ say hi\r\n✻ Thinking...')
|
||||
feedBoth(paths, '\r\n:: Hi!\r\n❯ Ask your question...')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
['working', 'say hi'],
|
||||
['done', 'say hi']
|
||||
])
|
||||
})
|
||||
|
||||
it('recovers prompt capture from interleaved OSC 9999 payloads (main improvement)', () => {
|
||||
// Deliberate divergence, not drift: the renderer's raw byte path lets an
|
||||
// OSC 9999 payload leak partial text into the scrape window (its ANSI
|
||||
// strip consumes only the ESC] introducer), which breaks the prompt-echo
|
||||
// line match. Main feeds the OSC 9999-stripped cleanData, so the prompt
|
||||
// (and therefore the done settle hint) survives an adjacent payload.
|
||||
const payloadThenPrompt = [
|
||||
'# Command Code v0.27.3\r\n',
|
||||
`${ESC}]9999;{"state":"working","agentType":"command-code"}${BEL}`,
|
||||
'❯ say hi\r\n✻ Thinking...',
|
||||
'\r\n:: Hi!\r\n❯ Ask your question...'
|
||||
]
|
||||
for (const chunk of payloadThenPrompt) {
|
||||
feedBoth(paths, chunk)
|
||||
}
|
||||
|
||||
expect(paths.renderer.events).toEqual([['working', '']])
|
||||
expect(paths.main.events).toEqual([
|
||||
['working', 'say hi'],
|
||||
['done', 'say hi']
|
||||
])
|
||||
})
|
||||
|
||||
it('stays silent without the Command Code banner in both paths', () => {
|
||||
feedBoth(paths, '❯ Fix the spinner\r\nThinking about unrelated shell output...')
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,210 +1,7 @@
|
||||
/**
|
||||
* Proves the substring/char-code guards added to `cross-platform-path.ts` and `parseWslUncPath`
|
||||
* are pure fast paths: a seeded differential fuzz against the pre-guard copy in
|
||||
* `cross-platform-path-unguarded.test-fixture.ts`, plus counters that fail if the guards regress.
|
||||
*/
|
||||
import { describe, expect, it, afterEach } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import * as guarded from './cross-platform-path'
|
||||
import * as unguarded from './cross-platform-path-unguarded.test-fixture'
|
||||
import { parseWslUncPath } from './wsl-paths'
|
||||
|
||||
// ─── Deterministic path generator ────────────────────────────────────
|
||||
|
||||
function createRandom(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const PREFIXES = [
|
||||
'',
|
||||
'/',
|
||||
'//',
|
||||
'///',
|
||||
'.',
|
||||
'./',
|
||||
'../',
|
||||
'C:/',
|
||||
'c:\\',
|
||||
'Z:',
|
||||
'\\\\',
|
||||
'\\\\wsl.localhost\\Ubuntu',
|
||||
'//wsl.localhost/Ubuntu-22.04',
|
||||
'//WSL$/Debian',
|
||||
'\\\\wsl$\\ubuntu',
|
||||
'//server/share',
|
||||
'/mnt/c',
|
||||
'\\\\wsl.localhost\\Ubuntu\\mnt\\c'
|
||||
]
|
||||
|
||||
// NFD + KELVIN SIGN are the folds `normalizeRuntimePathForComparison` is built around.
|
||||
const SEGMENTS = [
|
||||
'home',
|
||||
'user',
|
||||
'orca',
|
||||
'workspaces',
|
||||
'..',
|
||||
'.',
|
||||
'',
|
||||
'a',
|
||||
'B',
|
||||
'wsl$',
|
||||
'wsl.localhost',
|
||||
'mnt',
|
||||
'c',
|
||||
'C',
|
||||
'répertoire',
|
||||
're\u0301pertoire',
|
||||
'\u212Aelvin',
|
||||
'Kelvin',
|
||||
'back\\slash',
|
||||
'sp ace',
|
||||
'Ubuntu'
|
||||
]
|
||||
|
||||
const JOINERS = ['/', '/', '/', '//', '///', '\\', '\\\\']
|
||||
const SUFFIXES = ['', '', '', '/', '//', '\\', '/.', '/..']
|
||||
|
||||
function generatePath(random: () => number): string {
|
||||
const pick = <T>(items: readonly T[]): T => items[Math.floor(random() * items.length)]
|
||||
let path = pick(PREFIXES)
|
||||
const segmentCount = Math.floor(random() * 5)
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
path += (path === '' ? '' : pick(JOINERS)) + pick(SEGMENTS)
|
||||
}
|
||||
return path + pick(SUFFIXES)
|
||||
}
|
||||
|
||||
/** Roots that actually contain the candidate, so the matching branches get exercised too. */
|
||||
function generateRoot(random: () => number, candidate: string): string {
|
||||
const roll = random()
|
||||
if (roll < 0.35) {
|
||||
const cut = Math.floor(random() * (candidate.length + 1))
|
||||
return candidate.slice(0, cut)
|
||||
}
|
||||
if (roll < 0.45) {
|
||||
return candidate
|
||||
}
|
||||
return generatePath(random)
|
||||
}
|
||||
|
||||
// ─── Differential fuzz ───────────────────────────────────────────────
|
||||
|
||||
const FUZZ_ITERATIONS = 20_000
|
||||
|
||||
describe('guarded path normalization matches the pre-guard implementation', () => {
|
||||
it(`agrees on every export across ${FUZZ_ITERATIONS} seeded paths`, () => {
|
||||
const random = createRandom(0x5eed)
|
||||
const mismatches: string[] = []
|
||||
const record = (label: string, path: string, root: string): void => {
|
||||
if (mismatches.length < 5) {
|
||||
mismatches.push(`${label}: candidate=${JSON.stringify(path)} root=${JSON.stringify(root)}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (let iteration = 0; iteration < FUZZ_ITERATIONS; iteration++) {
|
||||
const path = generatePath(random)
|
||||
const root = generateRoot(random, path)
|
||||
const distro = Math.floor(random() * 2) === 0 ? 'Ubuntu' : 'debian'
|
||||
|
||||
const singles: [string, (value: string) => unknown, (value: string) => unknown][] = [
|
||||
[
|
||||
'isWindowsAbsolutePathLike',
|
||||
guarded.isWindowsAbsolutePathLike,
|
||||
unguarded.isWindowsAbsolutePathLike
|
||||
],
|
||||
[
|
||||
'isCaseInsensitiveRuntimeRoot',
|
||||
guarded.isCaseInsensitiveRuntimeRoot,
|
||||
unguarded.isCaseInsensitiveRuntimeRoot
|
||||
],
|
||||
[
|
||||
'normalizeRuntimePathSeparators',
|
||||
guarded.normalizeRuntimePathSeparators,
|
||||
unguarded.normalizeRuntimePathSeparators
|
||||
],
|
||||
[
|
||||
'normalizeRuntimePathForComparison',
|
||||
guarded.normalizeRuntimePathForComparison,
|
||||
unguarded.normalizeRuntimePathForComparison
|
||||
],
|
||||
['isRuntimePathAbsolute', guarded.isRuntimePathAbsolute, unguarded.isRuntimePathAbsolute],
|
||||
['getRuntimePathBasename', guarded.getRuntimePathBasename, unguarded.getRuntimePathBasename]
|
||||
]
|
||||
for (const [label, left, right] of singles) {
|
||||
if (left(path) !== right(path)) {
|
||||
record(label, path, root)
|
||||
}
|
||||
}
|
||||
|
||||
const identity = guarded.getLocalWindowsWslPathIdentity(path)
|
||||
const expectedIdentity = unguarded.getLocalWindowsWslPathIdentity(path)
|
||||
if (
|
||||
identity.normalizedPath !== expectedIdentity.normalizedPath ||
|
||||
identity.aliasComparisonPath !== expectedIdentity.aliasComparisonPath ||
|
||||
identity.isWslUnc !== expectedIdentity.isWslUnc
|
||||
) {
|
||||
record('getLocalWindowsWslPathIdentity', path, root)
|
||||
}
|
||||
const wslUnc = parseWslUncPath(path)
|
||||
const expectedWslUnc = unguarded.parseWslUncPath(path)
|
||||
if (
|
||||
wslUnc?.distro !== expectedWslUnc?.distro ||
|
||||
wslUnc?.linuxPath !== expectedWslUnc?.linuxPath
|
||||
) {
|
||||
record('parseWslUncPath', path, root)
|
||||
}
|
||||
if (
|
||||
guarded.areLocalWindowsWslPathAliases(root, path) !==
|
||||
unguarded.areLocalWindowsWslPathAliases(root, path)
|
||||
) {
|
||||
record('areLocalWindowsWslPathAliases', path, root)
|
||||
}
|
||||
if (
|
||||
guarded.isWslUncPathForCallerLinuxPath(root, path, distro) !==
|
||||
unguarded.isWslUncPathForCallerLinuxPath(root, path, distro)
|
||||
) {
|
||||
record('isWslUncPathForCallerLinuxPath', path, root)
|
||||
}
|
||||
if (
|
||||
guarded.isWslUncPathForLinuxMountedPath(root, path) !==
|
||||
unguarded.isWslUncPathForLinuxMountedPath(root, path)
|
||||
) {
|
||||
record('isWslUncPathForLinuxMountedPath', path, root)
|
||||
}
|
||||
if (guarded.resolveRuntimePath(root, path) !== unguarded.resolveRuntimePath(root, path)) {
|
||||
record('resolveRuntimePath', path, root)
|
||||
}
|
||||
if (guarded.isPathInsideOrEqual(root, path) !== unguarded.isPathInsideOrEqual(root, path)) {
|
||||
record('isPathInsideOrEqual', path, root)
|
||||
}
|
||||
if (
|
||||
guarded.createNormalizedPathInsideOrEqualMatcher(root)(
|
||||
guarded.normalizeRuntimePathForComparison(path)
|
||||
) !==
|
||||
unguarded.createNormalizedPathInsideOrEqualMatcher(root)(
|
||||
unguarded.normalizeRuntimePathForComparison(path)
|
||||
)
|
||||
) {
|
||||
record('createNormalizedPathInsideOrEqualMatcher', path, root)
|
||||
}
|
||||
if (
|
||||
guarded.relativePathInsideRoot(root, path) !== unguarded.relativePathInsideRoot(root, path)
|
||||
) {
|
||||
record('relativePathInsideRoot', path, root)
|
||||
}
|
||||
}
|
||||
|
||||
expect(mismatches).toEqual([])
|
||||
}, 120_000)
|
||||
})
|
||||
|
||||
// ─── The guards must not skip work that was actually needed ──────────
|
||||
|
||||
describe('guards still do the work when the fast path does not apply', () => {
|
||||
it('collapses doubled slashes', () => {
|
||||
expect(guarded.normalizeRuntimePathForComparison('/a//b///c')).toBe('/a/b/c')
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Verbatim pre-guard copy of `cross-platform-path.ts` and `parseWslUncPath`, kept only so
|
||||
* `cross-platform-path-guards.test.ts` can differentially fuzz the guarded versions
|
||||
* against what shipped. Comments were stripped; the code is otherwise unchanged. Update this file
|
||||
* only when the guarded originals are meant to change behaviour.
|
||||
*/
|
||||
import { toWindowsWslPath } from './wsl-paths'
|
||||
|
||||
type WslUncPathInfo = { distro: string; linuxPath: string }
|
||||
|
||||
export function parseWslUncPath(path: string): WslUncPathInfo | null {
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)(\/.*)?$/i)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return { distro: match[2], linuxPath: match[3] || '/' }
|
||||
}
|
||||
|
||||
function isWslUncPath(path: string): boolean {
|
||||
return parseWslUncPath(path) !== null
|
||||
}
|
||||
|
||||
const SLASH_CHAR_CODE = '/'.charCodeAt(0)
|
||||
|
||||
export function isWindowsAbsolutePathLike(value: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')
|
||||
}
|
||||
|
||||
export function isCaseInsensitiveRuntimeRoot(rootPath: string): boolean {
|
||||
return isWindowsAbsolutePathLike(rootPath) && !isWslUncPath(rootPath)
|
||||
}
|
||||
|
||||
export function normalizeRuntimePathSeparators(value: string): string {
|
||||
const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/')
|
||||
if (value.startsWith('\\\\') || value.startsWith('//')) {
|
||||
return `//${normalized.replace(/^\/+/, '')}`
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeRuntimePathForComparison(rawValue: string): string {
|
||||
const value = rawValue.normalize('NFC')
|
||||
const isWindowsPath = isWindowsAbsolutePathLike(value)
|
||||
const normalized = trimRuntimePathTrailingSlash(
|
||||
isWindowsPath ? normalizeRuntimePathSeparators(value) : value.replace(/\/+/g, '/')
|
||||
)
|
||||
const wslUnc = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/[\s\S]*)?$/i)
|
||||
if (wslUnc) {
|
||||
return `//wsl/${wslUnc[1].toLowerCase()}${wslUnc[2] ?? ''}`
|
||||
}
|
||||
return isWindowsPath ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
export function isWslUncPathForCallerLinuxPath(
|
||||
uncPath: string,
|
||||
linuxPath: string,
|
||||
callerDistro: string
|
||||
): boolean {
|
||||
const parsed = parseWslUncPath(uncPath)
|
||||
if (!parsed) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
parsed.distro.toLowerCase() === callerDistro.toLowerCase() &&
|
||||
normalizeRuntimePathForComparison(parsed.linuxPath) ===
|
||||
normalizeRuntimePathForComparison(linuxPath)
|
||||
)
|
||||
}
|
||||
|
||||
export function isWslUncPathForLinuxMountedPath(uncPath: string, linuxPath: string): boolean {
|
||||
const parsed = parseWslUncPath(uncPath)
|
||||
if (!parsed || !/^\/mnt\/[A-Za-z](?:\/|$)/.test(parsed.linuxPath)) {
|
||||
return false
|
||||
}
|
||||
if (!/^\/mnt\/[A-Za-z](?:\/|$)/.test(linuxPath)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
normalizeRuntimePathForComparison(toWindowsWslPath(parsed.linuxPath, parsed.distro)) ===
|
||||
normalizeRuntimePathForComparison(toWindowsWslPath(linuxPath, parsed.distro))
|
||||
)
|
||||
}
|
||||
|
||||
export function areLocalWindowsWslPathAliases(left: string, right: string): boolean {
|
||||
const leftIdentity = getLocalWindowsWslPathIdentity(left)
|
||||
const rightIdentity = getLocalWindowsWslPathIdentity(right)
|
||||
return (
|
||||
(leftIdentity.isWslUnc || rightIdentity.isWslUnc) &&
|
||||
leftIdentity.aliasComparisonPath === rightIdentity.aliasComparisonPath
|
||||
)
|
||||
}
|
||||
|
||||
export type LocalWindowsWslPathIdentity = {
|
||||
normalizedPath: string
|
||||
aliasComparisonPath: string
|
||||
isWslUnc: boolean
|
||||
}
|
||||
|
||||
export function getLocalWindowsWslPathIdentity(value: string): LocalWindowsWslPathIdentity {
|
||||
const wslPath = parseWslUncPath(value)
|
||||
const normalizedPath = normalizeRuntimePathForComparison(value)
|
||||
return {
|
||||
normalizedPath,
|
||||
aliasComparisonPath: wslPath
|
||||
? normalizeRuntimePathForComparison(toWindowsWslPath(wslPath.linuxPath, wslPath.distro))
|
||||
: normalizedPath,
|
||||
isWslUnc: wslPath !== null
|
||||
}
|
||||
}
|
||||
|
||||
export function isRuntimePathAbsolute(
|
||||
value: string,
|
||||
pathFlavor: 'posix' | 'windows' = isWindowsPathFlavor(value) ? 'windows' : 'posix'
|
||||
): boolean {
|
||||
if (pathFlavor === 'windows') {
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\') || value.startsWith('/')
|
||||
}
|
||||
return value.startsWith('/')
|
||||
}
|
||||
|
||||
export function resolveRuntimePath(basePath: string, targetPath: string): string {
|
||||
const pathFlavor =
|
||||
isWindowsPathFlavor(basePath) || isWindowsPathFlavor(targetPath) ? 'windows' : 'posix'
|
||||
if (isRuntimePathAbsolute(targetPath, pathFlavor)) {
|
||||
return normalizeRuntimePathDots(targetPath, pathFlavor)
|
||||
}
|
||||
return normalizeRuntimePathDots(
|
||||
`${trimRuntimePathTrailingSlash(normalizeRuntimePathSeparators(basePath))}/${targetPath}`,
|
||||
pathFlavor
|
||||
)
|
||||
}
|
||||
|
||||
export function getRuntimePathBasename(value: string): string {
|
||||
const trimmed = value.replace(/[\\/]+$/g, '')
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
return trimmed.split(/[\\/]/).findLast(Boolean) ?? ''
|
||||
}
|
||||
|
||||
export function createNormalizedPathInsideOrEqualMatcher(
|
||||
rootPath: string
|
||||
): (normalizedCandidate: string) => boolean {
|
||||
const root = normalizeRuntimePathForComparison(rootPath)
|
||||
const rootWithBoundary =
|
||||
root === '/' || /^[a-z]:\/$/i.test(root) ? root : `${root.replace(/\/+$/, '')}/`
|
||||
return (normalizedCandidate) =>
|
||||
normalizedCandidate === root || normalizedCandidate.startsWith(rootWithBoundary)
|
||||
}
|
||||
|
||||
export function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean {
|
||||
return createNormalizedPathInsideOrEqualMatcher(rootPath)(
|
||||
normalizeRuntimePathForComparison(candidatePath)
|
||||
)
|
||||
}
|
||||
|
||||
export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null {
|
||||
const normalizedCandidate = trimRuntimePathTrailingSlash(
|
||||
isWindowsAbsolutePathLike(candidatePath.normalize('NFC'))
|
||||
? normalizeRuntimePathSeparators(candidatePath)
|
||||
: candidatePath.replace(/\/+/g, '/')
|
||||
)
|
||||
const comparisonRoot = normalizeRuntimePathForComparison(rootPath)
|
||||
const comparisonCandidate = normalizeRuntimePathForComparison(candidatePath)
|
||||
|
||||
if (comparisonCandidate === comparisonRoot) {
|
||||
return ''
|
||||
}
|
||||
const isRoot = comparisonRoot === '/' || /^[a-z]:\/$/i.test(comparisonRoot)
|
||||
const comparisonPrefix = isRoot ? comparisonRoot : `${comparisonRoot}/`
|
||||
if (!comparisonCandidate.startsWith(comparisonPrefix)) {
|
||||
return null
|
||||
}
|
||||
return sliceCandidatePastRootSegments(comparisonRoot, normalizedCandidate)
|
||||
}
|
||||
|
||||
function sliceCandidatePastRootSegments(root: string, candidate: string): string {
|
||||
let remainingRootSegments = 0
|
||||
let inRootSegment = false
|
||||
for (let index = 0; index < root.length; index++) {
|
||||
if (root.charCodeAt(index) === SLASH_CHAR_CODE) {
|
||||
inRootSegment = false
|
||||
} else if (!inRootSegment) {
|
||||
inRootSegment = true
|
||||
remainingRootSegments++
|
||||
}
|
||||
}
|
||||
|
||||
let inSegment = false
|
||||
for (let index = 0; index < candidate.length; index++) {
|
||||
if (candidate.charCodeAt(index) === SLASH_CHAR_CODE) {
|
||||
inSegment = false
|
||||
continue
|
||||
}
|
||||
if (!inSegment) {
|
||||
inSegment = true
|
||||
if (remainingRootSegments-- === 0) {
|
||||
return candidate.slice(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function trimRuntimePathTrailingSlash(value: string): string {
|
||||
if (value === '/' || /^[A-Za-z]:\/$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return value.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function isWindowsPathFlavor(value: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.includes('\\') || value.startsWith('//')
|
||||
}
|
||||
|
||||
function normalizeRuntimePathDots(value: string, pathFlavor: 'posix' | 'windows'): string {
|
||||
const normalized = normalizeRuntimePathSeparators(value)
|
||||
const { root, rest } = splitRuntimePathRoot(normalized, pathFlavor)
|
||||
const segments: string[] = []
|
||||
for (const segment of rest.split('/')) {
|
||||
if (!segment || segment === '.') {
|
||||
continue
|
||||
}
|
||||
if (segment === '..') {
|
||||
if (segments.length > 0 && segments.at(-1) !== '..') {
|
||||
segments.pop()
|
||||
} else if (!root) {
|
||||
segments.push(segment)
|
||||
}
|
||||
continue
|
||||
}
|
||||
segments.push(segment)
|
||||
}
|
||||
const suffix = segments.join('/')
|
||||
if (!root) {
|
||||
return suffix || '.'
|
||||
}
|
||||
return suffix ? `${root}${suffix}` : trimRuntimePathTrailingSlash(root)
|
||||
}
|
||||
|
||||
function splitRuntimePathRoot(
|
||||
value: string,
|
||||
pathFlavor: 'posix' | 'windows'
|
||||
): { root: string; rest: string } {
|
||||
if (pathFlavor === 'windows') {
|
||||
const drive = value.match(/^([A-Za-z]:)(?:\/|$)/)
|
||||
if (drive) {
|
||||
return { root: `${drive[1]}/`, rest: value.slice(drive[0].length) }
|
||||
}
|
||||
if (value.startsWith('//')) {
|
||||
const parts = value.slice(2).split('/')
|
||||
if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
const root = `//${parts[0]}/${parts[1]}/`
|
||||
return { root, rest: parts.slice(2).join('/') }
|
||||
}
|
||||
return { root: '//', rest: value.slice(2) }
|
||||
}
|
||||
if (value.startsWith('/')) {
|
||||
return { root: '/', rest: value.slice(1) }
|
||||
}
|
||||
}
|
||||
if (value.startsWith('/')) {
|
||||
return { root: '/', rest: value.slice(1) }
|
||||
}
|
||||
return { root: '', rest: value }
|
||||
}
|
||||
Reference in New Issue
Block a user