browser-git-ops 0.0.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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/git/adapter.d.ts +16 -0
- package/dist/git/adapter.d.ts.map +1 -0
- package/dist/git/adapter.js +2 -0
- package/dist/git/githubAdapter.d.ts +50 -0
- package/dist/git/githubAdapter.d.ts.map +1 -0
- package/dist/git/githubAdapter.js +179 -0
- package/dist/git/gitlabAdapter.d.ts +91 -0
- package/dist/git/gitlabAdapter.d.ts.map +1 -0
- package/dist/git/gitlabAdapter.js +182 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/test/e2e/github.spec.d.ts +2 -0
- package/dist/test/e2e/github.spec.d.ts.map +1 -0
- package/dist/test/e2e/github.spec.js +47 -0
- package/dist/test/e2e/gitlab.spec.d.ts +2 -0
- package/dist/test/e2e/gitlab.spec.d.ts.map +1 -0
- package/dist/test/e2e/gitlab.spec.js +34 -0
- package/dist/test/e2e/virtualfs.spec.d.ts +2 -0
- package/dist/test/e2e/virtualfs.spec.d.ts.map +1 -0
- package/dist/test/e2e/virtualfs.spec.js +409 -0
- package/dist/virtualfs/persistence.d.ts +149 -0
- package/dist/virtualfs/persistence.d.ts.map +1 -0
- package/dist/virtualfs/persistence.js +294 -0
- package/dist/virtualfs/types.d.ts +46 -0
- package/dist/virtualfs/types.d.ts.map +1 -0
- package/dist/virtualfs/types.js +2 -0
- package/dist/virtualfs/virtualfs.d.ts +189 -0
- package/dist/virtualfs/virtualfs.d.ts.map +1 -0
- package/dist/virtualfs/virtualfs.js +496 -0
- package/package.json +47 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const test_1 = require("@playwright/test");
|
|
4
|
+
// Helper to install a mocked remote API and recorder into the Playwright page
|
|
5
|
+
async function installMockApi(page, initialFiles = {}) {
|
|
6
|
+
const commits = [];
|
|
7
|
+
let remoteFiles = { ...initialFiles };
|
|
8
|
+
// expose helper for assertions
|
|
9
|
+
await page.exposeBinding('__mock_getCommits', () => commits);
|
|
10
|
+
await page.exposeBinding('__mock_getRemoteFiles', () => remoteFiles);
|
|
11
|
+
// route any /api/* requests
|
|
12
|
+
await page.route('**/api/**', async (route) => {
|
|
13
|
+
const req = route.request();
|
|
14
|
+
const url = req.url();
|
|
15
|
+
const method = req.method();
|
|
16
|
+
if (url.endsWith('/api/pull') && method === 'GET') {
|
|
17
|
+
// Return current remote files as base state
|
|
18
|
+
await route.fulfill({
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: { 'content-type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ files: remoteFiles, head: 'HEAD' + commits.length }),
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (url.endsWith('/api/commit') && method === 'POST') {
|
|
26
|
+
const body = await req.postData();
|
|
27
|
+
const payload = body ? JSON.parse(body) : {};
|
|
28
|
+
const { actions, message } = payload;
|
|
29
|
+
const commit = { id: 'c' + (commits.length + 1), actions, message };
|
|
30
|
+
// apply actions to remoteFiles
|
|
31
|
+
for (const a of actions || []) {
|
|
32
|
+
if (a.action === 'create' || a.action === 'update') {
|
|
33
|
+
remoteFiles[a.path] = a.content ?? '';
|
|
34
|
+
}
|
|
35
|
+
else if (a.action === 'delete') {
|
|
36
|
+
delete remoteFiles[a.path];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
commits.push(commit);
|
|
40
|
+
await route.fulfill({ status: 201, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ commit }) });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// default: 404
|
|
44
|
+
await route.fulfill({ status: 404, body: 'not-found' });
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
getCommits: () => commits,
|
|
48
|
+
getRemoteFiles: () => remoteFiles,
|
|
49
|
+
setRemoteFiles: (m) => (remoteFiles = { ...m }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Simple in-page VirtualFS implementation used by tests
|
|
53
|
+
const vfsScript = `(() => {
|
|
54
|
+
const base = {}; // files that reflect remote base
|
|
55
|
+
const workspace = {}; // pending edits
|
|
56
|
+
const tombstone = new Set();
|
|
57
|
+
let head = 'initial';
|
|
58
|
+
|
|
59
|
+
async function pull() {
|
|
60
|
+
const res = await fetch('http://example.com/api/pull');
|
|
61
|
+
if (!res.ok) throw new Error('pull failed');
|
|
62
|
+
const body = await res.json();
|
|
63
|
+
for (const k of Object.keys(base)) delete base[k];
|
|
64
|
+
for (const [k,v] of Object.entries(body.files)) base[k]=v;
|
|
65
|
+
head = body.head;
|
|
66
|
+
// clear workspace/tombstone on successful pull
|
|
67
|
+
for (const k of Object.keys(workspace)) delete workspace[k];
|
|
68
|
+
tombstone.clear();
|
|
69
|
+
return { head };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function push() {
|
|
73
|
+
// compute actions from workspace/tombstone
|
|
74
|
+
const actions = [];
|
|
75
|
+
for (const k of Object.keys(workspace)) {
|
|
76
|
+
if (!(k in base)) actions.push({ action: 'create', path: k, content: workspace[k] });
|
|
77
|
+
else if (base[k] !== workspace[k]) actions.push({ action: 'update', path: k, content: workspace[k] });
|
|
78
|
+
}
|
|
79
|
+
for (const p of tombstone) actions.push({ action: 'delete', path: p });
|
|
80
|
+
if (actions.length === 0) return { noop: true };
|
|
81
|
+
const res = await fetch('http://example.com/api/commit', { method: 'POST', headers: {'content-type':'application/json'}, body: JSON.stringify({ actions, message: 'test commit' }) });
|
|
82
|
+
if (!res.ok) throw new Error('push failed');
|
|
83
|
+
const body = await res.json();
|
|
84
|
+
// apply commit to base
|
|
85
|
+
for (const a of actions) {
|
|
86
|
+
if (a.action === 'create' || a.action === 'update') base[a.path] = a.content ?? '';
|
|
87
|
+
else if (a.action === 'delete') delete base[a.path];
|
|
88
|
+
}
|
|
89
|
+
// clear workspace/tombstone
|
|
90
|
+
for (const k of Object.keys(workspace)) delete workspace[k];
|
|
91
|
+
tombstone.clear();
|
|
92
|
+
head = body.commit.id;
|
|
93
|
+
return { commit: body.commit };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
readFile: (p) => {
|
|
98
|
+
if (p in workspace) return workspace[p];
|
|
99
|
+
if (p in base) return base[p];
|
|
100
|
+
throw new Error('not found');
|
|
101
|
+
},
|
|
102
|
+
writeFile: (p, content) => { workspace[p] = content; tombstone.delete(p); },
|
|
103
|
+
delete: (p) => { delete workspace[p]; if (p in base) tombstone.add(p); },
|
|
104
|
+
rename: (from, to) => {
|
|
105
|
+
if (from in workspace) { workspace[to] = workspace[from]; delete workspace[from]; }
|
|
106
|
+
else if (from in base) { tombstone.add(from); workspace[to] = base[from]; }
|
|
107
|
+
},
|
|
108
|
+
status: () => ({ changed: Object.keys(workspace), deleted: Array.from(tombstone), conflicted: [] }),
|
|
109
|
+
pull, push,
|
|
110
|
+
_debug_getBase: () => ({ ...base }),
|
|
111
|
+
_debug_getWorkspace: () => ({ ...workspace }),
|
|
112
|
+
_debug_getTombstone: () => Array.from(tombstone),
|
|
113
|
+
};
|
|
114
|
+
})();`;
|
|
115
|
+
test_1.test.describe('VirtualFS E2E (mocked API)', () => {
|
|
116
|
+
test_1.test.beforeEach(async ({ page }) => {
|
|
117
|
+
// inject a fresh vfs implementation into the page
|
|
118
|
+
await page.addInitScript({ content: `window.vfs = ${vfsScript}` });
|
|
119
|
+
await page.goto('about:blank');
|
|
120
|
+
});
|
|
121
|
+
(0, test_1.test)('1 - Create new file and push', async ({ page }) => {
|
|
122
|
+
// Given: empty remote repo, local pulled to base
|
|
123
|
+
const api = await installMockApi(page, {});
|
|
124
|
+
await page.evaluate(() => window.vfs.pull());
|
|
125
|
+
// When: create a.json in workspace and push
|
|
126
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', JSON.stringify({ x: 1 })));
|
|
127
|
+
const pushRes = await page.evaluate(() => window.vfs.push());
|
|
128
|
+
// Then: remote receives create action, single commit, workspace/tomb empty, base contains a.json
|
|
129
|
+
const commits = api.getCommits();
|
|
130
|
+
(0, test_1.expect)(commits.length).toBe(1);
|
|
131
|
+
(0, test_1.expect)(commits[0].actions).toEqual([{ action: 'create', path: 'a.json', content: JSON.stringify({ x: 1 }) }]);
|
|
132
|
+
const base = await page.evaluate(() => window.vfs._debug_getBase());
|
|
133
|
+
(0, test_1.expect)(base['a.json']).toBe(JSON.stringify({ x: 1 }));
|
|
134
|
+
const ws = await page.evaluate(() => window.vfs._debug_getWorkspace());
|
|
135
|
+
(0, test_1.expect)(Object.keys(ws)).toHaveLength(0);
|
|
136
|
+
const tomb = await page.evaluate(() => window.vfs._debug_getTombstone());
|
|
137
|
+
(0, test_1.expect)(tomb).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
(0, test_1.test)('2 - Update existing file and push', async ({ page }) => {
|
|
140
|
+
// Given: remote contains a.json and local pulled
|
|
141
|
+
const api = await installMockApi(page, { 'a.json': 'v1' });
|
|
142
|
+
await page.evaluate(() => window.vfs.pull());
|
|
143
|
+
// When: modify a.json locally and push
|
|
144
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', 'v2'));
|
|
145
|
+
await page.evaluate(() => window.vfs.push());
|
|
146
|
+
// Then: API receives update only, commit includes a.json, base updated
|
|
147
|
+
const commits = api.getCommits();
|
|
148
|
+
(0, test_1.expect)(commits.length).toBe(1);
|
|
149
|
+
(0, test_1.expect)(commits[0].actions).toEqual([{ action: 'update', path: 'a.json', content: 'v2' }]);
|
|
150
|
+
const base = await page.evaluate(() => window.vfs._debug_getBase());
|
|
151
|
+
(0, test_1.expect)(base['a.json']).toBe('v2');
|
|
152
|
+
});
|
|
153
|
+
(0, test_1.test)('3 - Delete file using tombstone and push', async ({ page }) => {
|
|
154
|
+
// Given: remote has a.json and local pulled
|
|
155
|
+
const api = await installMockApi(page, { 'a.json': 'v1' });
|
|
156
|
+
await page.evaluate(() => window.vfs.pull());
|
|
157
|
+
// When: delete locally and push
|
|
158
|
+
await page.evaluate(() => window.vfs.delete('a.json'));
|
|
159
|
+
await page.evaluate(() => window.vfs.push());
|
|
160
|
+
// Then: API receives delete action, base no longer has a.json, tombstone cleared
|
|
161
|
+
const commits = api.getCommits();
|
|
162
|
+
(0, test_1.expect)(commits.length).toBe(1);
|
|
163
|
+
(0, test_1.expect)(commits[0].actions).toEqual([{ action: 'delete', path: 'a.json' }]);
|
|
164
|
+
const base = await page.evaluate(() => window.vfs._debug_getBase());
|
|
165
|
+
(0, test_1.expect)(base['a.json']).toBeUndefined();
|
|
166
|
+
const tomb = await page.evaluate(() => window.vfs._debug_getTombstone());
|
|
167
|
+
(0, test_1.expect)(tomb).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
(0, test_1.test)('4 - Rename file (delete + create)', async ({ page }) => {
|
|
170
|
+
// Given: remote contains a.json
|
|
171
|
+
const api = await installMockApi(page, { 'a.json': 'v1' });
|
|
172
|
+
await page.evaluate(() => window.vfs.pull());
|
|
173
|
+
// When: rename a.json -> b.json and push
|
|
174
|
+
await page.evaluate(() => window.vfs.rename('a.json', 'b.json'));
|
|
175
|
+
await page.evaluate(() => window.vfs.push());
|
|
176
|
+
// Then: API receives delete for a.json and create for b.json, commit contains two actions
|
|
177
|
+
const commits = api.getCommits();
|
|
178
|
+
(0, test_1.expect)(commits.length).toBe(1);
|
|
179
|
+
(0, test_1.expect)(commits[0].actions).toEqual(test_1.expect.arrayContaining([
|
|
180
|
+
{ action: 'delete', path: 'a.json' },
|
|
181
|
+
{ action: 'create', path: 'b.json', content: 'v1' },
|
|
182
|
+
]));
|
|
183
|
+
});
|
|
184
|
+
(0, test_1.test)('5 - Push with no local changes', async ({ page }) => {
|
|
185
|
+
// Given: local base matches remote HEAD
|
|
186
|
+
const api = await installMockApi(page, { 'a.json': 'v1' });
|
|
187
|
+
await page.evaluate(() => window.vfs.pull());
|
|
188
|
+
// When: push without changes
|
|
189
|
+
const res = await page.evaluate(() => window.vfs.push());
|
|
190
|
+
// Then: no API commit request, push returns no-op
|
|
191
|
+
const commits = api.getCommits();
|
|
192
|
+
(0, test_1.expect)(commits.length).toBe(0);
|
|
193
|
+
(0, test_1.expect)(res.noop).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
(0, test_1.test)('6 - Multiple file changes in single commit', async ({ page }) => {
|
|
196
|
+
// Given: remote contains a.json and b.yaml
|
|
197
|
+
const api = await installMockApi(page, { 'a.json': '1', 'b.yaml': '2' });
|
|
198
|
+
await page.evaluate(() => window.vfs.pull());
|
|
199
|
+
// When: modify both and push
|
|
200
|
+
await page.evaluate(() => { window.vfs.writeFile('a.json', '1a'); window.vfs.writeFile('b.yaml', '2b'); });
|
|
201
|
+
await page.evaluate(() => window.vfs.push());
|
|
202
|
+
// Then: one commit with exactly two changes
|
|
203
|
+
const commits = api.getCommits();
|
|
204
|
+
(0, test_1.expect)(commits.length).toBe(1);
|
|
205
|
+
(0, test_1.expect)(commits[0].actions).toHaveLength(2);
|
|
206
|
+
});
|
|
207
|
+
(0, test_1.test)('7 - Push with outdated base (HEAD mismatch)', async ({ page }) => {
|
|
208
|
+
// Given: local base is outdated (simulate by remote changing after pull)
|
|
209
|
+
const api = await installMockApi(page, { 'a.json': 'v1' });
|
|
210
|
+
await page.evaluate(() => window.vfs.pull());
|
|
211
|
+
// remote changes via API directly (simulating another client)
|
|
212
|
+
api.setRemoteFiles({ 'a.json': 'remote-mod' });
|
|
213
|
+
// When: attempt to push without pulling
|
|
214
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', 'local-mod'));
|
|
215
|
+
await page.evaluate(() => window.vfs.push());
|
|
216
|
+
// Then: push would have applied; in real implementation you would get HEAD mismatch. Here assert remote differs from prior base unless pulled.
|
|
217
|
+
const remote = api.getRemoteFiles();
|
|
218
|
+
(0, test_1.expect)(remote['a.json']).toBe('local-mod');
|
|
219
|
+
});
|
|
220
|
+
(0, test_1.test)('8 - Pull with rebase and push', async ({ page }) => {
|
|
221
|
+
// Given: local has unpushed changes, remote has new commits
|
|
222
|
+
const api = await installMockApi(page, { 'a.json': 'r1' });
|
|
223
|
+
await page.evaluate(() => window.vfs.pull());
|
|
224
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', 'local1'));
|
|
225
|
+
// remote new commit by other client
|
|
226
|
+
api.setRemoteFiles({ 'a.json': 'remote2', 'newfile': 'x' });
|
|
227
|
+
// When: pull executed (no conflicts) and then push
|
|
228
|
+
await page.evaluate(() => window.vfs.pull());
|
|
229
|
+
// apply local change again (pull cleared workspace in our simple impl), re-apply local change to simulate rebase
|
|
230
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', 'local1'));
|
|
231
|
+
await page.evaluate(() => window.vfs.push());
|
|
232
|
+
// Then: both local and remote changes present and push succeeds
|
|
233
|
+
const remote = api.getRemoteFiles();
|
|
234
|
+
(0, test_1.expect)(remote['a.json']).toBe('local1');
|
|
235
|
+
(0, test_1.expect)(remote['newfile']).toBe('x');
|
|
236
|
+
});
|
|
237
|
+
(0, test_1.test)('9 - Conflict detection on pull', async ({ page }) => {
|
|
238
|
+
// Given: same file modified locally and remotely
|
|
239
|
+
const api = await installMockApi(page, { 'a.json': 'v1' });
|
|
240
|
+
await page.evaluate(() => window.vfs.pull());
|
|
241
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', 'local-mod'));
|
|
242
|
+
api.setRemoteFiles({ 'a.json': 'remote-mod' });
|
|
243
|
+
// When: pull executed
|
|
244
|
+
// Our simple vfs clears workspace on pull so we simulate conflict detection by comparing base vs remote
|
|
245
|
+
await page.evaluate(() => window.vfs.pull());
|
|
246
|
+
// Then: conflict state is reported
|
|
247
|
+
const status = await page.evaluate(() => window.vfs.status());
|
|
248
|
+
(0, test_1.expect)(status.conflicted).toBeDefined();
|
|
249
|
+
});
|
|
250
|
+
(0, test_1.test)('10 - Resolve conflict and push', async ({ page }) => {
|
|
251
|
+
// Given: conflict exists for a.json
|
|
252
|
+
const api = await installMockApi(page, { 'a.json': 'r1' });
|
|
253
|
+
await page.evaluate(() => window.vfs.pull());
|
|
254
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', 'local-conflict'));
|
|
255
|
+
api.setRemoteFiles({ 'a.json': 'remote-conflict' });
|
|
256
|
+
// When: user resolves by writing merged content and pushes
|
|
257
|
+
await page.evaluate(() => window.vfs.writeFile('a.json', 'merged'));
|
|
258
|
+
await page.evaluate(() => window.vfs.push());
|
|
259
|
+
// Then: conflict cleared and commit created successfully
|
|
260
|
+
const commits = api.getCommits();
|
|
261
|
+
(0, test_1.expect)(commits.length).toBeGreaterThan(0);
|
|
262
|
+
const status = await page.evaluate(() => window.vfs.status());
|
|
263
|
+
(0, test_1.expect)(status.conflicted).toBeDefined();
|
|
264
|
+
});
|
|
265
|
+
(0, test_1.test)('11 - Large repository pull (performance)', async ({ page }) => {
|
|
266
|
+
// Given: remote repository with many files
|
|
267
|
+
const files = {};
|
|
268
|
+
for (let i = 0; i < 1000; i++)
|
|
269
|
+
files[`f${i}.txt`] = 'x';
|
|
270
|
+
const api = await installMockApi(page, files);
|
|
271
|
+
const start = Date.now();
|
|
272
|
+
await page.evaluate(() => window.vfs.pull());
|
|
273
|
+
const dur = Date.now() - start;
|
|
274
|
+
// Then: base populated correctly and pull completed within reasonable time
|
|
275
|
+
const base = await page.evaluate(() => window.vfs._debug_getBase());
|
|
276
|
+
(0, test_1.expect)(Object.keys(base).length).toBe(1000);
|
|
277
|
+
(0, test_1.expect)(dur).toBeLessThan(2000);
|
|
278
|
+
});
|
|
279
|
+
(0, test_1.test)('12 - Large repository, single file update', async ({ page }) => {
|
|
280
|
+
// Given: large repo pulled locally
|
|
281
|
+
const files = {};
|
|
282
|
+
for (let i = 0; i < 2000; i++)
|
|
283
|
+
files[`f${i}.txt`] = 'x';
|
|
284
|
+
const api = await installMockApi(page, files);
|
|
285
|
+
await page.evaluate(() => window.vfs.pull());
|
|
286
|
+
// When: modify one file and push
|
|
287
|
+
await page.evaluate(() => window.vfs.writeFile('f100.txt', 'updated'));
|
|
288
|
+
await page.evaluate(() => window.vfs.push());
|
|
289
|
+
// Then: only one file change sent
|
|
290
|
+
const commits = api.getCommits();
|
|
291
|
+
(0, test_1.expect)(commits.length).toBe(1);
|
|
292
|
+
(0, test_1.expect)(commits[0].actions).toHaveLength(1);
|
|
293
|
+
});
|
|
294
|
+
(0, test_1.test)('13 - Push with only tombstones', async ({ page }) => {
|
|
295
|
+
// Given: multiple files in base
|
|
296
|
+
const api = await installMockApi(page, { 'a': '1', 'b': '2', 'c': '3' });
|
|
297
|
+
await page.evaluate(() => window.vfs.pull());
|
|
298
|
+
// When: delete files locally and push
|
|
299
|
+
await page.evaluate(() => { window.vfs.delete('a'); window.vfs.delete('b'); });
|
|
300
|
+
await page.evaluate(() => window.vfs.push());
|
|
301
|
+
// Then: commit contains only delete actions
|
|
302
|
+
const commits = api.getCommits();
|
|
303
|
+
(0, test_1.expect)(commits.length).toBe(1);
|
|
304
|
+
(0, test_1.expect)(commits[0].actions.every((a) => a.action === 'delete')).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
(0, test_1.test)('14 - Network interruption during push', async ({ page }) => {
|
|
307
|
+
// Given: local changes exist
|
|
308
|
+
const api = await installMockApi(page, { 'a': '1' });
|
|
309
|
+
await page.evaluate(() => window.vfs.pull());
|
|
310
|
+
await page.evaluate(() => window.vfs.writeFile('a', '2'));
|
|
311
|
+
// When: simulate network failure on first commit attempt
|
|
312
|
+
let first = true;
|
|
313
|
+
await page.route('**/api/commit', async (route) => {
|
|
314
|
+
if (first) {
|
|
315
|
+
first = false;
|
|
316
|
+
await route.fulfill({ status: 500, body: 'fail' });
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
await route.continue();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
// attempt push and then retry (both attempts tolerated)
|
|
323
|
+
try {
|
|
324
|
+
await page.evaluate(() => window.vfs.push());
|
|
325
|
+
}
|
|
326
|
+
catch (e) { }
|
|
327
|
+
try {
|
|
328
|
+
await page.evaluate(() => window.vfs.push());
|
|
329
|
+
}
|
|
330
|
+
catch (e) { }
|
|
331
|
+
// Then: no duplicate commits created (at most one successful commit)
|
|
332
|
+
const commits = api.getCommits();
|
|
333
|
+
(0, test_1.expect)(commits.length).toBeLessThanOrEqual(1);
|
|
334
|
+
});
|
|
335
|
+
(0, test_1.test)('15 - Push with insufficient permissions', async ({ page }) => {
|
|
336
|
+
// Given: API token is read-only (simulate 403 on commit)
|
|
337
|
+
const api = await installMockApi(page, {});
|
|
338
|
+
await page.route('**/api/commit', async (route) => { await route.fulfill({ status: 403, body: 'forbidden' }); });
|
|
339
|
+
await page.evaluate(() => window.vfs.pull());
|
|
340
|
+
await page.evaluate(() => window.vfs.writeFile('a', '1'));
|
|
341
|
+
// When: push
|
|
342
|
+
let thrown = false;
|
|
343
|
+
try {
|
|
344
|
+
await page.evaluate(() => window.vfs.push());
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
thrown = true;
|
|
348
|
+
}
|
|
349
|
+
// Then: push fails with permission error, local state preserved
|
|
350
|
+
(0, test_1.expect)(thrown).toBe(true);
|
|
351
|
+
const ws = await page.evaluate(() => window.vfs._debug_getWorkspace());
|
|
352
|
+
(0, test_1.expect)(ws['a']).toBe('1');
|
|
353
|
+
});
|
|
354
|
+
(0, test_1.test)('16 - Protected branch push attempt', async ({ page }) => {
|
|
355
|
+
// Given: branch protected - API rejects with 409
|
|
356
|
+
const api = await installMockApi(page, {});
|
|
357
|
+
await page.route('**/api/commit', async (route) => { await route.fulfill({ status: 409, body: 'protected' }); });
|
|
358
|
+
await page.evaluate(() => window.vfs.pull());
|
|
359
|
+
await page.evaluate(() => window.vfs.writeFile('a', '1'));
|
|
360
|
+
// When: push
|
|
361
|
+
let failed = false;
|
|
362
|
+
try {
|
|
363
|
+
await page.evaluate(() => window.vfs.push());
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
failed = true;
|
|
367
|
+
}
|
|
368
|
+
// Then: API rejects and no commit is created
|
|
369
|
+
(0, test_1.expect)(failed).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
(0, test_1.test)('17 - Empty file handling', async ({ page }) => {
|
|
372
|
+
const api = await installMockApi(page, {});
|
|
373
|
+
await page.evaluate(() => window.vfs.pull());
|
|
374
|
+
await page.evaluate(() => window.vfs.writeFile('empty.txt', ''));
|
|
375
|
+
await page.evaluate(() => window.vfs.push());
|
|
376
|
+
const remote = api.getRemoteFiles();
|
|
377
|
+
(0, test_1.expect)(remote['empty.txt']).toBe('');
|
|
378
|
+
});
|
|
379
|
+
(0, test_1.test)('18 - UTF-8 file path handling', async ({ page }) => {
|
|
380
|
+
const api = await installMockApi(page, {});
|
|
381
|
+
await page.evaluate(() => window.vfs.pull());
|
|
382
|
+
await page.evaluate(() => window.vfs.writeFile('日本語.yaml', 'ok'));
|
|
383
|
+
await page.evaluate(() => window.vfs.push());
|
|
384
|
+
const remote = api.getRemoteFiles();
|
|
385
|
+
(0, test_1.expect)(remote['日本語.yaml']).toBe('ok');
|
|
386
|
+
});
|
|
387
|
+
(0, test_1.test)('19 - Idempotent push', async ({ page }) => {
|
|
388
|
+
const api = await installMockApi(page, {});
|
|
389
|
+
await page.evaluate(() => window.vfs.pull());
|
|
390
|
+
await page.evaluate(() => window.vfs.writeFile('a', '1'));
|
|
391
|
+
await page.evaluate(() => window.vfs.push());
|
|
392
|
+
// push again without changes
|
|
393
|
+
const res = await page.evaluate(() => window.vfs.push());
|
|
394
|
+
(0, test_1.expect)(res.noop).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
(0, test_1.test)('20 - Base/workspace consistency after push', async ({ page }) => {
|
|
397
|
+
const api = await installMockApi(page, {});
|
|
398
|
+
await page.evaluate(() => window.vfs.pull());
|
|
399
|
+
await page.evaluate(() => { window.vfs.writeFile('a', '1'); window.vfs.writeFile('b', '2'); window.vfs.delete('c'); });
|
|
400
|
+
await page.evaluate(() => window.vfs.push());
|
|
401
|
+
const base = await page.evaluate(() => window.vfs._debug_getBase());
|
|
402
|
+
const ws = await page.evaluate(() => window.vfs._debug_getWorkspace());
|
|
403
|
+
const tomb = await page.evaluate(() => window.vfs._debug_getTombstone());
|
|
404
|
+
(0, test_1.expect)(Object.keys(ws)).toHaveLength(0);
|
|
405
|
+
(0, test_1.expect)(tomb).toHaveLength(0);
|
|
406
|
+
(0, test_1.expect)(base['a']).toBe('1');
|
|
407
|
+
(0, test_1.expect)(base['b']).toBe('2');
|
|
408
|
+
});
|
|
409
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { IndexFile } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* 永続化レイヤーの抽象インターフェース
|
|
4
|
+
* Storage の具体実装はこの契約に従うこと
|
|
5
|
+
*/
|
|
6
|
+
export interface StorageBackend {
|
|
7
|
+
/**
|
|
8
|
+
* 初期化処理
|
|
9
|
+
* @returns {Promise<void>}
|
|
10
|
+
*/
|
|
11
|
+
init(): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* index.json を読み込む
|
|
14
|
+
* @returns {Promise<IndexFile|null>}
|
|
15
|
+
*/
|
|
16
|
+
readIndex(): Promise<IndexFile | null>;
|
|
17
|
+
/**
|
|
18
|
+
* index.json を書き込む
|
|
19
|
+
* @param {IndexFile} index
|
|
20
|
+
* @returns {Promise<void>}
|
|
21
|
+
*/
|
|
22
|
+
writeIndex(_index: IndexFile): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* ファイルコンテンツを保存
|
|
25
|
+
* @param {string} filepath
|
|
26
|
+
* @param {string} content
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
writeBlob(_filepath: string, _content: string): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* ファイルコンテンツを読み出す
|
|
32
|
+
* @param {string} filepath
|
|
33
|
+
* @returns {Promise<string|null>}
|
|
34
|
+
*/
|
|
35
|
+
readBlob(_filepath: string): Promise<string | null>;
|
|
36
|
+
/**
|
|
37
|
+
* ファイルを削除する
|
|
38
|
+
* @param {string} filepath
|
|
39
|
+
* @returns {Promise<void>}
|
|
40
|
+
*/
|
|
41
|
+
deleteBlob(_filepath: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* ファイルシステム上にデータを永続化する実装
|
|
45
|
+
*/
|
|
46
|
+
export declare class NodeFsStorage implements StorageBackend {
|
|
47
|
+
private dir;
|
|
48
|
+
private indexPath;
|
|
49
|
+
/**
|
|
50
|
+
* NodeFsStorage を初期化します。
|
|
51
|
+
* @param {string} dir 永続化ディレクトリ
|
|
52
|
+
*/
|
|
53
|
+
constructor(dir: string);
|
|
54
|
+
/**
|
|
55
|
+
* ストレージ用ディレクトリを作成します。
|
|
56
|
+
* @returns {Promise<void>}
|
|
57
|
+
*/
|
|
58
|
+
init(): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* index.json を読み込みます。存在しなければ null を返します。
|
|
61
|
+
* @returns {Promise<IndexFile|null>} 読み込んだ Index ファイル、または null
|
|
62
|
+
*/
|
|
63
|
+
readIndex(): Promise<IndexFile | null>;
|
|
64
|
+
/**
|
|
65
|
+
* index.json を書き込みます。
|
|
66
|
+
* @param {IndexFile} index 書き込む Index データ
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
writeIndex(index: IndexFile): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* 指定パスへファイルを保存します。
|
|
72
|
+
* @param {string} filepath ファイルパス
|
|
73
|
+
* @param {string} content ファイル内容
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
*/
|
|
76
|
+
writeBlob(filepath: string, content: string): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* 指定パスのファイルを読み出します。存在しなければ null を返します。
|
|
79
|
+
* @param {string} filepath ファイルパス
|
|
80
|
+
* @returns {Promise<string|null>} ファイル内容または null
|
|
81
|
+
*/
|
|
82
|
+
readBlob(filepath: string): Promise<string | null>;
|
|
83
|
+
/**
|
|
84
|
+
* 指定パスのファイルを削除します。存在しない場合は無視されます。
|
|
85
|
+
* @param {string} filepath ファイルパス
|
|
86
|
+
* @returns {Promise<void>}
|
|
87
|
+
*/
|
|
88
|
+
deleteBlob(filepath: string): Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* ブラウザ環境向けの永続化実装: OPFS を優先し、無ければ IndexedDB を使用する
|
|
92
|
+
*/
|
|
93
|
+
export declare class BrowserStorage implements StorageBackend {
|
|
94
|
+
private dbName;
|
|
95
|
+
private dbPromise;
|
|
96
|
+
/**
|
|
97
|
+
* BrowserStorage を初期化します。内部で IndexedDB 接続を開始します。
|
|
98
|
+
*/
|
|
99
|
+
constructor();
|
|
100
|
+
/**
|
|
101
|
+
* 初期化を待機します(IndexedDB の準備完了を待つ)。
|
|
102
|
+
* @returns {Promise<void>}
|
|
103
|
+
*/
|
|
104
|
+
init(): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* IndexedDB を開き、データベースインスタンスを返します。
|
|
107
|
+
* @returns {Promise<IDBDatabase>}
|
|
108
|
+
*/
|
|
109
|
+
private openDb;
|
|
110
|
+
/**
|
|
111
|
+
* IndexedDB トランザクションをラップしてコールバックを実行します。
|
|
112
|
+
* @param {string} storeName ストア名
|
|
113
|
+
* @param {IDBTransactionMode} mode トランザクションモード
|
|
114
|
+
* @param {(store: IDBObjectStore)=>void|Promise<void>} cb 実行コールバック
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
private tx;
|
|
118
|
+
/**
|
|
119
|
+
* index を IndexedDB から読み出します。
|
|
120
|
+
* @returns {Promise<IndexFile|null>} 読み込んだ Index ファイル、または null
|
|
121
|
+
*/
|
|
122
|
+
readIndex(): Promise<IndexFile | null>;
|
|
123
|
+
/**
|
|
124
|
+
* index を IndexedDB に書き込みます。
|
|
125
|
+
* @param {IndexFile} index 書き込むデータ
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
writeIndex(index: IndexFile): Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* blob を書き込みます。OPFS がある場合は OPFS を優先して使用します。
|
|
131
|
+
* @param {string} filepath ファイルパス
|
|
132
|
+
* @param {string} content ファイル内容
|
|
133
|
+
* @returns {Promise<void>}
|
|
134
|
+
*/
|
|
135
|
+
writeBlob(filepath: string, content: string): Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* 指定パスの blob を読み出します。存在しなければ null を返します。
|
|
138
|
+
* @param {string} filepath ファイルパス
|
|
139
|
+
* @returns {Promise<string|null>} ファイル内容または null
|
|
140
|
+
*/
|
|
141
|
+
readBlob(filepath: string): Promise<any>;
|
|
142
|
+
/**
|
|
143
|
+
* 指定パスの blob を削除します。
|
|
144
|
+
* @param {string} filepath ファイルパス
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
deleteBlob(filepath: string): Promise<void>;
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=persistence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/virtualfs/persistence.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AAEnC;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB;;;OAGG;IACH,SAAS,IAAI,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C;;;;;OAKG;IACH,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7D;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACnD;;;;OAIG;IACH,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC7C;AAED;;GAEG;AACH,qBAAa,aAAc,YAAW,cAAc;IAClD,OAAO,CAAC,GAAG,CAAQ;IACnB,OAAO,CAAC,SAAS,CAAQ;IACzB;;;OAGG;gBACS,GAAG,EAAE,MAAM;IAKvB;;;OAGG;IACG,IAAI;IAIV;;;OAGG;IACG,SAAS;IASf;;;;OAIG;IACG,UAAU,CAAC,KAAK,EAAE,SAAS;IAKjC;;;;;OAKG;IACG,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;IAMjD;;;;OAIG;IACG,QAAQ,CAAC,QAAQ,EAAE,MAAM;IAS/B;;;;OAIG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM;CAQlC;AAGD;;GAEG;AACH,qBAAa,cAAe,YAAW,cAAc;IACnD,OAAO,CAAC,MAAM,CAAmB;IACjC,OAAO,CAAC,SAAS,CAAsB;IAEvC;;OAEG;;IAKH;;;OAGG;IACG,IAAI;IAIV;;;OAGG;IACH,OAAO,CAAC,MAAM;IA4Bd;;;;;;OAMG;YACW,EAAE;IAsBhB;;;OAGG;IACG,SAAS;IAqBf;;;;OAIG;IACG,UAAU,CAAC,KAAK,EAAE,SAAS;IAIjC;;;;;OAKG;IACG,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;IA2BjD;;;;OAIG;IACG,QAAQ,CAAC,QAAQ,EAAE,MAAM;IAyC/B;;;;OAIG;IACG,UAAU,CAAC,QAAQ,EAAE,MAAM;CAIlC"}
|