Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
247 lines
7.6 KiB
TypeScript
247 lines
7.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { downloadGistRevision, downloadSyncGist } from './GitHubAdapter.ts';
|
|
import { SYNC_CONSTANTS } from '../../../domain/sync.ts';
|
|
|
|
type FetchCall = {
|
|
url: string;
|
|
headers?: HeadersInit;
|
|
};
|
|
|
|
function installFetchMock(
|
|
handler: (url: string, init?: RequestInit) => Promise<Response> | Response,
|
|
): { calls: FetchCall[]; restore: () => void } {
|
|
const calls: FetchCall[] = [];
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
calls.push({ url, headers: init?.headers });
|
|
return handler(url, init);
|
|
}) as typeof fetch;
|
|
return {
|
|
calls,
|
|
restore: () => {
|
|
globalThis.fetch = original;
|
|
},
|
|
};
|
|
}
|
|
|
|
const FULL_SYNCED_FILE = {
|
|
meta: {
|
|
version: 3,
|
|
updatedAt: 1_700_000_000_000,
|
|
deviceId: 'device-1',
|
|
deviceName: 'Test',
|
|
},
|
|
payload: `ENC:${'A'.repeat(50_000)}`,
|
|
};
|
|
|
|
const FULL_CONTENT = JSON.stringify(FULL_SYNCED_FILE, null, 2);
|
|
// Reproduce #2643: Gist API embeds a truncated mid-string payload (~900 KiB cut).
|
|
const TRUNCATED_CONTENT = FULL_CONTENT.slice(0, 900);
|
|
const utf8ByteLength = (value: string): number =>
|
|
new TextEncoder().encode(value).byteLength;
|
|
|
|
test('downloadSyncGist fetches raw_url when Gist API marks the file truncated', async () => {
|
|
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/netcatty-vault.json';
|
|
const { calls, restore } = installFetchMock((url) => {
|
|
if (url.includes('/gists/') && !url.includes('raw')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'gist-1',
|
|
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
|
|
files: {
|
|
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
|
|
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
|
content: TRUNCATED_CONTENT,
|
|
truncated: true,
|
|
raw_url: rawUrl,
|
|
size: utf8ByteLength(FULL_CONTENT),
|
|
},
|
|
},
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (url === rawUrl) {
|
|
return new Response(FULL_CONTENT, { status: 200 });
|
|
}
|
|
return new Response('not found', { status: 404 });
|
|
});
|
|
|
|
try {
|
|
const result = await downloadSyncGist('token-xyz', 'gist-1');
|
|
assert.deepEqual(result, FULL_SYNCED_FILE);
|
|
const rawCall = calls.find((c) => c.url === rawUrl);
|
|
assert.ok(rawCall, 'must fetch raw_url for truncated gist');
|
|
assert.deepEqual(rawCall.headers, {
|
|
Authorization: 'Bearer token-xyz',
|
|
Accept: 'application/vnd.github.raw',
|
|
});
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('downloadSyncGist falls back to raw_url when embedded content is incomplete JSON', async () => {
|
|
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/netcatty-vault.json';
|
|
const { restore } = installFetchMock((url) => {
|
|
if (url.includes('/gists/') && !url.includes('raw')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'gist-1',
|
|
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
|
|
files: {
|
|
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
|
|
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
|
// truncated flag missing (edge case) but content is cut mid-string
|
|
content: TRUNCATED_CONTENT,
|
|
raw_url: rawUrl,
|
|
size: utf8ByteLength(FULL_CONTENT),
|
|
},
|
|
},
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (url === rawUrl) {
|
|
return new Response(FULL_CONTENT, { status: 200 });
|
|
}
|
|
return new Response('not found', { status: 404 });
|
|
});
|
|
|
|
try {
|
|
const result = await downloadSyncGist('token-xyz', 'gist-1');
|
|
assert.deepEqual(result, FULL_SYNCED_FILE);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('downloadSyncGist uses embedded content when file is not truncated', async () => {
|
|
const small = { meta: { version: 1 }, payload: 'small' };
|
|
const body = JSON.stringify(small);
|
|
let rawFetches = 0;
|
|
const { restore } = installFetchMock((url) => {
|
|
if (url.includes('/gists/')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'gist-1',
|
|
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
|
|
files: {
|
|
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
|
|
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
|
content: body,
|
|
truncated: false,
|
|
raw_url: 'https://gist.githubusercontent.com/u/abc/raw/unused',
|
|
size: utf8ByteLength(body),
|
|
},
|
|
},
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
rawFetches += 1;
|
|
return new Response('should not fetch', { status: 500 });
|
|
});
|
|
|
|
try {
|
|
const result = await downloadSyncGist('token-xyz', 'gist-1');
|
|
assert.deepEqual(result, small);
|
|
assert.equal(rawFetches, 0);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('downloadSyncGist keeps embedded multibyte content without raw fetch', async () => {
|
|
const small = {
|
|
meta: {
|
|
version: 1,
|
|
deviceName: '我的电脑',
|
|
},
|
|
payload: 'small',
|
|
};
|
|
const body = JSON.stringify(small);
|
|
// GitHub reports UTF-8 bytes; string.length is smaller for CJK, which must not
|
|
// be treated as truncation.
|
|
assert.ok(utf8ByteLength(body) > body.length);
|
|
let rawFetches = 0;
|
|
const { restore } = installFetchMock((url) => {
|
|
if (url.includes('/gists/')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'gist-1',
|
|
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
|
|
files: {
|
|
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
|
|
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
|
content: body,
|
|
truncated: false,
|
|
raw_url: 'https://gist.githubusercontent.com/u/abc/raw/unused',
|
|
size: utf8ByteLength(body),
|
|
},
|
|
},
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
rawFetches += 1;
|
|
return new Response('should not fetch', { status: 500 });
|
|
});
|
|
|
|
try {
|
|
const result = await downloadSyncGist('token-xyz', 'gist-1');
|
|
assert.deepEqual(result, small);
|
|
assert.equal(rawFetches, 0);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('downloadGistRevision also recovers truncated content via raw_url', async () => {
|
|
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/rev/netcatty-vault.json';
|
|
const { restore } = installFetchMock((url) => {
|
|
if (url.includes('/gists/gist-1/deadbeef')) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'gist-1',
|
|
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
|
|
files: {
|
|
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
|
|
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
|
content: TRUNCATED_CONTENT,
|
|
truncated: true,
|
|
raw_url: rawUrl,
|
|
size: utf8ByteLength(FULL_CONTENT),
|
|
},
|
|
},
|
|
created_at: '2026-01-01T00:00:00Z',
|
|
updated_at: '2026-01-01T00:00:00Z',
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (url === rawUrl) {
|
|
return new Response(FULL_CONTENT, { status: 200 });
|
|
}
|
|
return new Response('not found', { status: 404 });
|
|
});
|
|
|
|
try {
|
|
const result = await downloadGistRevision('token-xyz', 'gist-1', 'deadbeef');
|
|
assert.deepEqual(result, FULL_SYNCED_FILE);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|