Files
orca/src/shared/file-write-disk-precondition.test.ts
T
NeilandClaude 7a912a3a24 Autosave relies on the guarded save instead of pause flags; identical saves don't rewrite; non-UTF-8 files aren't autosaved (#31768)
* Autosave relies on the guarded write instead of pausing restored or moved tabs

Delete the pendingDiskBaselineVerification and pendingLiveDiskVerification
pause flags. Every editor save is already a guarded write; a refusal raises
the changed-on-disk banner, which is what stops autosave until an explicit
save.

A guarded write now succeeds when disk changed but already holds exactly the
text being saved (local/host compare is byte-exact; client-side fallbacks
compare decoded text and refuse when it holds U+FFFD), so a restored draft
that matches disk goes clean instead of showing a banner.

The restore scan only raises the banner early for tabs that arrive dirty; a
watch resync no longer re-checks dirty tabs.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): skip guarded saves when disk is already identical

Return a compatible optional no-op result from hosts and client fallbacks, clean the tab, and clear its provisional self-write stamp without rewriting identical bytes.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): refuse lossy automatic saves, keep identical-skip on older hosts, flag dirty tabs on resync

- Autosave and pre-restart saves refuse when disk text can't be decoded losslessly, so a
  baseline that decoded to U+FFFD can't authorize overwriting different bytes.
- New files.write.identical-noop.v1 capability; for hosts with only the v1 guarded write the
  client compares first, skips identical saves, then delegates to the host's guarded write.
- Watch resync compares dirty tabs against disk and shows the changed-on-disk banner.
- Local guarded writes check for identical content before the write-only regular-file probe.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): tell non-UTF-8 files apart from disk changes, and quiet false resync banners

- Local guarded writes judge losslessness by bytes, so a valid UTF-8 file containing U+FFFD
  autosaves again. A file that really isn't UTF-8 is refused with its own reason, 'not-utf8'
  (older clients read it as 'changed'), which pauses autosave for that file with one notice
  until an explicit save converts it.
- A watch resync whose disk read fails leaves dirty tabs alone; the guarded save still catches
  a real conflict.
- Resync and restored-tab checks treat disk holding the tab's own just-written save as an echo.
- On hosts with only the v1 guarded write, only automatic saves read the file first; explicit
  saves go straight to the host's guard.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(editor): lift the non-UTF-8 autosave pause when its edits end, and report disk changes first

- The pause is released once no dirty tab of the file remains (close, reload from disk,
  discard), so a reopened or reloaded file's next autosave asks the host again and notifies.
- Client-side precondition checks compare the signature before looking for U+FFFD, so an
  external rewrite shows the changed-on-disk banner rather than the non-UTF-8 notice.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-21 19:20:04 -07:00

130 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
diskBaselineSignatureLength,
getDiskBaselineSignature,
utf8ByteSizeCanDecodeToLength
} from './disk-baseline-signature'
import {
compareDiskWritePrecondition,
diskConflictFromReadFailure,
parseFileWriteResult
} from './file-write-disk-precondition'
describe('disk write precondition', () => {
it('compares decoded disk text against the baseline signature', () => {
const precondition = { expectedSignature: getDiskBaselineSignature('base') }
expect(compareDiskWritePrecondition(precondition, 'base', 'mine')).toBeNull()
expect(compareDiskWritePrecondition(precondition, 'newer', 'mine')).toBe('changed')
expect(compareDiskWritePrecondition(precondition, undefined, 'mine')).toBe('changed')
expect(compareDiskWritePrecondition(precondition, null, 'mine')).toBe('missing')
expect(
compareDiskWritePrecondition({ ...precondition, allowMissing: true }, null, 'mine')
).toBeNull()
})
it('accepts a changed disk that already holds exactly the text being saved', () => {
const precondition = { expectedSignature: getDiskBaselineSignature('base') }
expect(compareDiskWritePrecondition(precondition, 'mine', 'mine')).toBe('identical')
expect(compareDiskWritePrecondition(precondition, 'base', 'base')).toBe('identical')
expect(compareDiskWritePrecondition(precondition, 'mine\r\n', 'mine\n')).toBe('changed')
expect(compareDiskWritePrecondition(precondition, undefined, 'mine')).toBe('changed')
// A replacement character may stand for bytes the decode dropped, so it proves nothing.
expect(compareDiskWritePrecondition(precondition, 'a�', 'a�')).toBe('changed')
})
it('refuses ambiguous automatic baseline matches but preserves explicit saves', () => {
const decoded = Buffer.from([0x61, 0xfe]).toString('utf8')
const current = Buffer.from([0x61, 0xff]).toString('utf8')
const precondition = { expectedSignature: getDiskBaselineSignature(decoded) }
expect(
compareDiskWritePrecondition({ ...precondition, requireLosslessUtf8: true }, current, decoded)
).toBe('not-utf8')
expect(compareDiskWritePrecondition(precondition, current, decoded)).toBeNull()
})
it('reports an external rewrite as changed before checking for U+FFFD', () => {
expect(
compareDiskWritePrecondition(
{ expectedSignature: getDiskBaselineSignature('original'), requireLosslessUtf8: true },
'rewritten �',
'mine'
)
).toBe('changed')
})
it('treats anything but an explicit refusal from a host as a completed write', () => {
expect(parseFileWriteResult({ ok: false, diskConflict: 'not-utf8' })).toEqual({
ok: false,
diskConflict: 'not-utf8'
})
expect(parseFileWriteResult({ ok: false, diskConflict: 'later' })).toEqual({
ok: false,
diskConflict: 'changed'
})
expect(parseFileWriteResult(undefined)).toEqual({ ok: true })
expect(parseFileWriteResult({ ok: true })).toEqual({ ok: true })
expect(parseFileWriteResult({ ok: true, unchanged: true })).toEqual({
ok: true,
unchanged: true
})
expect(parseFileWriteResult({ ok: false, diskConflict: 'missing' })).toEqual({
ok: false,
diskConflict: 'missing'
})
expect(parseFileWriteResult({ ok: false })).toEqual({ ok: false, diskConflict: 'changed' })
})
it('bounds the UTF-8 size a baseline can decode from', () => {
const content = 'a€𝄞'
const length = diskBaselineSignatureLength(getDiskBaselineSignature(content))
expect(length).toBe(content.length)
expect(utf8ByteSizeCanDecodeToLength(Buffer.byteLength(content), length ?? -1)).toBe(true)
expect(utf8ByteSizeCanDecodeToLength(content.length - 1, content.length)).toBe(false)
expect(utf8ByteSizeCanDecodeToLength(content.length * 3 + 1, content.length)).toBe(false)
expect(diskBaselineSignatureLength('not-a-signature')).toBeNull()
})
it.each([
[
'an ENOENT code with plain wording',
Object.assign(new Error('File not found'), { code: 'ENOENT' })
],
['an SFTP-style message', new Error('No such file or directory')]
])('treats %s from an older host as a missing file', (_label, error) => {
const precondition = { expectedSignature: getDiskBaselineSignature('base') }
expect(diskConflictFromReadFailure(precondition, error)).toBe('missing')
})
it.each([
[
'the desktop IPC wrapping',
"Error invoking remote method 'fs:readFile': Error: ENOENT: no such file or directory, open '/repo/a.ts'"
],
['the runtime wording', "ENOENT: no such file or directory, stat '/repo/a.ts'"]
])('reads a not-found from %s', (_label, message) => {
const precondition = { expectedSignature: getDiskBaselineSignature('base') }
expect(diskConflictFromReadFailure(precondition, new Error(message))).toBe('missing')
})
it.each([
"EACCES: permission denied, open '/repo/ENOENT.md'",
"EACCES: permission denied, open '/repo/ENOENT/notes.md'",
"EACCES: permission denied, open '/repo/No such file or directory/notes.md'",
"EACCES: permission denied, open '/repo/Error: ENOENT: no such file or directory.txt'",
"Error invoking remote method 'fs:readFile': Error: EACCES: permission denied, open '/repo/Error: ENOENT: no such file or directory.txt'"
])('never reads a path that merely names ENOENT as a missing file: %s', (message) => {
const precondition = {
expectedSignature: getDiskBaselineSignature('base'),
allowMissing: true
}
expect(() => diskConflictFromReadFailure(precondition, new Error(message))).toThrow('EACCES')
})
it('trusts a conflicting errno code over not-found wording', () => {
const error = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'EACCES' })
expect(() =>
diskConflictFromReadFailure({ expectedSignature: getDiskBaselineSignature('base') }, error)
).toThrow('ENOENT')
})
})