agentic-workflow-manager 9.18.0 → 9.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/plan/index.js +1 -1
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +24 -60
- package/dist/src/core/admission/index.js +11 -1
- package/dist/src/core/changes/command.js +38 -0
- package/dist/src/core/changes/derive.js +120 -0
- package/dist/src/core/changes/fs-safe.js +47 -0
- package/dist/src/core/changes/git-env.js +68 -0
- package/dist/src/core/changes/git.js +216 -0
- package/dist/src/core/changes/list-parse.js +67 -0
- package/dist/src/core/changes/list.js +408 -0
- package/dist/src/core/changes/parse.js +72 -0
- package/dist/src/core/changes/range.js +53 -0
- package/dist/src/core/changes/receipt.js +274 -0
- package/dist/src/core/changes/record-io.js +149 -0
- package/dist/src/core/changes/render-list.js +109 -0
- package/dist/src/core/changes/render.js +178 -0
- package/dist/src/core/changes/text-safety.js +116 -0
- package/dist/src/core/changes/trailers.js +76 -0
- package/dist/src/core/changes/types.js +26 -0
- package/dist/src/core/changes/validate-shape.js +219 -0
- package/dist/src/core/changes/worktree.js +53 -0
- package/dist/src/core/currentness/check.js +5 -2
- package/dist/src/index.js +4 -0
- package/dist/src/release/orchestrator.js +3 -1
- package/dist/tests/commands/change/new.test.js +1126 -0
- package/dist/tests/commands/change/validate.test.js +1145 -0
- package/dist/tests/commands/changes/list.test.js +423 -0
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
- package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
- package/dist/tests/core/admission/index.test.js +60 -0
- package/dist/tests/core/changes/derive.test.js +455 -0
- package/dist/tests/core/changes/fs-safe.test.js +82 -0
- package/dist/tests/core/changes/git-env.test.js +143 -0
- package/dist/tests/core/changes/git.test.js +442 -0
- package/dist/tests/core/changes/helpers.js +269 -0
- package/dist/tests/core/changes/list-fixtures.js +122 -0
- package/dist/tests/core/changes/list-long-history.test.js +52 -0
- package/dist/tests/core/changes/list-paging.test.js +511 -0
- package/dist/tests/core/changes/list-parse.test.js +120 -0
- package/dist/tests/core/changes/list-tree.test.js +44 -0
- package/dist/tests/core/changes/list.test.js +821 -0
- package/dist/tests/core/changes/parse.test.js +142 -0
- package/dist/tests/core/changes/predicates.test.js +83 -0
- package/dist/tests/core/changes/range.test.js +92 -0
- package/dist/tests/core/changes/receipt.test.js +947 -0
- package/dist/tests/core/changes/record-io.test.js +307 -0
- package/dist/tests/core/changes/record.test.js +549 -0
- package/dist/tests/core/changes/render-list.test.js +212 -0
- package/dist/tests/core/changes/render.test.js +202 -0
- package/dist/tests/core/changes/text-safety.test.js +226 -0
- package/dist/tests/core/changes/trailers.test.js +181 -0
- package/dist/tests/core/changes/worktree.test.js +259 -0
- package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- package/dist/tests/release/orchestrator-trailer.test.js +72 -0
- package/dist/tests/release/orchestrator.test.js +1 -0
- package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const helpers_1 = require("./helpers");
|
|
9
|
+
const record_io_1 = require("../../../src/core/changes/record-io");
|
|
10
|
+
const types_1 = require("../../../src/core/changes/types");
|
|
11
|
+
// HOME and AWM_HOME point at a throwaway home for the whole file (makeRepo); every scratch directory lives inside it.
|
|
12
|
+
let repo;
|
|
13
|
+
let n = 0;
|
|
14
|
+
beforeAll(() => { repo = (0, helpers_1.makeRepo)(); });
|
|
15
|
+
afterAll(() => repo.cleanup());
|
|
16
|
+
const scratch = () => fs_1.default.mkdtempSync(path_1.default.join(repo.home, `rio${n++}-`));
|
|
17
|
+
function intercept(name, match, replace) {
|
|
18
|
+
const real = fs_1.default[name];
|
|
19
|
+
jest.spyOn(fs_1.default, name).mockImplementation(((...args) => (match(args) ? replace(real, args) : real(...args))));
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => jest.restoreAllMocks());
|
|
22
|
+
describe('errorCode', () => {
|
|
23
|
+
test.each([['an errno error', Object.assign(new Error('x'), { code: 'ENOENT' }), 'ENOENT'], ['an error without a code', new Error('x'), 'unknown'], ['a non-error', 'boom', 'unknown'], ['null', null, 'unknown'], ['a numeric code', { code: 5 }, 'unknown']])('%s', (_n, e, expected) => {
|
|
24
|
+
expect((0, record_io_1.errorCode)(e)).toBe(expected);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('inspectRecordDir', () => {
|
|
28
|
+
test('is absent when docs does not exist', () => {
|
|
29
|
+
expect((0, record_io_1.inspectRecordDir)(scratch())).toEqual({ kind: 'absent' });
|
|
30
|
+
});
|
|
31
|
+
test('is absent when docs exists without changes', () => {
|
|
32
|
+
const root = scratch();
|
|
33
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
34
|
+
expect((0, record_io_1.inspectRecordDir)(root)).toEqual({ kind: 'absent' });
|
|
35
|
+
});
|
|
36
|
+
test('is ok with the directory when both exist', () => {
|
|
37
|
+
const root = scratch();
|
|
38
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs', 'changes'), { recursive: true });
|
|
39
|
+
expect((0, record_io_1.inspectRecordDir)(root)).toEqual({ kind: 'ok', dir: path_1.default.join(root, 'docs', 'changes') });
|
|
40
|
+
});
|
|
41
|
+
(0, helpers_1.posixOnly)('a symlinked docs is unsafe', () => {
|
|
42
|
+
const root = scratch();
|
|
43
|
+
const outside = scratch();
|
|
44
|
+
fs_1.default.symlinkSync(outside, path_1.default.join(root, 'docs'));
|
|
45
|
+
expect((0, record_io_1.inspectRecordDir)(root)).toEqual({ kind: 'unsafe' });
|
|
46
|
+
});
|
|
47
|
+
(0, helpers_1.posixOnly)('a symlinked changes is unsafe', () => {
|
|
48
|
+
const root = scratch();
|
|
49
|
+
const outside = scratch();
|
|
50
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
51
|
+
fs_1.default.symlinkSync(outside, path_1.default.join(root, 'docs', 'changes'));
|
|
52
|
+
expect((0, record_io_1.inspectRecordDir)(root)).toEqual({ kind: 'unsafe' });
|
|
53
|
+
});
|
|
54
|
+
test('a file in place of changes is unsafe', () => {
|
|
55
|
+
const root = scratch();
|
|
56
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
57
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'changes'), 'x');
|
|
58
|
+
expect((0, record_io_1.inspectRecordDir)(root)).toEqual({ kind: 'unsafe' });
|
|
59
|
+
});
|
|
60
|
+
test('an unreadable path is an error, not absence', () => {
|
|
61
|
+
const root = scratch();
|
|
62
|
+
intercept('lstatSync', (a) => path_1.default.basename(String(a[0])) === 'docs', () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); });
|
|
63
|
+
expect((0, record_io_1.inspectRecordDir)(root)).toEqual({ kind: 'error' });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('createRecordDir', () => {
|
|
67
|
+
test('creates docs and changes', () => {
|
|
68
|
+
const root = scratch();
|
|
69
|
+
expect((0, record_io_1.createRecordDir)(root)).toEqual({ kind: 'ok', dir: path_1.default.join(root, 'docs', 'changes') });
|
|
70
|
+
expect(fs_1.default.statSync(path_1.default.join(root, 'docs', 'changes')).isDirectory()).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
test('accepts directories that already exist', () => {
|
|
73
|
+
const root = scratch();
|
|
74
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs', 'changes'), { recursive: true });
|
|
75
|
+
expect((0, record_io_1.createRecordDir)(root).kind).toBe('ok');
|
|
76
|
+
});
|
|
77
|
+
(0, helpers_1.posixOnly)('refuses an existing symlinked docs without creating anything through it', () => {
|
|
78
|
+
const root = scratch();
|
|
79
|
+
const outside = scratch();
|
|
80
|
+
fs_1.default.symlinkSync(outside, path_1.default.join(root, 'docs'));
|
|
81
|
+
expect((0, record_io_1.createRecordDir)(root)).toEqual({ kind: 'unsafe' });
|
|
82
|
+
expect(fs_1.default.readdirSync(outside)).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
(0, helpers_1.posixOnly)('checks docs right after creating it, before creating changes inside it', () => {
|
|
85
|
+
const root = scratch();
|
|
86
|
+
const outside = scratch();
|
|
87
|
+
intercept('mkdirSync', (a) => path_1.default.basename(String(a[0])) === 'docs', (_r, a) => { fs_1.default.symlinkSync(outside, String(a[0])); });
|
|
88
|
+
expect((0, record_io_1.createRecordDir)(root)).toEqual({ kind: 'unsafe' });
|
|
89
|
+
expect(fs_1.default.readdirSync(outside)).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
(0, helpers_1.posixOnly)('checks changes right after creating it', () => {
|
|
92
|
+
const root = scratch();
|
|
93
|
+
const outside = scratch();
|
|
94
|
+
intercept('mkdirSync', (a) => path_1.default.basename(String(a[0])) === 'changes', (_r, a) => { fs_1.default.symlinkSync(outside, String(a[0])); });
|
|
95
|
+
expect((0, record_io_1.createRecordDir)(root)).toEqual({ kind: 'unsafe' });
|
|
96
|
+
expect(fs_1.default.readdirSync(outside)).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
test('a mkdir failure other than "exists" is an error', () => {
|
|
99
|
+
const root = scratch();
|
|
100
|
+
intercept('mkdirSync', () => true, () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); });
|
|
101
|
+
expect((0, record_io_1.createRecordDir)(root)).toEqual({ kind: 'error' });
|
|
102
|
+
});
|
|
103
|
+
test('a file in place of docs is unsafe', () => {
|
|
104
|
+
const root = scratch();
|
|
105
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs'), 'x');
|
|
106
|
+
expect((0, record_io_1.createRecordDir)(root)).toEqual({ kind: 'unsafe' });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('recordsWithSlug', () => {
|
|
110
|
+
test('lists the records with that slug under any date, and only them', () => {
|
|
111
|
+
const dir = scratch();
|
|
112
|
+
for (const f of ['2026-10-17-a.md', '2026-11-01-a.md', '2026-10-17-ab.md', '2026-10-17-b-a.md', 'a.md', '2026-10-17-A.md', '2026-10-17-a.md.bak'])
|
|
113
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, f), 'x');
|
|
114
|
+
expect((0, record_io_1.recordsWithSlug)(dir, 'a').sort()).toEqual(['2026-10-17-a.md', '2026-11-01-a.md']);
|
|
115
|
+
});
|
|
116
|
+
test('a directory entry named like a record counts', () => {
|
|
117
|
+
const dir = scratch();
|
|
118
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '2026-10-17-a.md'));
|
|
119
|
+
expect((0, record_io_1.recordsWithSlug)(dir, 'a')).toEqual(['2026-10-17-a.md']);
|
|
120
|
+
});
|
|
121
|
+
test('an unreadable directory is null, never an empty list', () => {
|
|
122
|
+
expect((0, record_io_1.recordsWithSlug)(path_1.default.join(scratch(), 'missing'), 'a')).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('readRecord', () => {
|
|
126
|
+
const write = (content) => { const f = path_1.default.join(scratch(), '2026-10-17-x.md'); fs_1.default.writeFileSync(f, content); return f; };
|
|
127
|
+
test('reads the text and a snapshot of the file', () => {
|
|
128
|
+
const f = write('---\nx: 1\n---\n');
|
|
129
|
+
const r = (0, record_io_1.readRecord)(f, '2026-10-17-x.md');
|
|
130
|
+
expect(r).toMatchObject({ ok: true, text: '---\nx: 1\n---\n' });
|
|
131
|
+
expect(r.ok && r.snapshot.size).toBe(13);
|
|
132
|
+
});
|
|
133
|
+
test('keeps a leading BOM as U+FEFF instead of dropping it', () => {
|
|
134
|
+
const r = (0, record_io_1.readRecord)(write(Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('---\n')])), 'n.md');
|
|
135
|
+
expect(r.ok && r.text.charCodeAt(0)).toBe(0xfeff);
|
|
136
|
+
});
|
|
137
|
+
test('refuses invalid UTF-8 as record-invalid', () => {
|
|
138
|
+
expect((0, record_io_1.readRecord)(write(Buffer.from([0xff, 0xfe, 0x0a])), 'n.md')).toMatchObject({ ok: false, code: 'record-invalid' });
|
|
139
|
+
});
|
|
140
|
+
test('refuses a record over the size limit as record-invalid, flagged tooLarge', () => {
|
|
141
|
+
expect((0, record_io_1.readRecord)(write('x'.repeat(types_1.MAX_RECORD_BYTES + 1)), 'n.md')).toMatchObject({ ok: false, code: 'record-invalid', tooLarge: true });
|
|
142
|
+
});
|
|
143
|
+
test('only the size refusal is flagged tooLarge', () => {
|
|
144
|
+
expect((0, record_io_1.readRecord)(write(Buffer.from([0xff, 0xfe, 0x0a])), 'n.md')).not.toHaveProperty('tooLarge');
|
|
145
|
+
expect((0, record_io_1.readRecord)(scratch(), 'n.md')).not.toHaveProperty('tooLarge');
|
|
146
|
+
});
|
|
147
|
+
test('accepts a record of exactly the size limit', () => {
|
|
148
|
+
expect((0, record_io_1.readRecord)(write('x'.repeat(types_1.MAX_RECORD_BYTES)), 'n.md').ok).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
test('refuses a directory as record-invalid', () => {
|
|
151
|
+
expect((0, record_io_1.readRecord)(scratch(), 'n.md')).toMatchObject({ ok: false, code: 'record-invalid' });
|
|
152
|
+
});
|
|
153
|
+
(0, helpers_1.posixOnly)('refuses a symlink as unsafe-path', () => {
|
|
154
|
+
const real = write('x');
|
|
155
|
+
const link = path_1.default.join(scratch(), 'l.md');
|
|
156
|
+
fs_1.default.symlinkSync(real, link);
|
|
157
|
+
expect((0, record_io_1.readRecord)(link, 'l.md')).toMatchObject({ ok: false, code: 'unsafe-path' });
|
|
158
|
+
});
|
|
159
|
+
test('a missing file is io-failed', () => {
|
|
160
|
+
expect((0, record_io_1.readRecord)(path_1.default.join(scratch(), 'gone.md'), 'gone.md')).toMatchObject({ ok: false, code: 'io-failed' });
|
|
161
|
+
});
|
|
162
|
+
test('the message escapes a hostile file name and stays on one line within 300 units', () => {
|
|
163
|
+
const r = (0, record_io_1.readRecord)(path_1.default.join(scratch(), 'gone.md'), 'a\n\u001b[31m'.repeat(60));
|
|
164
|
+
expect(r.ok).toBe(false);
|
|
165
|
+
const message = r.ok ? '' : r.message;
|
|
166
|
+
expect(message).not.toMatch(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/);
|
|
167
|
+
expect(message.length).toBeLessThanOrEqual(300);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('unchangedSince', () => {
|
|
171
|
+
function snapshotOf(content) {
|
|
172
|
+
const file = path_1.default.join(scratch(), 'r.md');
|
|
173
|
+
fs_1.default.writeFileSync(file, content);
|
|
174
|
+
return { file, snap: snap(file) };
|
|
175
|
+
}
|
|
176
|
+
function snap(file) {
|
|
177
|
+
const r = (0, record_io_1.readRecord)(file, 'r.md');
|
|
178
|
+
return r.ok ? r.snapshot : null;
|
|
179
|
+
}
|
|
180
|
+
test('is true for a file nobody touched', () => {
|
|
181
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
182
|
+
expect((0, record_io_1.unchangedSince)(file, s)).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
test('is false when the size changed', () => {
|
|
185
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
186
|
+
fs_1.default.appendFileSync(file, 'd');
|
|
187
|
+
expect((0, record_io_1.unchangedSince)(file, s)).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
test('is false when only the modification time changed', () => {
|
|
190
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
191
|
+
const later = new Date(Date.now() + 60_000);
|
|
192
|
+
fs_1.default.utimesSync(file, later, later);
|
|
193
|
+
expect((0, record_io_1.unchangedSince)(file, s)).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
test('is false when the size differs, whatever the modification time says', () => {
|
|
196
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
197
|
+
expect((0, record_io_1.unchangedSince)(file, { ...s, size: s.size + 1 })).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
test('is false when the modification time differs, whatever the size says', () => {
|
|
200
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
201
|
+
expect((0, record_io_1.unchangedSince)(file, { ...s, mtimeMs: s.mtimeMs + 1 })).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
test('is false for a directory, even with a snapshot that matches it exactly', () => {
|
|
204
|
+
const dir = scratch();
|
|
205
|
+
const stat = fs_1.default.lstatSync(dir);
|
|
206
|
+
expect((0, record_io_1.unchangedSince)(dir, { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino })).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
(0, helpers_1.posixOnly)('is false for a symlink, even with a snapshot that matches it exactly', () => {
|
|
209
|
+
const dir = scratch();
|
|
210
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'real.md'), 'abc');
|
|
211
|
+
fs_1.default.symlinkSync(path_1.default.join(dir, 'real.md'), path_1.default.join(dir, 'l.md'));
|
|
212
|
+
const stat = fs_1.default.lstatSync(path_1.default.join(dir, 'l.md'));
|
|
213
|
+
expect((0, record_io_1.unchangedSince)(path_1.default.join(dir, 'l.md'), { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino })).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
test('is false when the file is gone', () => {
|
|
216
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
217
|
+
fs_1.default.rmSync(file);
|
|
218
|
+
expect((0, record_io_1.unchangedSince)(file, s)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
(0, helpers_1.posixOnly)('is false when the inode differs (same size and time, another file)', () => {
|
|
221
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
222
|
+
expect(s.ino).not.toBe(0);
|
|
223
|
+
expect((0, record_io_1.unchangedSince)(file, { ...s, ino: s.ino + 1 })).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
test('compares inodes only when the file reports one: an inode of 0 on the file side is ignored', () => {
|
|
226
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
227
|
+
expect(s.ino).not.toBe(0);
|
|
228
|
+
intercept('lstatSync', (a) => a[0] === file, (real, a) => Object.assign(Object.create(Object.getPrototypeOf(real(...a))), real(...a), { ino: 0 }));
|
|
229
|
+
expect((0, record_io_1.unchangedSince)(file, s)).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
test('compares inodes only when both sides carry one', () => {
|
|
232
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
233
|
+
expect((0, record_io_1.unchangedSince)(file, { ...s, ino: 0 })).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
(0, helpers_1.posixOnly)('is false when a symlink took its place', () => {
|
|
236
|
+
const { file, snap: s } = snapshotOf('abc');
|
|
237
|
+
const other = path_1.default.join(scratch(), 'o.md');
|
|
238
|
+
fs_1.default.writeFileSync(other, 'abc');
|
|
239
|
+
fs_1.default.rmSync(file);
|
|
240
|
+
fs_1.default.symlinkSync(other, file);
|
|
241
|
+
expect((0, record_io_1.unchangedSince)(file, s)).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
describe('createExclusive', () => {
|
|
245
|
+
test('creates the file with the content', () => {
|
|
246
|
+
const f = path_1.default.join(scratch(), 'a.md');
|
|
247
|
+
expect((0, record_io_1.createExclusive)(f, 'hello')).toBe('ok');
|
|
248
|
+
expect(fs_1.default.readFileSync(f, 'utf8')).toBe('hello');
|
|
249
|
+
});
|
|
250
|
+
test('never replaces an existing file', () => {
|
|
251
|
+
const f = path_1.default.join(scratch(), 'a.md');
|
|
252
|
+
fs_1.default.writeFileSync(f, 'old');
|
|
253
|
+
expect((0, record_io_1.createExclusive)(f, 'new')).toBe('exists');
|
|
254
|
+
expect(fs_1.default.readFileSync(f, 'utf8')).toBe('old');
|
|
255
|
+
});
|
|
256
|
+
(0, helpers_1.posixOnly)('never writes through a symlink at the target', () => {
|
|
257
|
+
const dir = scratch();
|
|
258
|
+
const outside = path_1.default.join(dir, 'outside.md');
|
|
259
|
+
fs_1.default.writeFileSync(outside, 'precious');
|
|
260
|
+
fs_1.default.symlinkSync(outside, path_1.default.join(dir, 'a.md'));
|
|
261
|
+
expect((0, record_io_1.createExclusive)(path_1.default.join(dir, 'a.md'), 'new')).toBe('exists');
|
|
262
|
+
expect(fs_1.default.readFileSync(outside, 'utf8')).toBe('precious');
|
|
263
|
+
});
|
|
264
|
+
test('a failed write leaves no partial file', () => {
|
|
265
|
+
const f = path_1.default.join(scratch(), 'a.md');
|
|
266
|
+
intercept('writeFileSync', (a) => typeof a[0] === 'number', () => { throw Object.assign(new Error('full'), { code: 'ENOSPC' }); });
|
|
267
|
+
expect((0, record_io_1.createExclusive)(f, 'x')).toBe('error');
|
|
268
|
+
expect(fs_1.default.existsSync(f)).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
test('a missing directory is an error', () => {
|
|
271
|
+
expect((0, record_io_1.createExclusive)(path_1.default.join(scratch(), 'no', 'a.md'), 'x')).toBe('error');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
describe('replaceRecord', () => {
|
|
275
|
+
test('replaces the content', () => {
|
|
276
|
+
const f = path_1.default.join(scratch(), 'a.md');
|
|
277
|
+
fs_1.default.writeFileSync(f, 'old');
|
|
278
|
+
expect((0, record_io_1.replaceRecord)(f, 'new')).toEqual({ ok: true });
|
|
279
|
+
expect(fs_1.default.readFileSync(f, 'utf8')).toBe('new');
|
|
280
|
+
});
|
|
281
|
+
(0, helpers_1.posixOnly)('keeps the mode of the file', () => {
|
|
282
|
+
const f = path_1.default.join(scratch(), 'a.md');
|
|
283
|
+
fs_1.default.writeFileSync(f, 'old');
|
|
284
|
+
fs_1.default.chmodSync(f, 0o640);
|
|
285
|
+
(0, record_io_1.replaceRecord)(f, 'new');
|
|
286
|
+
expect(fs_1.default.statSync(f).mode & 0o777).toBe(0o640);
|
|
287
|
+
});
|
|
288
|
+
(0, helpers_1.posixOnly)('does not replace a symlink', () => {
|
|
289
|
+
const dir = scratch();
|
|
290
|
+
const real = path_1.default.join(dir, 'real.md');
|
|
291
|
+
fs_1.default.writeFileSync(real, 'precious');
|
|
292
|
+
fs_1.default.symlinkSync(real, path_1.default.join(dir, 'l.md'));
|
|
293
|
+
expect((0, record_io_1.replaceRecord)(path_1.default.join(dir, 'l.md'), 'new')).toMatchObject({ ok: false });
|
|
294
|
+
expect(fs_1.default.readFileSync(real, 'utf8')).toBe('precious');
|
|
295
|
+
expect(fs_1.default.lstatSync(path_1.default.join(dir, 'l.md')).isSymbolicLink()).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
test('a failed rename keeps the old content, reports its code and leaves no temporary file', () => {
|
|
298
|
+
const dir = scratch();
|
|
299
|
+
const f = path_1.default.join(dir, 'a.md');
|
|
300
|
+
fs_1.default.writeFileSync(f, 'old');
|
|
301
|
+
intercept('renameSync', () => true, () => { throw Object.assign(new Error('nope'), { code: 'EPERM' }); });
|
|
302
|
+
expect((0, record_io_1.replaceRecord)(f, 'new')).toEqual({ ok: false, code: 'EPERM' });
|
|
303
|
+
jest.restoreAllMocks();
|
|
304
|
+
expect(fs_1.default.readFileSync(f, 'utf8')).toBe('old');
|
|
305
|
+
expect(fs_1.default.readdirSync(dir)).toEqual(['a.md']);
|
|
306
|
+
});
|
|
307
|
+
});
|