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
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import * as Y from 'yjs';
|
||||
import { FileBridge } from '../../src/sync/fileBridge';
|
||||
import { LiveSpace } from '../../src/sync/liveSpace';
|
||||
import type { SyncAuth } from '../../src/sync/auth';
|
||||
|
||||
const EXTENSION_ID = 'root-at-skic.archify-vscode-ext';
|
||||
|
||||
@@ -483,6 +487,165 @@ export async function run(): Promise<void> {
|
||||
await vscode.workspace.fs.delete(target);
|
||||
});
|
||||
|
||||
await check('without a shared model, sync stays unloaded and its commands are there', async () => {
|
||||
const commands = await vscode.commands.getCommands(true);
|
||||
for (const id of ['archify.sync.signIn', 'archify.sync.share', 'archify.sync.join']) assert.ok(commands.includes(id), `${id} is registered`);
|
||||
// The extension host's module cache: dist/sync.js (Yjs, the WebSocket) is not loaded.
|
||||
const loaded = Object.keys(require.cache).filter((file) => file.endsWith(`${path.sep}dist${path.sep}sync.js`));
|
||||
assert.deepEqual(loaded, [], 'the sync bundle is not loaded');
|
||||
});
|
||||
|
||||
await check('sync file bridge: local edits reach the document, teammates\' edits reach files and editors', async () => {
|
||||
const root = workspaceFile('shared');
|
||||
const file = (rel: string) => vscode.Uri.joinPath(root, ...rel.split('/'));
|
||||
const write = (rel: string, text: string) => vscode.workspace.fs.writeFile(file(rel), new TextEncoder().encode(text));
|
||||
const read = async (rel: string) => new TextDecoder().decode(await vscode.workspace.fs.readFile(file(rel)));
|
||||
const exists = (rel: string) => Promise.resolve(vscode.workspace.fs.stat(file(rel))).then(() => true, () => false);
|
||||
await write('main.archify', 'model:\n id: shared\n');
|
||||
await write('0100-context/a.archify', 'schema_version: 1\r\nmeta:\r\n title: A\r\n');
|
||||
await write('notes.txt', 'not a model file');
|
||||
|
||||
const doc = new Y.Doc();
|
||||
const teammate = new Y.Doc();
|
||||
// The server in between: every update reaches the other side.
|
||||
doc.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(teammate, u, 'remote'));
|
||||
teammate.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(doc, u, 'remote'));
|
||||
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
|
||||
const bridge = new FileBridge(root, doc, { origin: 'local' }, quiet);
|
||||
const theirs = () => teammate.getMap<Y.Text>('files');
|
||||
try {
|
||||
await bridge.pushAll(false);
|
||||
bridge.start(true);
|
||||
assert.deepEqual([...theirs().keys()].sort(), ['0100-context/a.archify', 'main.archify'], 'only model files are shared');
|
||||
assert.equal(theirs().get('0100-context/a.archify')!.toString(), 'schema_version: 1\nmeta:\n title: A\n', 'line endings travel as \\n');
|
||||
|
||||
// A local edit in an open editor.
|
||||
const main = await vscode.workspace.openTextDocument(file('main.archify'));
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(main.uri, new vscode.Position(2, 0), ' name: Shared\n');
|
||||
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||
await waitFor('local edit at the teammate', () => theirs().get('main.archify')!.toString() === 'model:\n id: shared\n name: Shared\n');
|
||||
await main.save();
|
||||
|
||||
// The teammate types twice in a row into the open file: both land, nothing is undone by an echo.
|
||||
theirs().get('main.archify')!.insert(0, '# one\n');
|
||||
theirs().get('main.archify')!.insert(0, '# two\n');
|
||||
await waitFor('teammate edits in the open editor', () => main.getText() === '# two\n# one\nmodel:\n id: shared\n name: Shared\n');
|
||||
await waitFor('saved, so git sees it', () => !main.isDirty);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
assert.equal(theirs().get('main.archify')!.toString(), main.getText(), 'no echo changed it back');
|
||||
|
||||
// The teammate edits a closed file, keeping its CRLF on disk.
|
||||
const a = theirs().get('0100-context/a.archify')!;
|
||||
a.insert(a.length, ' output: a.html\n');
|
||||
const deadline = Date.now() + 10_000;
|
||||
while ((await read('0100-context/a.archify')) !== 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
|
||||
assert.equal(await read('0100-context/a.archify'), 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n');
|
||||
|
||||
// A new file and a deletion from the teammate.
|
||||
teammate.transact(() => {
|
||||
const t = new Y.Text();
|
||||
t.insert(0, 'schema_version: 1\n');
|
||||
theirs().set('registry/people.archify-registry', t);
|
||||
theirs().set('../escape.archify', new Y.Text('nope'));
|
||||
});
|
||||
const until = async (what: string, probe: () => Promise<boolean>) => {
|
||||
const end = Date.now() + 10_000;
|
||||
while (!(await probe())) {
|
||||
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
};
|
||||
await until('new file on disk', () => exists('registry/people.archify-registry'));
|
||||
assert.ok(!(await exists('../escape.archify')), 'a path outside the model is never written');
|
||||
teammate.transact(() => theirs().delete('0100-context/a.archify'));
|
||||
await until('deleted file gone', async () => !(await exists('0100-context/a.archify')));
|
||||
|
||||
// A change on disk by another tool (git, an agent) to a closed file.
|
||||
await write('registry/people.archify-registry', 'schema_version: 1\nelements: {}\n');
|
||||
await waitFor('disk change at the teammate', () => theirs().get('registry/people.archify-registry')?.toString() === 'schema_version: 1\nelements: {}\n');
|
||||
} finally {
|
||||
bridge.dispose();
|
||||
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
|
||||
await vscode.workspace.fs.delete(root, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Opt-in: a running Archify Sync server, a space on it and an access token.
|
||||
const syncServer = process.env.ARCHIFY_SYNC_TEST_URL;
|
||||
if (syncServer) await check('two shared copies of a model stay the same through the sync server', async () => {
|
||||
const space = process.env.ARCHIFY_SYNC_TEST_SPACE!;
|
||||
const token = process.env.ARCHIFY_SYNC_TEST_TOKEN!;
|
||||
const storage = workspaceFile('.sync-storage');
|
||||
const auth = {
|
||||
session: async () => ({ account: { id: 'it', display_name: 'Integration' } }),
|
||||
accessToken: async () => token,
|
||||
} as unknown as SyncAuth;
|
||||
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
|
||||
const services = (dir: string) => ({ context: { globalStorageUri: vscode.Uri.joinPath(storage, dir) } as vscode.ExtensionContext, auth, log: quiet, onChange() {} });
|
||||
const link = { server: syncServer, space, name: 'IT' };
|
||||
const mine = workspaceFile('live-mine');
|
||||
const theirs = workspaceFile('live-theirs');
|
||||
const read = async (root: vscode.Uri, rel: string) => {
|
||||
try {
|
||||
return new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, ...rel.split('/'))));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const until = async (what: string, probe: () => Promise<boolean>) => {
|
||||
const end = Date.now() + 15_000;
|
||||
while (!(await probe())) {
|
||||
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
};
|
||||
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'main.archify'), new TextEncoder().encode('model:\n id: it\n'));
|
||||
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'registry', 'people.archify-registry'), new TextEncoder().encode('schema_version: 1\n'));
|
||||
await vscode.workspace.fs.createDirectory(theirs);
|
||||
const a = new LiveSpace(mine, link, 'share', services('a'));
|
||||
let b: LiveSpace | undefined;
|
||||
try {
|
||||
await a.start();
|
||||
await until('shared copy online', async () => a.state === 'online');
|
||||
b = new LiveSpace(theirs, link, 'join', services('b'));
|
||||
await b.start();
|
||||
await until('joined copy gets the files', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n' && (await read(theirs, 'registry/people.archify-registry')) === 'schema_version: 1\n');
|
||||
|
||||
// Typing in one copy's editor shows up in the other copy's file.
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(mine, 'main.archify'));
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(doc.uri, new vscode.Position(2, 0), ' name: Live 🚀\n');
|
||||
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||
await until('edit arrives in the other copy', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n name: Live 🚀\n');
|
||||
|
||||
// A file written by another tool in the other copy comes back.
|
||||
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(theirs, '0100-context', 'c.archify'), new TextEncoder().encode('schema_version: 1\n'));
|
||||
await until('new file arrives back', async () => (await read(mine, '0100-context/c.archify')) === 'schema_version: 1\n');
|
||||
await until('presence shows one person in both', async () => a.people().length === 1 && b!.people().length === 1);
|
||||
|
||||
// A cursor in one copy shows in the other, and stays on its word while text is typed above it.
|
||||
const at = 'model:\n id: it\n name: '.length;
|
||||
assert.ok(a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), { anchor: at, head: at + 4 }));
|
||||
const theirsMain = vscode.Uri.joinPath(theirs, 'main.archify');
|
||||
await until('cursor arrives in the other copy', async () => b!.cursors(theirsMain).length === 1);
|
||||
const cursor = b.cursors(theirsMain)[0];
|
||||
assert.deepEqual([cursor.name, cursor.anchor, cursor.head], ['Integration', at, at + 4]);
|
||||
assert.equal(a.cursors(vscode.Uri.joinPath(mine, 'main.archify')).length, 0, 'your own cursor is not drawn for you');
|
||||
const above = new vscode.WorkspaceEdit();
|
||||
above.insert(theirsMain, new vscode.Position(0, 0), '# top\n');
|
||||
assert.ok(await vscode.workspace.applyEdit(above));
|
||||
await until('cursor moves with the text', async () => b!.cursors(theirsMain)[0]?.anchor === at + '# top\n'.length);
|
||||
a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), undefined);
|
||||
await until('cursor goes away', async () => b!.cursors(theirsMain).length === 0);
|
||||
} finally {
|
||||
a.dispose();
|
||||
b?.dispose();
|
||||
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
|
||||
for (const uri of [mine, theirs, storage]) await vscode.workspace.fs.delete(uri, { recursive: true }).then(undefined, () => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
await check('registry and decision pages own their document: edits make it dirty and VS Code undo reverts them', async () => {
|
||||
for (const [file, viewType] of [
|
||||
['model/registry/containers.archify-registry', 'archify.registry'],
|
||||
|
||||
@@ -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