Remove initial server health check and bump Harbor versions
This commit is contained in:
@@ -90,6 +90,60 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
|
||||
);
|
||||
});
|
||||
|
||||
test('startup discards a rejected cached subscription and returns to first-run', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-rejected-cache-'));
|
||||
const port = await freePort();
|
||||
const subscriptionUrl = 'https://provider.example/disabled';
|
||||
const rejectedServer = {
|
||||
type: 'vless',
|
||||
tag: '🚫 Subscription disabled',
|
||||
server: '0.0.0.0',
|
||||
server_port: 1,
|
||||
};
|
||||
const routeRules = [{ type: 'domain_suffix', value: 'example.org', enabled: true }];
|
||||
fs.writeFileSync(path.join(dir, 'state.json'), JSON.stringify({
|
||||
subscriptionUrl,
|
||||
selectedTag: rejectedServer.tag,
|
||||
servers: [rejectedServer],
|
||||
routeRules,
|
||||
}));
|
||||
fs.writeFileSync(path.join(dir, 'subscription-cache.json'), JSON.stringify({
|
||||
url: subscriptionUrl,
|
||||
config: { outbounds: [rejectedServer] },
|
||||
}));
|
||||
fs.writeFileSync(path.join(dir, 'sing-box-config.json'), '{}');
|
||||
|
||||
const child = spawn(process.execPath, ['src/server/index.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
APP_MODE: 'client',
|
||||
DATA_DIR: dir,
|
||||
PORT: String(port),
|
||||
HARBOR_HOST_NETWORK_STATE: path.join(dir, 'missing-network.json'),
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
t.after(async () => {
|
||||
child.kill('SIGTERM');
|
||||
if (child.exitCode === null) await new Promise((resolve) => child.once('exit', resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const state = await waitForState(port, child, () => stderr);
|
||||
assert.equal(state.subscription.status, 'missing');
|
||||
assert.equal(state.hasSubscription, false);
|
||||
assert.deepEqual(state.servers, []);
|
||||
assert.ok(state.route.localRules.some((rule) => (
|
||||
rule.type === 'domain_suffix' && rule.value === 'example.org' && rule.enabled
|
||||
)));
|
||||
assert.equal(fs.existsSync(path.join(dir, 'subscription-cache.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'sing-box-config.json')), false);
|
||||
assert.equal(child.exitCode, null);
|
||||
});
|
||||
|
||||
test('data invariant: API mutations return one snapshot, increase revision and roll back subscription failures', async (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-contract-'));
|
||||
const binDir = path.join(dir, 'bin');
|
||||
@@ -121,6 +175,7 @@ setInterval(() => {}, 60_000);
|
||||
let providerFetchCount = 0;
|
||||
let delayedPath = '';
|
||||
let invalidNextPath = '';
|
||||
let trafficExhaustedNextPath = '';
|
||||
let delayedRequestStarted = null;
|
||||
let releaseDelayedRequest = null;
|
||||
const subscriptionServer = http.createServer(async (req, res) => {
|
||||
@@ -134,6 +189,42 @@ setInterval(() => {}, 60_000);
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
return res.end('not a subscription');
|
||||
}
|
||||
if (req.url === '/traffic' || req.url === trafficExhaustedNextPath) {
|
||||
trafficExhaustedNextPath = '';
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'subscription-userinfo': 'upload=60; download=40; total=100; expire=4102444800',
|
||||
});
|
||||
return res.end(JSON.stringify({
|
||||
outbounds: [{
|
||||
type: 'vless',
|
||||
tag: 'Account unavailable',
|
||||
server: '0.0.0.0',
|
||||
server_port: 1,
|
||||
}],
|
||||
}));
|
||||
}
|
||||
if (req.url === '/expired') {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'subscription-userinfo': 'upload=10; download=20; total=100; expire=1',
|
||||
});
|
||||
return res.end(JSON.stringify(config));
|
||||
}
|
||||
if (req.url === '/disabled') {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/json',
|
||||
'subscription-userinfo': 'upload=0; download=0; total=100; expire=4102444800',
|
||||
});
|
||||
return res.end(JSON.stringify({
|
||||
outbounds: [{
|
||||
type: 'vless',
|
||||
tag: '🚫 Subscription disabled',
|
||||
server: '0.0.0.0',
|
||||
server_port: 1,
|
||||
}],
|
||||
}));
|
||||
}
|
||||
if (req.url === invalidNextPath) {
|
||||
invalidNextPath = '';
|
||||
res.writeHead(200, { 'content-type': 'text/plain' });
|
||||
@@ -240,6 +331,9 @@ setInterval(() => {}, 60_000);
|
||||
for (const [pathname, expectedCode] of [
|
||||
['/timeout', 'PROVIDER_UNAVAILABLE'],
|
||||
['/invalid', 'SUBSCRIPTION_INVALID'],
|
||||
['/expired', 'SUBSCRIPTION_EXPIRED'],
|
||||
['/traffic', 'SUBSCRIPTION_TRAFFIC_EXHAUSTED'],
|
||||
['/disabled', 'SUBSCRIPTION_DISABLED'],
|
||||
]) {
|
||||
const failedImport = await rawRequest(
|
||||
port,
|
||||
@@ -262,6 +356,16 @@ setInterval(() => {}, 60_000);
|
||||
);
|
||||
}
|
||||
|
||||
trafficExhaustedNextPath = '/subscription/test';
|
||||
const exhaustedRefresh = await rawRequest(port, '/api/subscription/refresh', 'POST');
|
||||
assert.equal(exhaustedRefresh.response.status, 400);
|
||||
assert.equal(exhaustedRefresh.payload.error.code, 'SUBSCRIPTION_TRAFFIC_EXHAUSTED');
|
||||
const stateAfterExhaustedRefresh = await request(port, '/api/state');
|
||||
assert.equal(stateAfterExhaustedRefresh.hasSubscription, true);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8'), preservedSubscription.cache);
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'sing-box-config.json'), 'utf8'), preservedSubscription.config);
|
||||
revision = stateAfterExhaustedRefresh.revision;
|
||||
|
||||
const missingServer = await rawRequest(
|
||||
port,
|
||||
'/api/apply',
|
||||
|
||||
@@ -54,3 +54,25 @@ test('removed selected server requires an explicit new choice', () => {
|
||||
assert.equal(selectRefreshedServer(before[1].id, before, after), '');
|
||||
assert.equal(selectRefreshedServer('', before, after), '');
|
||||
});
|
||||
|
||||
test('provider placeholders never become selectable servers', () => {
|
||||
const disabled = outbound('🚫 Subscription disabled', '0.0.0.0', 1);
|
||||
const trafficExhausted = outbound('🚫 Traffic limit exceeded', '0.0.0.0', 1);
|
||||
|
||||
assert.throws(
|
||||
() => parse([disabled]),
|
||||
(error) => error.code === 'SUBSCRIPTION_DISABLED',
|
||||
);
|
||||
assert.throws(
|
||||
() => parse([outbound('Account error', '::', 1)]),
|
||||
(error) => error.code === 'SUBSCRIPTION_REJECTED',
|
||||
);
|
||||
assert.throws(
|
||||
() => parse([trafficExhausted]),
|
||||
(error) => error.code === 'SUBSCRIPTION_TRAFFIC_EXHAUSTED',
|
||||
);
|
||||
|
||||
const parsed = parse([disabled, outbound('Amsterdam', 'nl.example')]);
|
||||
assert.deepEqual(parsed.servers.map((server) => server.label), ['Amsterdam']);
|
||||
assert.equal(parsed.config.outbounds.length, 1);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ const versions = {
|
||||
test('version paths map to the components actually shipped by this repository', () => {
|
||||
assert.deepEqual(affectedComponents(['src/web/App.jsx']), ['macClient', 'gatewayClient']);
|
||||
assert.deepEqual(affectedComponents(['src/server/index.js']), ['macClient', 'gatewayBackend']);
|
||||
assert.deepEqual(affectedComponents(['docker-compose.client.local.yml']), ['macClient']);
|
||||
assert.deepEqual(affectedComponents(['package-lock.json']), [
|
||||
'macClient',
|
||||
'gatewayClient',
|
||||
|
||||
@@ -5,6 +5,7 @@ import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||
|
||||
function rule(selector, source = styles) {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -33,12 +34,15 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
|
||||
test('server rows scroll without moving the subscription column or showing a scrollbar', () => {
|
||||
const scroll = rule('.client-server-scroll');
|
||||
const simpleScroll = rule('.client-server-mode-panel.is-simple .client-server-scroll');
|
||||
const grid = rule('.client-server-grid');
|
||||
|
||||
assert.match(scroll, /overflow-y:\s*auto/);
|
||||
assert.match(scroll, /scrollbar-width:\s*none/);
|
||||
assert.match(styles, /\.client-server-scroll::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/);
|
||||
assert.match(simpleScroll, /max-height:\s*none/);
|
||||
assert.match(simpleScroll, /overflow:\s*visible/);
|
||||
assert.match(grid, /width:\s*min\(100%, 150px\)/);
|
||||
assert.doesNotMatch(rule('.client-servers.is-scalable .client-server-grid'), /width:/);
|
||||
});
|
||||
|
||||
test('tablet and mobile regions use normal flow with viewport-safe widths', () => {
|
||||
@@ -92,3 +96,21 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
|
||||
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
||||
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
||||
});
|
||||
|
||||
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
|
||||
assert.match(component, /api\.subscription\.validate\(normalizedSubscriptionUrl/);
|
||||
assert.match(component, /status: 'checking'/);
|
||||
assert.match(component, /status: 'valid'/);
|
||||
assert.match(component, /message: ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message/);
|
||||
assert.match(component, /subscriptionValidationStatus === 'checking' \? '…' : '×'/);
|
||||
assert.match(component, /if \(error\?\.context === 'subscription'\) onDismissError\(\)/);
|
||||
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
|
||||
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
|
||||
assert.match(rule('.client-inline-error.is-subscription small'), /opacity:\s*0\.45/);
|
||||
assert.match(styles, /@keyframes client-subscription-error-in[\s\S]*filter:\s*blur\(7px\)/);
|
||||
assert.match(styles, /@keyframes client-subscription-code-in[\s\S]*transform:\s*translateY\(-2px\)/);
|
||||
assert.match(
|
||||
/@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '',
|
||||
/\.client-inline-error\.is-subscription > \*/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,8 +17,11 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
|
||||
assert.match(component, /activeLocalRules/);
|
||||
assert.match(component, /localRulesRevision/);
|
||||
assert.match(component, /Не сохранено/);
|
||||
assert.match(component, /if \(!runtimeActive\) return \['saved', 'Сохранено'\]/);
|
||||
assert.match(component, /Ждёт перезапуска/);
|
||||
assert.match(component, /Перезапустить VPN/);
|
||||
assert.match(component, /const localRulesPendingRestart = connected && state\?\.route\?\.localRulesPendingRestart === true/);
|
||||
assert.match(component, /if \(!connected \|\| !result\.state\.route\.localRulesPendingRestart\) setLocalRulesOpen\(false\)/);
|
||||
assert.match(component, /client-deletable-row/);
|
||||
assert.match(component, /className="client-delete-strike"[\s\S]*onAnimationEnd/);
|
||||
assert.doesNotMatch(component, /client-rule-delete-cross/);
|
||||
|
||||
@@ -36,11 +36,9 @@ test('server picker handles 1, 30 and 300 stable-ID servers with duplicate label
|
||||
assert.equal(SERVER_RESULT_WINDOW, 60);
|
||||
});
|
||||
|
||||
test('server picker checks health once on load, keeps manual refresh and bounds the result window', () => {
|
||||
test('server picker checks health only on manual refresh and bounds the result window', () => {
|
||||
assert.doesNotMatch(overview, /pingAll|servers\.ping/);
|
||||
assert.match(picker, /const initialCheckStarted = useRef\(false\)/);
|
||||
assert.match(picker, /if \(initialCheckStarted\.current \|\| !servers\.length\) return/);
|
||||
assert.match(picker, /initialCheckStarted\.current = true;\s*checkVisible\(\)/);
|
||||
assert.doesNotMatch(picker, /checkVisible\(\);/);
|
||||
assert.match(picker, /onClick={checkVisible}/);
|
||||
assert.match(picker, /\{ \.\.\.current\[id\], checking: true \}/);
|
||||
assert.match(picker, /700 - \(performance\.now\(\) - startedAt\)/);
|
||||
|
||||
Reference in New Issue
Block a user