Files
vs-code-setup/test/fake-gitea.js
T
max d45639bf2f Build, publish to Gitea releases, and install the three extensions
Answers two things: how to install these without a marketplace, and how to keep
settings in sync when VS Code's Settings Sync cannot be pointed at Gitea.

- build.ps1 packages each extension into dist/
- publish.ps1 creates a release and uploads them, replacing same-named assets so a
  rolling "latest" tag stays clean
- install.ps1 downloads a release's assets and installs them, or -Local from dist/
- settings.ps1 copies User/ between the repo and the machine, and installs the
  marketplace extensions recorded in extensions.txt
- dev-link.ps1 junctions the sources into ~/.vscode/extensions for development
- ci-release.sh plus a Gitea Actions workflow do the build and publish on a tag push

Settings are copied, not symlinked: an atomic save replaces a symlink with a regular
file and the sync stops without looking broken.

vsce/ pins vsce and undici so packaging works on Node 18, which current vsce does not
support.

test/run.ps1 drives the real scripts against a stub of the Gitea release API. It
caught the multipart Content-Disposition being unquoted on .NET Framework, and
Invoke-WebRequest dropping the Authorization header across a redirect.
2026-09-07 19:35:20 +02:00

216 lines
8.3 KiB
JavaScript

// A stub of the parts of the Gitea API that publish.ps1 and install.ps1 use.
//
// The point is to prove the real scripts work: that the token header is sent, that the
// multipart upload delivers the .vsix byte-for-byte (a zip, so any encoding step would
// corrupt it), that re-publishing replaces assets instead of duplicating them, and that
// the download survives a redirect with the auth header intact.
const http = require('http');
const crypto = require('crypto');
const PORT = Number(process.env.PORT || 5602);
const TOKEN = 'TESTTOKEN';
let nextId = 100;
const releases = []; // { id, tag_name, name, body, assets: [] }
const blobs = new Map(); // uuid -> Buffer
const log = [];
function sha256(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex').toUpperCase();
}
function authorized(request) {
return request.headers.authorization === `token ${TOKEN}`;
}
function send(response, status, payload) {
const body = payload === undefined ? '' : JSON.stringify(payload);
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(body);
}
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on('data', chunk => chunks.push(chunk));
request.on('end', () => resolve(Buffer.concat(chunks)));
request.on('error', reject);
});
}
/** The one file out of a multipart/form-data body, as raw bytes. */
function parseMultipart(buffer, contentType) {
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
if (!match) { return null; }
const boundary = Buffer.from(`--${match[1] || match[2]}`);
const parts = [];
let at = buffer.indexOf(boundary);
while (at >= 0) {
const from = at + boundary.length;
const next = buffer.indexOf(boundary, from);
if (next < 0) { break; }
parts.push(buffer.slice(from, next));
at = next;
}
for (const part of parts) {
const split = part.indexOf('\r\n\r\n');
if (split < 0) { continue; }
const headers = part.slice(0, split).toString('latin1');
// Trailing CRLF belongs to the delimiter, not the content.
let content = part.slice(split + 4);
if (content.slice(-2).toString('latin1') === '\r\n') {
content = content.slice(0, -2);
}
// Values may be unquoted: .NET Framework's MultipartFormDataContent writes
// name=attachment, and Go's mime/multipart (what Gitea uses) accepts both.
const name = /\bname=(?:"([^"]*)"|([^;\s]+))/i.exec(headers);
const filename = /\bfilename=(?:"([^"]*)"|([^;\s]+))/i.exec(headers);
const fieldName = name ? (name[1] !== undefined ? name[1] : name[2]) : null;
if (fieldName === 'attachment') {
return {
content,
filename: filename ? (filename[1] !== undefined ? filename[1] : filename[2]) : null,
headers: headers.trim(),
};
}
}
return null;
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, 'http://localhost');
const path = url.pathname;
log.push(`${request.method} ${request.url}`);
// Serving an attachment: what browser_download_url points at.
let hit = /^\/attachments\/([^/]+)$/.exec(path);
if (hit && request.method === 'GET') {
// Redirect once, so the client has to carry the auth header across it.
response.writeHead(302, { Location: `/files/${hit[1]}` });
return response.end();
}
hit = /^\/files\/([^/]+)$/.exec(path);
if (hit && request.method === 'GET') {
if (!authorized(request)) {
log.push(' !! download had no token');
return send(response, 401, { message: 'token required on download' });
}
const blob = blobs.get(hit[1]);
if (!blob) { return send(response, 404, { message: 'no such attachment' }); }
response.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': blob.length,
});
return response.end(blob);
}
// Test-only: lets the harness assert on what the server actually received.
if (path === '/__state') {
return send(response, 200, {
log,
releases: releases.map(release => ({
id: release.id,
tag_name: release.tag_name,
name: release.name,
assets: release.assets.map(asset => ({
id: asset.id,
name: asset.name,
size: asset.size,
sha256: asset.sha256,
uploadFilename: asset.uploadFilename,
})),
})),
});
}
if (!authorized(request)) {
log.push(' !! missing or wrong token');
return send(response, 401, { message: 'token required' });
}
const api = /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/releases(.*)$/.exec(path);
if (!api) { return send(response, 404, { message: `unhandled ${path}` }); }
const rest = api[3];
// GET /releases/tags/:tag
hit = /^\/tags\/(.+)$/.exec(rest);
if (hit && request.method === 'GET') {
const tag = decodeURIComponent(hit[1]);
const release = releases.find(candidate => candidate.tag_name === tag);
if (!release) { return send(response, 404, { message: 'release not found' }); }
return send(response, 200, release);
}
// POST /releases
if (rest === '' && request.method === 'POST') {
const body = JSON.parse((await readBody(request)).toString('utf8') || '{}');
if (!body.tag_name) { return send(response, 422, { message: 'tag_name required' }); }
const release = {
id: nextId++,
tag_name: body.tag_name,
name: body.name || body.tag_name,
body: body.body || '',
draft: !!body.draft,
prerelease: !!body.prerelease,
target_commitish: body.target_commitish || 'main',
assets: [],
};
releases.push(release);
return send(response, 201, release);
}
// POST /releases/:id/assets?name=...
hit = /^\/(\d+)\/assets$/.exec(rest);
if (hit && request.method === 'POST') {
const release = releases.find(candidate => candidate.id === Number(hit[1]));
if (!release) { return send(response, 404, { message: 'no such release' }); }
const raw = await readBody(request);
const part = parseMultipart(raw, request.headers['content-type']);
if (!part) {
const preview = raw.slice(0, 400).toString('latin1').replace(/\r\n/g, '\\r\\n');
log.push(` !! no attachment field. content-type=${request.headers['content-type']}`);
log.push(` !! body starts: ${preview}`);
return send(response, 400, { message: 'expected multipart with an attachment field' });
}
log.push(` disposition: ${part.headers.replace(/\r\n/g, ' | ')}`);
const name = url.searchParams.get('name') || part.filename || 'unnamed';
const uuid = crypto.randomBytes(8).toString('hex');
blobs.set(uuid, part.content);
const asset = {
id: nextId++,
name,
size: part.content.length,
sha256: sha256(part.content),
uploadFilename: part.filename,
browser_download_url: `http://127.0.0.1:${PORT}/attachments/${uuid}`,
};
release.assets.push(asset);
return send(response, 201, asset);
}
// DELETE /releases/:id/assets/:assetId
hit = /^\/(\d+)\/assets\/(\d+)$/.exec(rest);
if (hit && request.method === 'DELETE') {
const release = releases.find(candidate => candidate.id === Number(hit[1]));
if (!release) { return send(response, 404, { message: 'no such release' }); }
const before = release.assets.length;
release.assets = release.assets.filter(asset => asset.id !== Number(hit[2]));
if (release.assets.length === before) {
return send(response, 404, { message: 'no such asset' });
}
response.writeHead(204);
return response.end();
}
return send(response, 404, { message: `unhandled ${request.method} ${path}` });
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`fake gitea on http://127.0.0.1:${PORT} (token ${TOKEN})`);
});