Simplify subscription profile management UI
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-11 07:36:44 +03:00
parent af1c45e424
commit 5cad3e9061
12 changed files with 148 additions and 251 deletions
+2 -2
View File
@@ -202,8 +202,8 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.match(overview, /const appliedServerId = state\?\.selection\?\.appliedServerId \|\| ''/);
assert.match(overview, /const appliedServer = appliedProfile\?\.servers\.find[\s\S]*state\.selection\.appliedServerSnapshot/);
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{appliedProfile\.label\} · \$\{appliedServer\.label\}`/);
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{operationProfile\.label\} · \$\{operationServer\.label\}`/);
assert.match(overview, /const mainIdentity = gatewayDirect[\s\S]*: connected[\s\S]*appliedProfile && appliedServer[\s\S]*`\$\{subscriptionDomain\(appliedProfile\.subscription\.host\)\} · \$\{appliedServer\.label\}`/);
assert.match(overview, /const switchIdentity = gatewayDirect[\s\S]*switchingServer && operationProfile && operationServer[\s\S]*`Переключаем на \$\{subscriptionDomain\(operationProfile\.subscription\.host\)\} · \$\{operationServer\.label\}`/);
assert.match(overview, /serverSlot=\{<AppliedIdentity identity=\{mainIdentity\} operation=\{switchIdentity\} \/>\}/);
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
+4 -3
View File
@@ -244,10 +244,11 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
test('subscription validation is local and provider errors keep stable detail slots', () => {
assert.match(subscription, /const validationStatus = !normalizedUrl[\s\S]*isSubscriptionUrlValid\(normalizedUrl\) \? 'valid' : 'invalid'/);
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController|status: 'checking'/);
assert.match(subscription, /const message = feature\.duplicateLabel[\s\S]*feature\.validationStatus === 'invalid'[\s\S]*ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message[\s\S]*feature\.addError\?\.message/);
assert.match(subscription, /const message = feature\.validationStatus === 'invalid'[\s\S]*ERROR_DEFINITIONS\.SUBSCRIPTION_INVALID\.message[\s\S]*feature\.addError\?\.message/);
assert.match(subscription, /aria-invalid=\{feature\.validationStatus === 'invalid'\}/);
assert.match(subscription, /disabled=\{feature\.addBlocked \|\| !feature\.label\.trim\(\) \|\| feature\.duplicateLabel \|\| feature\.validationStatus !== 'valid'\}/);
assert.match(subscription, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
assert.match(subscription, /disabled=\{feature\.addBlocked \|\| feature\.validationStatus !== 'valid'\}/);
assert.match(subscription, /const host = new URL\(normalizedUrl\)\.hostname;[\s\S]*if \(!await onAdd\(label, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
assert.doesNotMatch(subscription, />Название<|duplicateLabel/);
assert.match(subscription, /setUrl: \(value: string\) => \{[\s\S]*onDismissError\(\)/);
assert.match(component, /error\.retry[\s\S]*error\.correlationId/);
assert.match(rule('.client-inline-error.is-subscription small'), /flex-basis:\s*100%/);
+6 -4
View File
@@ -94,8 +94,9 @@ test('manual ping uses plain language and keeps results beside server names', ()
assert.match(picker, /<svg className="client-server-health-checking"/);
assert.doesNotMatch(picker, />Проверяем…<\/span>/);
assert.match(picker, /function ServerCheckButton/);
assert.match(picker, /client-server-check client-tooltip-anchor/);
assert.match(picker, /<span className="client-tooltip" role="tooltip">/);
assert.match(picker, /client-server-check\$\{checking \? ' is-checking' : ''\}/);
assert.match(picker, /<span className="client-server-check-label">/);
assert.doesNotMatch(picker, /client-tooltip/);
assert.doesNotMatch(picker, /TCP|Не проверен/);
assert.match(picker, /<strong>\{server\.label\}<\/strong>[\s\S]*?<\/button>[\s\S]*?client-server-meta/);
assert.match(picker, /\[server\.city, server\.country\]\.filter\(Boolean\)\.join\(' · '\)/);
@@ -132,6 +133,7 @@ test('server picker starts simple and reveals advanced controls on demand', () =
assert.match(picker, /\.slice\(0, SIMPLE_SERVER_LIMIT\)/);
assert.match(picker, /\{selected && <ServerRow/);
assert.match(styles, /\.client-profile-body \.client-server-row \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\) 64px;/);
assert.match(styles, /\.client-profile-body \.client-server-row\.is-selected \{[\s\S]*?box-shadow: inset 2px 0 var\(--client-accent\);/);
assert.match(styles, /\.client-profile-body \.client-server-check::after \{[\s\S]*?content: 'ПРОВЕРИТЬ ПИНГ';/);
assert.match(styles, /\.client-profile-body \.client-server-row\.is-selected \{[\s\S]*?background: color-mix\(in oklch, var\(--client-accent\) 5%, transparent\);/);
assert.match(styles, /\.client-profile-body \.client-server-check-label \{[\s\S]*?display: inline;/);
assert.doesNotMatch(styles, /\.client-profile-body \.client-server-check::after/);
});
+15 -15
View File
@@ -36,26 +36,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 795,
cascadeEdges: 792,
customProperties: 31,
declarations: 2996,
declarations: 2977,
important: 0,
keyframes: 51,
media: 13,
rules: 911,
variableReferences: 347,
rules: 910,
variableReferences: 338,
},
hashes: {
cascadeEdges: '5cf4f4dc60eabe1e8175fa7c514a4ebc95983b588c2190adea0f75ffbbb813ef',
cascadeEdges: 'f83d896540885ddaf09e55551a2f1ba5b587dcebfc285cc305efe80136154d93',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: '47604ceab56972914f0c193ea10b3fc29b2532ca5b8a8057d1cfb437f5c8f86e',
declarations: '14728723e4a95d6031b60eb39c0864da0ccc061e24798091cd093b6354a5d906',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: 'e063b7ac4965baed089fbcbba37544d7545eec7a7e84c3a68589465af53ed017',
duplicateSelectors: 'c0ec5c96e48cd2ceed32aa5050383bcf29c3c0b8f1b440ed6883435e3e9b964d',
keyframes: '9e87e997d0b776462ac3e495c7ced584871a5d9512226e5008eb84b4d311530a',
ruleDeclarationSequences: 'd7db36cc3209e48c75e6e1b0c2748d0bff6be5a078f4e61b97c039d9da9c12cb',
selectors: 'cd61e05ec477919f2c34d3944edc4fbb175b1572f8060a7696f0b73a687ddfad',
variableReferences: 'b6f3567fc26a250c81f9214d1486a1cd3f7998012383aeb9d9e53fdeb32b17b1',
witnesses: '1ba29e4efc61874a5c329d7d48d0fc399dae8b1c9dccfea0e682ceba3cb1630f',
ruleDeclarationSequences: 'eac60ada2de39eff87713f71f5ca533cd831ee7d400d27eaa09562c29dfbfadd',
selectors: 'e79f61996bcd2a806828406c0a626e87d60112f1b24264d60b8670f9b8f831af',
variableReferences: '5c56197ab72968fc007f333c0cc58f90b6ec90465d6273bc757b7c77503dd26e',
witnesses: '7066589e7b69a5b3c728ff70430a8d6d3c6c6d562cea91377244c0754e18b8da',
},
};
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 745);
assert.equal(witnesses.length, 735);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -306,8 +306,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-BJwATLJx.css']);
assert.deepEqual(assets, ['index-pEpt1ReD.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 112406);
assert.equal(sha256(built), '4b117a70cf431bbe7aacd368df3cb30ab50c5ab58498190b3822b92607aa9045');
assert.equal(built.byteLength, 111837);
assert.equal(sha256(built), 'f797af224ecae5e0435c7fb165cc158a237d11654c48a8cb287b32b2230e6b0c');
});
@@ -33,10 +33,10 @@ test('App owns profile mutations and always uses the latest canonical revision',
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\.rename\(profileId, label, 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]*onRename: onRenameProfile,[\s\S]*onRefresh: onRefreshProfile,[\s\S]*onForget: onForgetProfile/);
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/);
});
@@ -52,8 +52,8 @@ test('local validation, scoped refresh and drawer focus remain feature-owned', (
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.doesNotMatch(feature, /addInvokerRef/);
assert.match(feature, /function cancelRename\(\)[\s\S]*client-profile-menu-\$\{profileId\}[\s\S]*\.focus\(\)/);
assert.match(feature, /id=\{`client-profile-menu-\$\{profile\.id\}`\}/);
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', () => {
@@ -85,7 +85,7 @@ test('validation rejection parser preserves structured errors and normalizes non
});
test('feature keeps exact slots, truthy closes and subscription DOM order', () => {
assert.match(feature, /if \(!await onAdd\(normalizedLabel, normalizedUrl\)\) return;[\s\S]*resetAdd\(\)/);
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, /client-profiles-operation[\s\S]*\{statusSlot\}[\s\S]*client-profile-list[\s\S]*feature\.adding[\s\S]*AddProfileForm[\s\S]*client-profile-add-trigger/);
assert.doesNotMatch(feature, /client-profiles-current|aria-label="Добавить подписку"/);
@@ -106,6 +106,9 @@ test('profiles render as flat accordion groups with scoped controls', () => {
assert.match(feature, /!expanded && 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-refresh\.is-refreshing/);