mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(app-update): add in-app update flow with native e2e coverage
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDirectory, '..');
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const createTempWorkspace = async (prefix) => fs.promises.mkdtemp(path.join(os.tmpdir(), `${prefix}-`));
|
||||
|
||||
const logStep = (message) => {
|
||||
console.log(`[app-update-e2e] ${message}`);
|
||||
};
|
||||
|
||||
const runCommand = (command, args, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
logStep(`$ ${command} ${args.join(' ')}`);
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
},
|
||||
stdio: options.captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
if (options.captureOutput) {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `${command} exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
|
||||
const waitFor = async (predicate, { timeoutMs = 120000, intervalMs = 1000, description = 'condition' } = {}) => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await predicate();
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${description}`);
|
||||
};
|
||||
|
||||
const findFirstMatchingPath = async (rootPath, matcher) => {
|
||||
const entries = await fs.promises.readdir(rootPath, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(rootPath, entry.name);
|
||||
if (matcher(entryPath, entry)) {
|
||||
return entryPath;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nestedMatch = await findFirstMatchingPath(entryPath, matcher);
|
||||
if (nestedMatch) {
|
||||
return nestedMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const copyPath = async (sourcePath, targetPath) => {
|
||||
await fs.promises.rm(targetPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.promises.mkdir(path.dirname(targetPath), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
if (process.platform === 'darwin' && sourcePath.endsWith('.app')) {
|
||||
await runCommand('/usr/bin/ditto', [sourcePath, targetPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.promises.cp(sourcePath, targetPath, {
|
||||
recursive: true,
|
||||
});
|
||||
};
|
||||
|
||||
const startFixtureServer = async () => {
|
||||
const state = {
|
||||
version: null,
|
||||
assets: [],
|
||||
};
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const requestUrl = new URL(request.url || '/', `http://${request.headers.host || '127.0.0.1'}`);
|
||||
|
||||
response.setHeader('Access-Control-Allow-Origin', '*');
|
||||
response.setHeader('Access-Control-Allow-Headers', '*');
|
||||
response.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS');
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === '/releases/latest') {
|
||||
if (!state.version || state.assets.length === 0) {
|
||||
response.writeHead(503, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
response.end(JSON.stringify({ error: 'fixture release not configured' }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
tag_name: `v${state.version}`,
|
||||
html_url: `${requestUrl.origin}/releases/v${state.version}`,
|
||||
assets: state.assets.map((asset) => ({
|
||||
name: asset.name,
|
||||
browser_download_url: `${requestUrl.origin}/assets/${asset.name}`,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname.startsWith('/releases/')) {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
});
|
||||
response.end('<!doctype html><title>Fixture Release</title><p>Fixture release page</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname.startsWith('/assets/')) {
|
||||
const assetName = decodeURIComponent(requestUrl.pathname.slice('/assets/'.length));
|
||||
const matchedAsset = state.assets.find((asset) => asset.name === assetName);
|
||||
|
||||
if (!matchedAsset) {
|
||||
response.writeHead(404, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
});
|
||||
response.end('asset not found');
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
fs.createReadStream(matchedAsset.filePath).pipe(response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
});
|
||||
response.end('not found');
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.on('error', reject);
|
||||
server.listen(0, '0.0.0.0', resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Could not resolve fixture server port');
|
||||
}
|
||||
|
||||
return {
|
||||
port: address.port,
|
||||
setRelease(version, assets) {
|
||||
state.version = version;
|
||||
state.assets = assets;
|
||||
logStep(`fixture server release set to v${version} with assets: ${assets.map((asset) => asset.name).join(', ')}`);
|
||||
},
|
||||
async close() {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { copyPath, createTempWorkspace, findFirstMatchingPath, logStep, repoRoot, runCommand, sleep, startFixtureServer, waitFor };
|
||||
Reference in New Issue
Block a user