0.10.0: Archify Sync, optional live collaboration on a model
Several people edit one model at once through a server you run yourself (archify-vscode-ext-sync); the files stay ordinary files on each disk. Sign-in through the browser, offline-first with later merge, teammates' cursors in the Source view and text editors, and a Shared section on the model page with people, versions, Compare and Restore. Without a server nothing changes: the sync code is a separate bundle that loads only when a model is shared, after a sign-in, or when a sync command or invite link is used.
This commit is contained in:
1 parent
a9021312b0
commit
c99f3e5fe8
27 files changed
+2899
-14
No files matched your search
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import * as Y from 'yjs';
|
||||
import { checkServerUrl, parseInviteLink, syncSocketUrl } from '../src/sync/api';
|
||||
import { OfflineStore } from '../src/sync/offlineStore';
|
||||
import { formatLink, parseLink } from '../src/sync/protocol';
|
||||
import { applyToYText, safeChange } from '../src/sync/textSync';
|
||||
import { applyChanges } from '../src/util/textDiff';
|
||||
|
||||
test('safeChange never splits a surrogate pair', () => {
|
||||
const cases: [string, string][] = [
|
||||
['x😀y', 'x😃y'],
|
||||
['😀', '😃'],
|
||||
['a😀', 'a😀b'],
|
||||
['😀b', 'a😀b'],
|
||||
['label: 🚀 API', 'label: 🛰 API'],
|
||||
['plain', 'plainer'],
|
||||
];
|
||||
for (const [before, after] of cases) {
|
||||
const change = safeChange(before, after)!;
|
||||
assert.equal(applyChanges(before, [change]), after, `${before} → ${after}`);
|
||||
for (const at of [change.from, change.to]) {
|
||||
const code = before.charCodeAt(at);
|
||||
assert.ok(!(code >= 0xdc00 && code <= 0xdfff), `change edge ${at} lands inside a pair in ${before}`);
|
||||
}
|
||||
}
|
||||
assert.equal(safeChange('same', 'same'), undefined);
|
||||
});
|
||||
|
||||
test('applyToYText reaches the text and syncs between copies', () => {
|
||||
const a = new Y.Doc();
|
||||
const b = new Y.Doc();
|
||||
a.on('update', (u: Uint8Array) => Y.applyUpdate(b, u));
|
||||
const text = a.getMap<Y.Text>('files');
|
||||
const t = new Y.Text();
|
||||
text.set('main.archify', t);
|
||||
applyToYText(t, 'model:\n id: shop\n');
|
||||
applyToYText(t, '# 😀\nmodel:\n id: shop\n');
|
||||
applyToYText(t, '# 😃\nmodel:\n id: shop2\n');
|
||||
assert.equal(b.getMap<Y.Text>('files').get('main.archify')!.toString(), '# 😃\nmodel:\n id: shop2\n');
|
||||
assert.equal(applyToYText(t, '# 😃\nmodel:\n id: shop2\n'), false);
|
||||
});
|
||||
|
||||
test('the offline copy keeps every update and survives a torn write', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-sync-'));
|
||||
try {
|
||||
const doc = new Y.Doc();
|
||||
const store = new OfflineStore(dir, 'space');
|
||||
assert.equal(store.exists(), false);
|
||||
doc.on('update', (u: Uint8Array) => store.append(u));
|
||||
doc.getText('t').insert(0, 'one ');
|
||||
doc.getText('t').insert(4, 'two');
|
||||
assert.equal(store.pending, 2);
|
||||
// A crash in the middle of the next append.
|
||||
fs.appendFileSync(path.join(dir, 'space.log'), Buffer.from([200, 0, 0, 0, 1, 2]));
|
||||
|
||||
const again = new Y.Doc();
|
||||
for (const u of new OfflineStore(dir, 'space').load()) Y.applyUpdate(again, u);
|
||||
assert.equal(again.getText('t').toString(), 'one two');
|
||||
|
||||
store.compact(Y.encodeStateAsUpdate(doc));
|
||||
assert.equal(store.pending, 0);
|
||||
const compacted = new Y.Doc();
|
||||
for (const u of new OfflineStore(dir, 'space').load()) Y.applyUpdate(compacted, u);
|
||||
assert.equal(compacted.getText('t').toString(), 'one two');
|
||||
store.remove();
|
||||
assert.equal(store.exists(), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('server addresses: https, or http only on this machine', () => {
|
||||
assert.equal(checkServerUrl('https://archify.example.com/').url, 'https://archify.example.com');
|
||||
assert.equal(checkServerUrl('http://localhost:8080').url, 'http://localhost:8080');
|
||||
assert.equal(checkServerUrl('http://127.0.0.1:8080/sync/').url, 'http://127.0.0.1:8080/sync');
|
||||
assert.ok(checkServerUrl('http://archify.example.com').problem);
|
||||
assert.ok(checkServerUrl('ftp://x').problem);
|
||||
assert.ok(checkServerUrl('nonsense').problem);
|
||||
assert.equal(syncSocketUrl('https://a.example', 'id'), 'wss://a.example/v1/spaces/id/sync?v=1');
|
||||
});
|
||||
|
||||
test('invite links in both forms', () => {
|
||||
const token = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcde';
|
||||
assert.deepEqual(parseInviteLink(`https://sync.example/join/${token}`), { server: 'https://sync.example', invite: token });
|
||||
assert.deepEqual(parseInviteLink(`https://example.com/archify/join/${token}`), { server: 'https://example.com/archify', invite: token });
|
||||
const ext = `vscode://root-at-skic.archify-vscode-ext/join?server=${encodeURIComponent('https://sync.example/')}&invite=${token}`;
|
||||
assert.deepEqual(parseInviteLink(ext), { server: 'https://sync.example', invite: token });
|
||||
assert.equal(parseInviteLink('https://example.com/other'), undefined);
|
||||
assert.equal(parseInviteLink('not a link'), undefined);
|
||||
});
|
||||
|
||||
test('link files round-trip and refuse junk', () => {
|
||||
const link = { server: 'https://sync.example', space: '0198f3a2-1b2c-7d3e-8f40-123456789abc', name: 'Shop' };
|
||||
assert.deepEqual(parseLink(formatLink(link)), link);
|
||||
assert.equal(parseLink('{"server":"https://x","space":"../../etc"}'), undefined);
|
||||
assert.equal(parseLink('not json'), undefined);
|
||||
});
|
||||
Reference in new issue
Block a user