Files
harbor-net/test/web/subscription-feature-contract.test.js
T
dokril 9ad0307333
Build and Deploy Gateway / build-and-push (push) Successful in 28s
Build and Deploy Gateway / deploy (push) Successful in 7s
Polish subscription and server picker animations
2026-08-11 07:55:41 +03:00

122 lines
8.0 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { normalizeRequestError } from '../../.test-dist/src/web/features/subscription/requestError.js';
const root = path.resolve(import.meta.dirname, '../..');
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
const app = fs.readFileSync(path.join(root, 'src/web/App.tsx'), 'utf8');
const feature = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/subscription.css'), 'utf8');
const boundary = fs.readFileSync(path.join(root, 'src/web/features/subscription/index.ts'), 'utf8');
test('subscription feature is the sole always-mounted lifecycle and view owner', () => {
assert.match(boundary, /SubscriptionDeleteDialog,[\s\S]*SubscriptionPanel,[\s\S]*SubscriptionToggle,[\s\S]*useSubscriptionFeature/);
assert.match(page, /from '\.\.\/features\/subscription\/index\.js'/);
assert.equal((page.match(/useSubscriptionFeature\(/g) || []).length, 1);
assert.equal((page.match(/<SubscriptionToggle/g) || []).length, 1);
assert.equal((page.match(/<SubscriptionPanel/g) || []).length, 1);
assert.equal((page.match(/<SubscriptionDeleteDialog/g) || []).length, 1);
assert.doesNotMatch(page, /client-subscription-summary|client-usage-bar|id="delete-subscription"/);
assert.doesNotMatch(page, /subscriptionValidationAttempt|confirmingDeleteRef|previousHasSubscriptionRef|SUBSCRIPTION_REVEAL_DELAY_MS/);
assert.match(feature, /function ProfileGroup/);
assert.match(feature, /client-profile-list/);
assert.match(feature, /id="delete-subscription"/);
});
test('App owns profile mutations and always uses the latest canonical revision', () => {
assert.match(app, /const revisionRef = useRef\(0\)/);
assert.match(app, /const hasAcceptedSnapshotRef = useRef\(false\)/);
assert.equal((app.match(/if \(!hasAcceptedSnapshotRef\.current \|\| snapshot\.revision > revisionRef\.current\)/g) || []).length, 2);
assert.equal((app.match(/revisionRef\.current = snapshot\.revision/g) || []).length, 2);
assert.doesNotMatch(app, /if \(state\) revisionRef\.current = state\.revision/);
assert.match(app, /api\.profiles\.add\(label, url, revisionRef\.current\)/);
assert.match(app, /api\.profiles\.refresh\(profileId, revisionRef\.current\)/);
assert.match(app, /api\.profiles\.forget\(profileId, mode, revisionRef\.current\)/);
assert.match(page, /profiles,[\s\S]*onAdd: onAddProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/);
assert.doesNotMatch(page, /onRenameProfile/);
assert.doesNotMatch(feature, /from ['"][^'"]*\/api\/|\bapi\./);
assert.doesNotMatch(feature, /<ServerPicker|<InlineError|<InlineProgress|<ConnectionPanel|<DevicesPanel|<DiagnosticsPanel/);
});
test('local validation, scoped refresh and drawer focus remain feature-owned', () => {
assert.match(feature, /isSubscriptionUrlValid\(normalizedUrl\)/);
assert.doesNotMatch(feature, /validateSubscription\(|AbortController|setTimeout\(async/);
assert.match(feature, /async function refresh\(profileId: string\)/);
assert.match(feature, /Math\.max\(900, Math\.ceil\(elapsed \/ 900\) \* 900\)/);
assert.match(feature, /previousProfileCountRef\.current === 0 && profiles\.length > 0[\s\S]*setOpen\(true\)[\s\S]*setExpanded/);
assert.match(feature, /previousProfileCountRef\.current > 0\) setOpen\(false\)/);
assert.match(feature, /if \(!adding \|\| \(profiles\.length > 0 && !open\)\) return undefined/);
assert.match(feature, /if \(deleteIdRef\.current\) return;[\s\S]*toggleRef\.current\?\.focus\(\)/);
assert.match(feature, /setAdding\(false\)[\s\S]*client-profile-add-trigger'\)\?\.focus\(\)/);
assert.match(feature, /addFormRef\.current\?\.contains\(target\)[\s\S]*resetAdd\(Boolean\(target && panelRef\.current\?\.contains\(target\)\)\)/);
assert.match(feature, /feature\.adding \|\| feature\.addClosing/);
assert.doesNotMatch(feature, /addInvokerRef/);
assert.match(feature, /className="client-profile-delete"[\s\S]*client-profile-delete-lid/);
assert.doesNotMatch(feature, /cancelRename|client-profile-menu|onRename/);
});
test('validation rejection parser preserves structured errors and normalizes non-objects', () => {
const retry = () => true;
const structured = Object.assign(new Error('provider unavailable'), {
name: 'HarborApiError',
context: 'subscription',
correlationId: 'correlation-1',
retryable: true,
retry,
});
assert.deepEqual(normalizeRequestError(structured), {
name: 'HarborApiError',
context: 'subscription',
message: 'provider unavailable',
correlationId: 'correlation-1',
retryable: true,
retry,
});
assert.deepEqual(normalizeRequestError(null), {
name: undefined,
context: undefined,
message: 'Ссылка подписки недействительна.',
correlationId: undefined,
retryable: false,
retry: null,
});
assert.equal(normalizeRequestError('provider rejected').message, 'Ссылка подписки недействительна.');
});
test('feature keeps exact slots, truthy closes and subscription DOM order', () => {
assert.match(feature, /const host = new URL\(normalizedUrl\)\.hostname;[\s\S]*if \(!await onAdd\(label, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
assert.match(feature, /if \(!await onForget\(deleteProfile\.id, deleteStopsVpn \? 'stop-and-delete' : 'delete'\)\) return;[\s\S]*setDeleteId\(''\)/);
assert.match(feature, /\{statusSlot\}[\s\S]*client-profile-list[\s\S]*feature\.adding[\s\S]*AddProfileForm[\s\S]*client-profile-add-trigger/);
assert.doesNotMatch(feature, /client-profiles-operation|activeOperation|operationText/);
assert.doesNotMatch(feature, /client-profiles-current|aria-label="Добавить подписку"/);
assert.match(feature, /const drawerOpen = feature\.open && feature\.profiles\.length > 0/);
assert.match(feature, /feature\.profiles\.length === 0 && <div className="client-form client-subscription-first-run"/);
assert.match(page, /<SubscriptionPanel[\s\S]*statusSlot=\{<>[\s\S]*InlineError[\s\S]*renderServerPicker=[\s\S]*<ServerPicker/);
assert.doesNotMatch(page, /<InlineProgress[^>]*context="subscription"/);
assert.match(page, /<SubscriptionToggle[\s\S]*subscriptionFeature\.toggle\(\)/);
assert.match(page, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/);
assert.doesNotMatch(feature, /setInterval|copyText|client-live-region/);
});
test('profiles render as flat accordion groups with scoped controls', () => {
assert.match(feature, /feature\.profiles\.map\(\(profile\) => <ProfileGroup/);
assert.match(feature, /aria-expanded=\{expanded\}/);
assert.match(feature, /profile\.subscription\.status === 'stale'[\s\S]*profile\.subscription\.fetchedAt/);
assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/);
assert.match(feature, /!expanded && !closing && visibleServer && <button[\s\S]*client-profile-selected-server/);
assert.match(feature, /const visibleServerId = applied \? feature\.selection\.appliedServerId : profile\.desiredServerId/);
assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/);
assert.match(feature, /const profileDomain = subscriptionDomain\(profile\.subscription\.host\)/);
assert.match(feature, /const profileDetails = `Серверов: \$\{profile\.servers\.length\}/);
assert.doesNotMatch(feature, /<strong>\{profile\.label\}<\/strong>|Переименовать|client-profile-menu/);
assert.match(styles, /\.client-profile-group/);
assert.match(styles, /\.client-profile-body/);
assert.match(styles, /\.client-profile-body \{[\s\S]*grid-template-rows: 0fr[\s\S]*\.client-profile-body\.is-open \{[\s\S]*grid-template-rows: 1fr/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-profile-body \*/);
assert.match(styles, /\.client-profile-refresh\.is-refreshing/);
assert.doesNotMatch(feature, /client-subscription-card|role="tab"/);
});