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.
102 lines
4.6 KiB
TypeScript
102 lines
4.6 KiB
TypeScript
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);
|
|
});
|