This commit is contained in:
@@ -0,0 +1,675 @@
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function paragraph(text) {
|
||||
return '<p>' + text + '</p>';
|
||||
}
|
||||
|
||||
function heading(text) {
|
||||
return '<h2>' + text + '</h2>';
|
||||
}
|
||||
|
||||
function codeBlock(lines) {
|
||||
return '<pre><code>' + escapeHtml(Array.isArray(lines) ? lines.join('\n') : lines) + '</code></pre>';
|
||||
}
|
||||
|
||||
function figure(src, alt, caption) {
|
||||
return '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||||
}
|
||||
|
||||
function orderedList(items) {
|
||||
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||||
}
|
||||
|
||||
function dataTable(caption, headers, rows) {
|
||||
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
|
||||
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
|
||||
return '<div class="table-scroll"><table><caption>' + caption + '</caption>' + head + body + '</table></div>';
|
||||
}
|
||||
|
||||
function sourceList(items) {
|
||||
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||||
}
|
||||
|
||||
function plainText(content) {
|
||||
return content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function bodyText(content) {
|
||||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||||
}
|
||||
|
||||
function createRevision(meta, bodyParts, sources) {
|
||||
if (sources.length < 2) {
|
||||
throw new Error(meta.slug + ': нужно минимум два первичных или официальных источника');
|
||||
}
|
||||
|
||||
const contentHtml = bodyParts.join('\n') + '\n' + heading('Проверяемые источники') + '\n' + sourceList(sources);
|
||||
const proseLength = bodyText(contentHtml).length;
|
||||
|
||||
if (proseLength < 5000 || proseLength > 15000) {
|
||||
throw new Error(meta.slug + ': основной текст вне 5 000–15 000 знаков: ' + proseLength);
|
||||
}
|
||||
|
||||
return { ...meta, contentHtml, proseLength };
|
||||
}
|
||||
|
||||
const navigationTimingDraft = {
|
||||
title: 'W3C Navigation Timing Level 2, Working Draft от 17 января 2022 года',
|
||||
url: 'https://www.w3.org/TR/2022/WD-navigation-timing-2-20220117/',
|
||||
note: 'исторический снимок W3C, существовавший до февральских дат публикации. Он описывает интерфейс PerformanceNavigationTiming и временную шкалу навигации; учебные поля fixture не являются его полями.',
|
||||
};
|
||||
|
||||
const userTimingDraft = {
|
||||
title: 'W3C User Timing Level 3, Working Draft от 13 сентября 2021 года',
|
||||
url: 'https://www.w3.org/TR/2021/WD-user-timing-3-20210913/',
|
||||
note: 'исторический W3C draft о mark и measure. Он позволяет говорить о названных точках и измерениях, но не задаёт наш допуск и не превращает ticks fixture в данные браузера.',
|
||||
};
|
||||
|
||||
const performanceTimelineDraft = {
|
||||
title: 'W3C Performance Timeline Level 2, Working Draft от 19 августа 2021 года',
|
||||
url: 'https://www.w3.org/TR/2021/WD-performance-timeline-2-20210819/',
|
||||
note: 'исторический W3C draft о хранении и получении performance entries. Он не определяет правило CI PASS, бюджет маршрута или интерпретацию учебных чисел.',
|
||||
};
|
||||
|
||||
const commonSources = [navigationTimingDraft, userTimingDraft, performanceTimelineDraft];
|
||||
|
||||
const practiceExample = [
|
||||
'import { runPerformanceBudgetFixture } from "./upgrade-2022-02.mjs";',
|
||||
'',
|
||||
'const fixture = runPerformanceBudgetFixture();',
|
||||
'const baseline = fixture.baselineComparison;',
|
||||
'const candidate = fixture.candidateComparison;',
|
||||
'',
|
||||
'console.log(baseline.overall.status);',
|
||||
'// pass',
|
||||
'console.log(candidate.aggregate.status);',
|
||||
'// pass — aggregate не заметил сдвиг одной части',
|
||||
'console.log(candidate.overall.status);',
|
||||
'// fail — named component budget имеет приоритет',
|
||||
'',
|
||||
'if (candidate.componentChecks.scriptTicks.status !== "fail") {',
|
||||
' throw new Error("fixture must expose the named regression");',
|
||||
'}',
|
||||
'',
|
||||
'// In-memory training model: no browser, CI, trace, network or Web Vitals.',
|
||||
].join('\n');
|
||||
|
||||
const mechanismExample = [
|
||||
'import { compareRouteWorkload, createTeachingBudget } from "./upgrade-2022-02.mjs";',
|
||||
'',
|
||||
'const budget = createTeachingBudget();',
|
||||
'const baseline = {',
|
||||
' route: "/training/checkout/review",',
|
||||
' scenario: "anonymous-cart-with-one-item",',
|
||||
' conditions: budget.conditions,',
|
||||
' timingFields: { documentTicks: 24, styleTicks: 15, scriptTicks: 26, renderTicks: 20 },',
|
||||
'};',
|
||||
'const candidate = {',
|
||||
' ...baseline,',
|
||||
' timingFields: { ...baseline.timingFields, scriptTicks: 34 },',
|
||||
'};',
|
||||
'',
|
||||
'const comparison = compareRouteWorkload(baseline, candidate, budget);',
|
||||
'console.log(comparison.componentChecks.scriptTicks.allowed);',
|
||||
'// 30: limit 28 plus explicit tolerance 2',
|
||||
'console.log(comparison.diagnosis.scope);',
|
||||
'// scriptTicks only',
|
||||
].join('\n');
|
||||
|
||||
const fieldExample = [
|
||||
'import { runPerformanceBudgetFixture } from "./upgrade-2022-02.mjs";',
|
||||
'',
|
||||
'const { candidateComparison } = runPerformanceBudgetFixture();',
|
||||
'const { diagnosis, measurementConditions } = candidateComparison;',
|
||||
'',
|
||||
'console.log(diagnosis.kind);',
|
||||
'// named-component-over-budget',
|
||||
'console.log(diagnosis.component);',
|
||||
'// scriptTicks',
|
||||
'console.log(diagnosis.nextAction);',
|
||||
'// inspect-script-input-and-one-route-boundary',
|
||||
'',
|
||||
'if (measurementConditions.browserObservation !== "not-collected") {',
|
||||
' throw new Error("fixture must not pretend to hold a browser observation");',
|
||||
'}',
|
||||
'',
|
||||
'// The result narrows a next check; it is not a global speed claim.',
|
||||
].join('\n');
|
||||
|
||||
const practiceGuardExample = [
|
||||
'const firstFailure = Object.values(candidate.componentChecks)',
|
||||
' .find((check) => check.status === "fail");',
|
||||
'',
|
||||
'if (candidate.aggregate.status === "pass" && firstFailure) {',
|
||||
' console.log(firstFailure.component);',
|
||||
' // scriptTicks: aggregate must not erase this result',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
const mechanismContractExample = [
|
||||
'const changedConditions = {',
|
||||
' ...candidate,',
|
||||
' conditions: { ...candidate.conditions, cache: "changed-training-cache-state" },',
|
||||
'};',
|
||||
'const unavailable = compareRouteWorkload(baseline, changedConditions, budget);',
|
||||
'',
|
||||
'console.log(unavailable.overall.reason);',
|
||||
'// measurement-contract-invalid',
|
||||
].join('\n');
|
||||
|
||||
const fieldRecheckExample = [
|
||||
'const next = runPerformanceBudgetFixture().candidateComparison;',
|
||||
'',
|
||||
'if (next.diagnosis.component !== "scriptTicks") {',
|
||||
' throw new Error("do not widen investigation without a new observation");',
|
||||
'}',
|
||||
'console.log(next.overall.failedComponents);',
|
||||
'// ["scriptTicks"]',
|
||||
].join('\n');
|
||||
|
||||
const requiredTimingFields = Object.freeze(['documentTicks', 'styleTicks', 'scriptTicks', 'renderTicks']);
|
||||
|
||||
function sumTimingFields(timingFields) {
|
||||
return requiredTimingFields.reduce((total, field) => total + timingFields[field], 0);
|
||||
}
|
||||
|
||||
function sameObject(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Учебный контракт измерения. Он намеренно не создаёт browser/CI workload и
|
||||
* не использует Performance API. Значения timingFields — условные ticks из
|
||||
* локального object, а не LCP, TTFB, trace, user metric или SLA.
|
||||
*/
|
||||
export function createTeachingBudget() {
|
||||
const conditions = Object.freeze({
|
||||
scenario: 'anonymous-cart-with-one-item',
|
||||
route: '/training/checkout/review',
|
||||
workloadVersion: 'training-navigation-workload-v1',
|
||||
timingUnit: 'teaching-ticks',
|
||||
browserObservation: 'not-collected',
|
||||
ciObservation: 'not-collected',
|
||||
network: 'not-modelled',
|
||||
cpu: 'not-modelled',
|
||||
cache: 'not-modelled',
|
||||
trace: 'not-collected',
|
||||
});
|
||||
|
||||
const components = Object.freeze({
|
||||
documentTicks: Object.freeze({ limit: 30, tolerance: 2, owner: 'document-input' }),
|
||||
styleTicks: Object.freeze({ limit: 18, tolerance: 2, owner: 'style-boundary' }),
|
||||
scriptTicks: Object.freeze({ limit: 28, tolerance: 2, owner: 'script-input' }),
|
||||
renderTicks: Object.freeze({ limit: 24, tolerance: 2, owner: 'render-boundary' }),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
kind: 'teaching-route-performance-budget-v1',
|
||||
conditions,
|
||||
components,
|
||||
aggregate: Object.freeze({ limit: 95, tolerance: 3, label: 'training-total-ticks' }),
|
||||
boundary: Object.freeze({
|
||||
fact: 'a comparison requires one route, one named scenario and matching declared conditions',
|
||||
notClaim: 'not-browser-trace-not-ci-result-not-real-user-metric-not-web-vitals-not-sla',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function validateRouteWorkload(workload, budget) {
|
||||
const missingFields = requiredTimingFields.filter((field) => !Number.isFinite(workload.timingFields?.[field]));
|
||||
const hasOnlyKnownFields = Object.keys(workload.timingFields || {}).every((field) => requiredTimingFields.includes(field));
|
||||
const routeMatches = workload.route === budget.conditions.route;
|
||||
const scenarioMatches = workload.scenario === budget.conditions.scenario;
|
||||
const conditionsMatch = sameObject(workload.conditions, budget.conditions);
|
||||
const nonNegative = requiredTimingFields.every((field) => workload.timingFields?.[field] >= 0);
|
||||
|
||||
return Object.freeze({
|
||||
ok: missingFields.length === 0 && hasOnlyKnownFields && routeMatches && scenarioMatches && conditionsMatch && nonNegative,
|
||||
missingFields,
|
||||
hasOnlyKnownFields,
|
||||
routeMatches,
|
||||
scenarioMatches,
|
||||
conditionsMatch,
|
||||
nonNegative,
|
||||
});
|
||||
}
|
||||
|
||||
function createComponentChecks(timingFields, budget) {
|
||||
return Object.freeze(Object.fromEntries(requiredTimingFields.map((field) => {
|
||||
const rule = budget.components[field];
|
||||
const actual = timingFields[field];
|
||||
const allowed = rule.limit + rule.tolerance;
|
||||
const status = actual <= allowed ? 'pass' : 'fail';
|
||||
return [field, Object.freeze({
|
||||
component: field,
|
||||
owner: rule.owner,
|
||||
actual,
|
||||
limit: rule.limit,
|
||||
tolerance: rule.tolerance,
|
||||
allowed,
|
||||
status,
|
||||
deltaFromLimit: actual - rule.limit,
|
||||
})];
|
||||
})));
|
||||
}
|
||||
|
||||
function createDiagnosis(componentChecks, validation) {
|
||||
if (!validation.ok) {
|
||||
return Object.freeze({
|
||||
kind: 'measurement-contract-invalid',
|
||||
component: null,
|
||||
scope: 'route-scenario-or-conditions-contract',
|
||||
nextAction: 'restore-one-named-route-scenario-and-controlled-conditions-before-comparing',
|
||||
notClaim: 'no-performance-conclusion-is-permitted',
|
||||
});
|
||||
}
|
||||
|
||||
const failed = Object.values(componentChecks).find((check) => check.status === 'fail');
|
||||
if (!failed) {
|
||||
return Object.freeze({
|
||||
kind: 'no-named-component-over-budget',
|
||||
component: null,
|
||||
scope: 'no-diagnosis-required-by-training-model',
|
||||
nextAction: 'keep-the-snapshot-and-repeat-only-under-the-same-declared-conditions',
|
||||
notClaim: 'not-proof-of-global-performance',
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
kind: 'named-component-over-budget',
|
||||
component: failed.component,
|
||||
scope: failed.component + ' only',
|
||||
observed: failed.actual,
|
||||
allowed: failed.allowed,
|
||||
nextAction: failed.component === 'scriptTicks'
|
||||
? 'inspect-script-input-and-one-route-boundary'
|
||||
: 'inspect-the-named-component-and-one-route-boundary',
|
||||
notClaim: 'not-a-global-speed-claim-not-a-browser-trace-not-a-real-user-result-not-an-sla-verdict',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Сравнивает два in-memory объекта. Первый задаёт snapshot, второй —
|
||||
* candidate. Функция не запускает browser, Lighthouse, CI, HTTP, таймер,
|
||||
* PerformanceObserver, файловую систему или сетевой workload.
|
||||
*/
|
||||
export function compareRouteWorkload(baseline, candidate, budget = createTeachingBudget()) {
|
||||
const baselineValidation = validateRouteWorkload(baseline, budget);
|
||||
const candidateValidation = validateRouteWorkload(candidate, budget);
|
||||
const comparable = baselineValidation.ok && candidateValidation.ok
|
||||
&& sameObject(baseline.conditions, candidate.conditions);
|
||||
const componentChecks = comparable
|
||||
? createComponentChecks(candidate.timingFields, budget)
|
||||
: Object.freeze({});
|
||||
const candidateTotal = comparable ? sumTimingFields(candidate.timingFields) : null;
|
||||
const baselineTotal = baselineValidation.ok ? sumTimingFields(baseline.timingFields) : null;
|
||||
const aggregateAllowed = budget.aggregate.limit + budget.aggregate.tolerance;
|
||||
const aggregate = Object.freeze({
|
||||
label: budget.aggregate.label,
|
||||
baselineTotal,
|
||||
candidateTotal,
|
||||
limit: budget.aggregate.limit,
|
||||
tolerance: budget.aggregate.tolerance,
|
||||
allowed: aggregateAllowed,
|
||||
status: comparable && candidateTotal <= aggregateAllowed ? 'pass' : 'not-comparable',
|
||||
interpretation: 'aggregate is a secondary guard and cannot turn a failed named component into pass',
|
||||
});
|
||||
const failedComponents = Object.values(componentChecks).filter((check) => check.status === 'fail');
|
||||
const overall = Object.freeze({
|
||||
status: comparable && failedComponents.length === 0 ? 'pass' : 'fail',
|
||||
reason: comparable
|
||||
? (failedComponents.length === 0 ? 'all-named-components-within-budget' : 'named-component-over-budget')
|
||||
: 'measurement-contract-invalid',
|
||||
failedComponents: Object.freeze(failedComponents.map((check) => check.component)),
|
||||
});
|
||||
const diagnosis = createDiagnosis(componentChecks, Object.freeze({ ok: comparable }));
|
||||
|
||||
return Object.freeze({
|
||||
kind: 'teaching-route-budget-comparison',
|
||||
measurementConditions: budget.conditions,
|
||||
validation: Object.freeze({ baseline: baselineValidation, candidate: candidateValidation, comparable }),
|
||||
baseline: Object.freeze({ timingFields: baseline.timingFields, total: baselineTotal }),
|
||||
candidate: Object.freeze({ timingFields: candidate.timingFields, total: candidateTotal }),
|
||||
componentChecks,
|
||||
aggregate,
|
||||
overall,
|
||||
diagnosis,
|
||||
boundary: budget.boundary,
|
||||
});
|
||||
}
|
||||
|
||||
export function runPerformanceBudgetFixture() {
|
||||
const budget = createTeachingBudget();
|
||||
const baseline = Object.freeze({
|
||||
route: budget.conditions.route,
|
||||
scenario: budget.conditions.scenario,
|
||||
conditions: budget.conditions,
|
||||
timingFields: Object.freeze({
|
||||
documentTicks: 24,
|
||||
styleTicks: 15,
|
||||
scriptTicks: 26,
|
||||
renderTicks: 20,
|
||||
}),
|
||||
});
|
||||
const candidate = Object.freeze({
|
||||
route: budget.conditions.route,
|
||||
scenario: budget.conditions.scenario,
|
||||
conditions: budget.conditions,
|
||||
timingFields: Object.freeze({
|
||||
documentTicks: 24,
|
||||
styleTicks: 15,
|
||||
scriptTicks: 34,
|
||||
renderTicks: 20,
|
||||
}),
|
||||
});
|
||||
const mismatchedConditionsCandidate = Object.freeze({
|
||||
...candidate,
|
||||
conditions: Object.freeze({ ...budget.conditions, cache: 'changed-training-cache-state' }),
|
||||
});
|
||||
const baselineComparison = compareRouteWorkload(baseline, baseline, budget);
|
||||
const candidateComparison = compareRouteWorkload(baseline, candidate, budget);
|
||||
const mismatchedConditionsComparison = compareRouteWorkload(baseline, mismatchedConditionsCandidate, budget);
|
||||
const repeatedCandidateComparison = compareRouteWorkload(baseline, candidate, budget);
|
||||
const externalBoundary = Object.freeze({
|
||||
browser: 'not-used',
|
||||
ci: 'not-used',
|
||||
trace: 'not-used',
|
||||
network: 'not-used',
|
||||
filesystem: 'not-used',
|
||||
clock: 'not-used',
|
||||
metricClass: 'training-ticks-not-web-vitals',
|
||||
});
|
||||
const assertions = Object.freeze({
|
||||
baselineIsWithinEveryNamedBudget: baselineComparison.overall.status === 'pass'
|
||||
&& Object.values(baselineComparison.componentChecks).every((check) => check.status === 'pass'),
|
||||
candidateFailsNamedScriptBudget: candidateComparison.componentChecks.scriptTicks.status === 'fail'
|
||||
&& candidateComparison.componentChecks.scriptTicks.component === 'scriptTicks',
|
||||
candidateOverallFailsWhenOneNamedComponentFails: candidateComparison.overall.status === 'fail'
|
||||
&& candidateComparison.overall.failedComponents.length === 1
|
||||
&& candidateComparison.overall.failedComponents[0] === 'scriptTicks',
|
||||
aggregatePassCannotHideNamedFailure: candidateComparison.aggregate.status === 'pass'
|
||||
&& candidateComparison.overall.status === 'fail'
|
||||
&& candidateComparison.aggregate.interpretation.includes('cannot turn a failed named component into pass'),
|
||||
componentToleranceIsExplicit: candidateComparison.componentChecks.scriptTicks.limit === 28
|
||||
&& candidateComparison.componentChecks.scriptTicks.tolerance === 2
|
||||
&& candidateComparison.componentChecks.scriptTicks.allowed === 30,
|
||||
aggregateToleranceIsExplicit: candidateComparison.aggregate.limit === 95
|
||||
&& candidateComparison.aggregate.tolerance === 3
|
||||
&& candidateComparison.aggregate.allowed === 98,
|
||||
baselineAndCandidateShareDeclaredConditions: sameObject(baseline.conditions, candidate.conditions)
|
||||
&& candidateComparison.validation.comparable === true
|
||||
&& candidateComparison.measurementConditions.browserObservation === 'not-collected',
|
||||
conditionMismatchBlocksComparison: mismatchedConditionsComparison.validation.comparable === false
|
||||
&& mismatchedConditionsComparison.overall.reason === 'measurement-contract-invalid'
|
||||
&& mismatchedConditionsComparison.aggregate.status === 'not-comparable',
|
||||
allComponentsRemainIndividuallyVisible: Object.keys(candidateComparison.componentChecks).length === requiredTimingFields.length
|
||||
&& requiredTimingFields.every((field) => candidateComparison.componentChecks[field].component === field),
|
||||
diagnosisIsBoundedToNamedComponent: candidateComparison.diagnosis.kind === 'named-component-over-budget'
|
||||
&& candidateComparison.diagnosis.component === 'scriptTicks'
|
||||
&& candidateComparison.diagnosis.scope === 'scriptTicks only',
|
||||
diagnosisLeadsToFocusedAction: candidateComparison.diagnosis.nextAction === 'inspect-script-input-and-one-route-boundary'
|
||||
&& candidateComparison.diagnosis.notClaim.includes('not-a-global-speed-claim'),
|
||||
arithmeticReconciles: baselineComparison.baseline.total === 85
|
||||
&& candidateComparison.candidate.total === 93
|
||||
&& candidateComparison.candidate.total - candidateComparison.baseline.total === 8,
|
||||
invalidOrUnknownMeasurementIsNotSmuggledThrough: candidateComparison.validation.candidate.hasOnlyKnownFields === true
|
||||
&& candidateComparison.validation.candidate.nonNegative === true
|
||||
&& mismatchedConditionsComparison.validation.candidate.conditionsMatch === false,
|
||||
fixtureHasNoExternalObservation: externalBoundary.browser === 'not-used'
|
||||
&& externalBoundary.ci === 'not-used'
|
||||
&& externalBoundary.trace === 'not-used'
|
||||
&& externalBoundary.network === 'not-used'
|
||||
&& externalBoundary.metricClass === 'training-ticks-not-web-vitals',
|
||||
repeatedComparisonIsDeterministic: sameObject(candidateComparison, repeatedCandidateComparison),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
kind: 'deterministic-performance-budget-training-fixture',
|
||||
budget,
|
||||
baseline,
|
||||
candidate,
|
||||
baselineComparison,
|
||||
candidateComparison,
|
||||
mismatchedConditionsComparison,
|
||||
externalBoundary,
|
||||
assertions,
|
||||
});
|
||||
}
|
||||
|
||||
const practiceArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2022-02-practice-performance-budget',
|
||||
title: 'Бюджет пользовательского пути: общий PASS больше не скрывает регрессию',
|
||||
categories: ['Производительность', 'Frontend'],
|
||||
cover: '/assets/editorial/2022/performance-budget-allocation-2022.svg',
|
||||
excerpt: 'Практический маршрут: описать один пользовательский путь, зафиксировать условия и baseline, сравнить candidate по именованным частям и получить действие вместо общего PASS.',
|
||||
readingMinutes: 11,
|
||||
},
|
||||
[
|
||||
paragraph('Проблема начинается на странице оформления: пользователь ждёт, пока станет доступен следующий шаг. Изменения приходят маленькими: новый код, стиль, виджет, правило рендера. Каждое по отдельности проходит общий check. Через несколько релизов путь стал тяжелее, но CI всё ещё отдаёт зелёный PASS. Цена такого сигнала проста: он говорит, что не пересечён порог, но не говорит, какая часть пути забрала запас и где искать изменение.'),
|
||||
paragraph('Не надо отвечать на это обещанием «ускорить сайт». Сначала нужен один воспроизводимый контракт для конкретного пути: route, сценарий, условия наблюдения, snapshot и именные части бюджета. Тогда проверка отвечает на скромный вопрос: candidate остаётся внутри заранее названного допуска или нет. Она не выдаёт учебный расчёт за браузерный trace, реальную пользовательскую метрику, SLA или результат существующего CI.'),
|
||||
heading('Начинаем с пути, а не с общей оценки'),
|
||||
paragraph('Бюджет полезен только у входа, который можно назвать. В fixture это <code>/training/checkout/review</code> и сценарий <code>anonymous-cart-with-one-item</code>. Название намеренно длиннее слова «главная»: при другом товаре, авторизации, locale или feature flag это уже может быть другой вход. Если один job смешивает такие варианты, изменение в одном из них способно спрятаться за средним числом другого.'),
|
||||
paragraph('Дальше фиксируются условия. В учебной модели это version workload, единица <code>teaching-ticks</code>, а browser observation, CI observation, сеть, CPU, cache и trace имеют значение <code>not-collected</code> или <code>not-modelled</code>. Эти поля не делают проверку слабой. Они запрещают дорисовать доказательство задним числом: пока нет trace, нельзя из суммы ticks сделать вывод о браузере; пока нет CI run, нельзя назвать fixture результатом pipeline.'),
|
||||
dataTable(
|
||||
'Контракт учебного бюджета одного пользовательского пути',
|
||||
['Элемент', 'Что хранит fixture', 'Для чего нужен', 'Чего не доказывает'],
|
||||
[
|
||||
['Route и scenario', '<code>/training/checkout/review</code>, одна корзина', 'не смешать разные входы', 'что путь уже прошёл в production'],
|
||||
['Условия', 'version workload, ticks, <code>not-collected</code>', 'остановить сравнение при другой среде', 'свойства браузера или CI runner'],
|
||||
['Компоненты', '<code>documentTicks</code>, <code>styleTicks</code>, <code>scriptTicks</code>, <code>renderTicks</code>', 'увидеть вклад по частям', 'стандартные Web Vitals'],
|
||||
['Допуск', 'limit плюс tolerance у каждого имени', 'сделать правило явным', 'универсальную норму для любого продукта'],
|
||||
['Aggregate', 'вторичный total guard', 'увидеть суммарный запас', 'право скрыть failed component'],
|
||||
],
|
||||
),
|
||||
heading('Разделите запас до того, как появится candidate'),
|
||||
paragraph('Слово «бюджет» часто превращают в одну сумму. Для пользовательского пути это недостаточно. Один общий предел замечает только итог, а не перенос затрат между частями. В fixture у <code>scriptTicks</code> limit равен <code>28</code> и tolerance равен <code>2</code>; значит допустимо до <code>30</code>. У total limit <code>95</code> и tolerance <code>3</code>. Эти числа условны. Их смысл не в удачном размере, а в том, что limit и допуск существуют отдельно и печатаются в результате проверки.'),
|
||||
paragraph('Почему допуск не стоит прятать в комментарии? Потому что без него нельзя понять, какой сдвиг команда заранее согласилась игнорировать и в каких условиях. Если tolerance нужен из-за нестабильности настоящего замера, эта нестабильность должна быть описана в measurement contract. В fixture она не измеряется: поэтому допуск — проектное правило учебного объекта, не статистическая модель и не утверждение о дисперсии браузера.'),
|
||||
figure(
|
||||
'/assets/editorial/2022/performance-budget-allocation-2022.svg',
|
||||
'Вертикальная схема учебного бюджета пути checkout review: четыре именованные части document, style, script и render имеют свои limit и tolerance. Baseline укладывается во все части, candidate превышает допустимый scriptTicks, хотя общий total остаётся в пределах вторичного порога.',
|
||||
'Распределение показывает порядок проверки: сначала сравниваются именованные части с явным допуском, затем читается общий total. Зелёная сумма не отменяет красный component check.',
|
||||
),
|
||||
heading('Snapshot и candidate должны быть сопоставимы'),
|
||||
paragraph('Snapshot — не «последний хороший отчёт». Это сохранённый объект с route, scenario, conditions и четырьмя учебными полями. Baseline в fixture содержит <code>24, 15, 26, 20</code>; candidate меняет только <code>scriptTicks</code> на <code>34</code>. Сумма candidate равна <code>93</code>, поэтому aggregate остаётся PASS до allowed <code>98</code>. Но component rule разрешает максимум <code>30</code>, и общий результат обязан быть FAIL. Именно это защищает от красивой, но бесполезной зелёной суммы.'),
|
||||
codeBlock(practiceExample),
|
||||
paragraph('Код не вызывает Lighthouse, Chrome, PerformanceObserver или сетевой запрос. Он не получает timestamps. Его роль — зафиксировать invariant модели: named component сильнее aggregate. В рабочем проекте аналогичный object можно заполнить данными выбранного теста, но только после того, как команда назвала источник, версию инструмента, контролируемые условия и смысл каждого поля. Подмена этих границ «примерным CI» создаёт ту же ложную уверенность, от которой пытаемся уйти.'),
|
||||
codeBlock(practiceGuardExample),
|
||||
heading('Маршрут: симптом → причина → проверка → действие'),
|
||||
orderedList([
|
||||
'<strong>Симптом.</strong> Общая проверка стабильно зелёная, но конкретный пользовательский путь постепенно меняется и становится труднее объяснить.',
|
||||
'<strong>Причина.</strong> Aggregate объединяет части пути и не показывает, какая из них съела запас; baseline и candidate могут быть собраны при разных условиях.',
|
||||
'<strong>Проверка контракта.</strong> Назовите route, сценарий, version workload, единицу, наличие или отсутствие browser/CI observation. Если conditions отличаются, не сравнивайте числа.',
|
||||
'<strong>Проверка snapshot.</strong> Сохраните baseline с именованными полями. У каждого поля должны быть limit, tolerance и owner, а не только одна сумма в документации.',
|
||||
'<strong>Проверка candidate.</strong> Сначала прочитайте <code>componentChecks</code>, затем aggregate. В fixture <code>scriptTicks</code> = 34 больше allowed 30, даже когда total 93 меньше aggregate allowed 98.',
|
||||
'<strong>Действие.</strong> Откройте один input и одну границу, принадлежащую failed component. Для <code>scriptTicks</code> fixture возвращает <code>inspect-script-input-and-one-route-boundary</code>; не начинайте глобальную оптимизацию без дополнительного факта.',
|
||||
]),
|
||||
heading('Как превратить модель в проверку без ложного PASS'),
|
||||
paragraph('В проекте порядок важнее выбора файла. Сначала profile route в контролируемых условиях: один вариант сценария, одна версия приложения, явно записанная конфигурация измерения. Затем сохранить snapshot вместе с conditions. Только после этого compare candidate. И уже после compare назначить focused action. Если начать с порога в CI, получится gate без смысла: он может зелёнеть, потому что route заменили, cache стал другим или в результаты попала другая часть опыта.'),
|
||||
heading('Что происходит при несовпадении условий'),
|
||||
paragraph('Fixture создаёт также candidate с изменённым условием cache. У него не появляется новый PASS или FAIL по компонентам: comparison останавливается как <code>measurement-contract-invalid</code>. Это сознательное поведение. Сравнение разных условий не становится корректным от того, что оба объекта содержат числа. Сначала восстановите один scenario и controlled conditions, затем снова снимите candidate. Так в отчёте остаётся отдельная категория «данных для вывода нет», а не случайный технический verdict.'),
|
||||
paragraph('Эта ветка особенно полезна для CI. У pipeline может быть хороший сигнал о том, что script завершился успешно, но этот факт не тождественен сопоставимому performance observation. Не нужно обесценивать CI: ему следует отдать узкое правило, которое он действительно проверяет. Например, сравнение serialized snapshot с candidate по одному маршруту. Но browser trace, real-user data и service-level решение остаются другими артефактами и требуют собственных условий.'),
|
||||
heading('Факт платформы и учебная модель'),
|
||||
paragraph('Исторический W3C Navigation Timing Level 2 от 17 января 2022 года описывает <code>PerformanceNavigationTiming</code> и timing information navigation. User Timing Level 3 от сентября 2021 года описывает именованные marks и measures. Performance Timeline Level 2 от августа 2021 года описывает хранение и получение performance entries. Это официальные смыслы платформенных интерфейсов, а не источник наших <code>documentTicks</code> и <code>scriptTicks</code>.'),
|
||||
paragraph('Модель пакета намеренно уже: она учит не считать скорость, а правильно оформлять сравнение. Ее output можно читать как «в этих условных входах named rule нарушен». Из него нельзя получить LCP, TTFB, браузерный waterfall, real-user result, количество пользователей или SLA. Для фактической интеграции сначала сопоставьте реальные поля с официальной документацией используемого инструмента и добавьте отдельный тест на mapping.'),
|
||||
heading('Критерий готовности и ограничение'),
|
||||
paragraph('Работа закончена не при первом зелёном total. Готовность для одного budget change выглядит так: route и scenario названы; conditions сохранены; baseline и candidate имеют один schema; каждый component имеет limit/tolerance; failed component даёт bounded diagnosis; следующий шаг ограничен одной границей. Затем выбранное изменение проверяется повторно в тех же условиях. Если conditions уже другие, это новая карточка сравнения, а не повод переписать baseline.'),
|
||||
paragraph('Fixture проверяет пятнадцать собственных assertions, включая baseline PASS, named failure candidate, явные tolerances, невозможность aggregate скрыть component, остановку при condition mismatch и отсутствие внешнего наблюдения. Это регрессия контракта текста и кода. Она не заменяет профиль настоящего route, потому что настоящий профиль остаётся отдельной инженерной работой с отдельной средой и доказательством.'),
|
||||
heading('Историческая граница февраля 2022'),
|
||||
paragraph('Статья ограничена документами, опубликованными не позднее января 2022 года. W3C sources ниже — рабочие drafts, а не современная рекомендация и не набор новых показателей. Здесь нет поздних метрик взаимодействия, нет текущих советов Lighthouse и нет заявлений об измеренном продукте. Это сохраняет честную дистанцию между исторической рамкой, официальным API и нашей учебной схемой бюджета.'),
|
||||
],
|
||||
commonSources,
|
||||
);
|
||||
|
||||
const mechanismArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2022-02-mechanism-performance-budget',
|
||||
title: 'Почему общий PASS не равен бюджету пути: модель сравнения и допуск',
|
||||
categories: ['Производительность', 'Frontend'],
|
||||
cover: '/assets/editorial/2022/performance-budget-trend-2022.svg',
|
||||
excerpt: 'Причинная модель бюджета пути: от controlled conditions и snapshot к component checks, aggregate и ограниченной диагностике без подмены учебного расчёта browser trace.',
|
||||
readingMinutes: 12,
|
||||
},
|
||||
[
|
||||
paragraph('Проблема появляется, когда общий PASS считают доказательством неизменности пути. Путь может получить лишнюю работу в одной части и сохранить приемлемую общую сумму. Цена — неверный выбор действия: команда смотрит на score или total, не видит сдвиг в компоненте и начинает менять всё подряд. Через несколько таких проверок бюджет превращается в табличку, которая ничего не запрещает.'),
|
||||
paragraph('Причина не в том, что aggregate бесполезен. Он отвечает на другой вопрос: не вышла ли общая учебная сумма за свой предел. Бюджет пути отвечает сначала на вопрос о каждой именованной части и только потом о сумме. Чтобы ответ был воспроизводимым, рядом нужны controlled conditions, baseline snapshot, candidate и явный tolerance. Ни одно из этих слов не означает реальный browser observation: fixture строит только локальные objects с условными ticks.'),
|
||||
heading('Четыре слоя одной проверки'),
|
||||
paragraph('Первый слой — measurement contract. Он связывает route, user scenario, version workload и условия. Второй — snapshot baseline, который описывает известный вход именно в этом contract. Третий — component checks: каждый field сравнивается со своим limit плюс tolerance. Четвёртый — decision. Overall FAIL появляется, если хотя бы один named component не проходит; aggregate остаётся вторичным guard. Это простое правило убирает самую частую лазейку: «total зелёный, значит можно не смотреть красную строку». '),
|
||||
paragraph('В fixture четыре поля называются <code>documentTicks</code>, <code>styleTicks</code>, <code>scriptTicks</code> и <code>renderTicks</code>. Они не названы <code>LCP</code>, <code>TTFB</code> или browser event, потому что не получены из браузера. Это project vocabulary учебной модели. Если настоящий проект берёт browser field, его официальное значение, supported browser и сбор нужно описать отдельно. Нельзя взять знакомое имя метрики и приклеить его к произвольному object только ради убедительного отчёта.'),
|
||||
dataTable(
|
||||
'Слои budget comparison и границы вывода',
|
||||
['Слой', 'Вход', 'Выход', 'Правило', 'Запрещённый вывод'],
|
||||
[
|
||||
['Contract', 'route, scenario, declared conditions', 'comparable / invalid', 'любое несовпадение останавливает compare', '«числа сами по себе сопоставимы»'],
|
||||
['Snapshot', 'baseline timingFields', 'точка отсчёта', 'хранит тот же schema', '«последний отчёт всегда baseline»'],
|
||||
['Component', 'candidate field и limit+tolerance', 'pass/fail по имени', 'каждый field виден отдельно', '«общая сумма лечит частный fail»'],
|
||||
['Aggregate', 'сумма fields и total rule', 'вторичный pass/fail', 'читается после component', '«PASS total доказывает путь»'],
|
||||
['Diagnosis', 'failed named component', 'one bounded next action', 'не расширяет scope без факта', '«найдена глобальная причина скорости»'],
|
||||
],
|
||||
),
|
||||
heading('Допуск — часть контракта, а не скрытая скидка'),
|
||||
paragraph('Если limit равен 28, а tolerance равен 2, то allowed равен 30. Такой расчёт должен быть виден в output, иначе reviewer не может отличить осознанный допуск от опечатки или смены правила. В fixture candidate имеет <code>scriptTicks: 34</code>. Он превышает allowed на четыре tick. Отдельное поле <code>deltaFromLimit</code> равно шести, потому что описывает расстояние до базового limit, а не до порога с допуском. Оба числа нужны, если заранее объяснить их смысл.'),
|
||||
paragraph('Допуск нельзя переводить в процент без контекста. У одной части пути абсолютное изменение может быть осмысленным, у другой — нет. Настоящий способ выбрать tolerance зависит от источника наблюдения, повторов, среды и стоимости ложного FAIL. Учебная fixture не знает ни одной из этих вещей. Поэтому она не предлагает «правильные 2 ticks», а требует явного поля. Реальная команда добавляет обоснование рядом со своим mapping и не переносит учебные значения в конфигурацию молча.'),
|
||||
heading('Почему линия aggregate может оставаться зелёной'),
|
||||
paragraph('Baseline fixture суммируется до 85 ticks. Candidate суммируется до 93. Aggregate allowed равен 98, поэтому aggregate status — PASS. Однако рост находится в <code>scriptTicks</code>: baseline 26, candidate 34, allowed 30. Эта комбинация сделана специально. Если result позволил бы overall PASS, потому что 93 меньше 98, budget не защищал бы контракт пользователя: он прятал бы изменение в самой части, для которой владелец и limit были заведены.'),
|
||||
figure(
|
||||
'/assets/editorial/2022/performance-budget-trend-2022.svg',
|
||||
'Сравнительная диаграмма baseline и candidate учебного маршрута: total растёт с 85 до 93 и остаётся ниже aggregate allowed 98, но scriptTicks растёт с 26 до 34 и превышает allowed 30. Итоговый verdict показан как fail по named component, а не как глобальная оценка скорости.',
|
||||
'Линия aggregate нужна для контекста, а не для отмены component failure. На схеме значения подписаны как teaching ticks и отделены от браузерных метрик.',
|
||||
),
|
||||
heading('Механизм сравнения в одном объекте'),
|
||||
codeBlock(mechanismExample),
|
||||
paragraph('Функция <code>compareRouteWorkload</code> сначала вызывает validation для baseline и candidate. Она проверяет, что все четыре поля существуют, лишних нет, значения неотрицательные, route и scenario совпадают с contract, а conditions равны. Только после этого она строит component checks. Если conditions отличаются, component map пуст, aggregate становится <code>not-comparable</code>, а overall получает <code>measurement-contract-invalid</code>. Это предотвращает тихое смешение «до» и «после». '),
|
||||
codeBlock(mechanismContractExample),
|
||||
paragraph('Этот fragment не пытается «нормализовать» изменённый cache. Он оставляет compare недоступным, пока не восстановлен один declared contract. Так разница условий не превращается в число, которому дают performance-смысл.'),
|
||||
heading('Сначала observation, потом архитектурный вывод'),
|
||||
paragraph('M5-подход начинается с пользовательского сценария и заканчивается небольшим изменением, а не с тезиса «пакет тяжёлый». Candidate failure говорит только: в declared teaching model field <code>scriptTicks</code> больше allowed. Из этого нельзя вывести, что виновата конкретная библиотека, загрузчик, server response или пользовательское устройство. Чтобы назвать причину, нужно собрать следующий наблюдаемый артефакт на одной границе: dependency list, bundle diff, route trace или профиль — в зависимости от того, что реально доступно.'),
|
||||
heading('Маршрут: симптом → причина → проверка → действие'),
|
||||
orderedList([
|
||||
'<strong>Симптом.</strong> В отчёте есть общий PASS, но изменения одной части пути продолжают накапливаться без понятного владельца.',
|
||||
'<strong>Причина.</strong> Aggregate использовали как final verdict, хотя он не хранит распределение и может пройти при failed component.',
|
||||
'<strong>Проверка contract.</strong> Сверьте route, scenario и все declared conditions baseline/candidate. Изменился хотя бы один обязательный элемент — верните <code>not-comparable</code>.',
|
||||
'<strong>Проверка allocation.</strong> Для каждого component покажите actual, limit, tolerance, allowed и owner. Отдельно посчитайте total, но не смешивайте его с verdict component.',
|
||||
'<strong>Проверка decision.</strong> Overall PASS допустим только когда все names PASS. Overall FAIL обязан перечислить failed component, даже если aggregate остаётся зелёным.',
|
||||
'<strong>Действие.</strong> Возьмите первую failed name и сузьте следующую проверку до одного input и одной route boundary. Зафиксируйте, какой дополнительный факт нужен до архитектурного изменения.',
|
||||
]),
|
||||
heading('Откуда взять настоящие поля и где не подменять их'),
|
||||
paragraph('Navigation Timing Level 2 уже к январю 2022 года описывал navigation timing information и <code>PerformanceNavigationTiming</code>. User Timing Level 3 описывал named marks/measures и их metadata. Performance Timeline Level 2 описывал получение entries. Эти документы полезны, когда проект выбирает platform source. Они не предлагают готовый budget для checkout, не говорят, что total должен быть 95, и не определяют связь наших четырех ticks с полями API.'),
|
||||
paragraph('Поэтому корректная интеграция начинается с mapping-table. В ней для каждого production field указывают источник, version, значение, условия доступности, преобразование и owner. Затем отдельно документируют runner и repeatability. Пока этого нет, честнее оставить language учебным: <code>teaching-ticks</code>, <code>not-collected</code>, <code>not-modelled</code>. Это не уклонение от работы; это защита от визуально похожих, но невалидных цифр.'),
|
||||
heading('Граница CI и browser observation'),
|
||||
paragraph('CI может исполнить compare и упасть по возвратному коду. Но факт исполнения job не создаёт browser observation. Browser observation может быть полезным входом в budget, если его получали по определённому протоколу и сохранили вместе с conditions. У fixture оба значения прямо равны <code>not-collected</code>. Так output не позволяет сказать «CI замерил маршрут» или «Chrome подтвердил результат». '),
|
||||
heading('Критерий корректной эволюции бюджета'),
|
||||
paragraph('Budget change корректен, если меняется одна известная вещь: новый route scenario, новый component, limit/tolerance или способ получения поля. В каждом случае обновляется contract и baseline, а причина изменения остаётся рядом. Неправильно просто повысить общий total, когда <code>scriptTicks</code> красный: это снимает симптом, но не говорит, согласен ли владелец пути на более дорогую часть. Иногда повышение оправдано, но оно должно быть отдельным решением со стоимостью и ограничением.'),
|
||||
paragraph('В fixture есть пятнадцать assertions: baseline проходит каждый named budget; candidate падает по <code>scriptTicks</code>; aggregate PASS не скрывает failure; tolerances явны; conditions несовместимы блокируют compare; diagnosis ограничена одним component; никаких browser, CI, trace или network observations не выполняется. Это техническая проверка нашей модели. Она не измеряет реальную страницу и не должна использоваться как её заменитель.'),
|
||||
heading('Историческая граница февраля 2022'),
|
||||
paragraph('Все ссылки ниже существовали до февраля 2022 года: Navigation Timing draft от 17 января 2022 года, User Timing draft от сентября 2021-го и Performance Timeline draft от августа 2021-го. Они процитированы как historical sources и сами обозначены W3C как working drafts. В статье нет поздних interaction metrics и нет современных рекомендаций, появившихся после этой рамки. Технический вывод относится только к структуре учебного comparison.'),
|
||||
],
|
||||
commonSources,
|
||||
);
|
||||
|
||||
const fieldArticle = createRevision(
|
||||
{
|
||||
slug: 'editorial-2022-02-field-performance-budget',
|
||||
title: 'Красный component при зелёном total: как сузить диагностику бюджета пути',
|
||||
categories: ['Производительность', 'Frontend'],
|
||||
cover: '/assets/editorial/2022/performance-budget-diagnosis-2022.svg',
|
||||
excerpt: 'Полевой маршрут для случая, когда aggregate остаётся PASS, а named component превышает допуск: проверить contract, сохранить факт и выбрать одно ограниченное действие без глобального performance verdict.',
|
||||
readingMinutes: 11,
|
||||
},
|
||||
[
|
||||
paragraph('На пользовательском пути есть неприятный случай: общий total остаётся зелёным, но одна именованная часть пересекает свой допуск. Его легко списать как «небольшое колебание», особенно если pipeline не упал. Цена такого решения — диагностический долг. Следующее изменение добавит ещё немного работы, а у команды останется только общий график и спор о том, когда именно путь начал меняться.'),
|
||||
paragraph('Нужно не искать виноватого по одной строке, а правильно ограничить вывод. В учебной fixture candidate имеет total 93 при aggregate allowed 98, но <code>scriptTicks</code> равен 34 при allowed 30. Этот факт достаточен, чтобы заблокировать contract test, и недостаточен, чтобы объявить глобальную деградацию, проблему браузера, user metric или SLA. Хороший диагностический маршрут оставляет оба предложения рядом.'),
|
||||
heading('Сначала отделите три разных состояния'),
|
||||
paragraph('Первое состояние — comparable PASS: baseline и candidate имеют одинаковый contract, и все component checks проходят. Второе — comparable FAIL: contract тот же, но хотя бы один named component больше allowed. Третье — not comparable: scenario или conditions изменились, поэтому budget verdict не строится. Самая опасная ошибка — превратить третье состояние во второе. Тогда случайная смена cache, route data или config выглядит как performance regression, хотя данных для такого вывода нет.'),
|
||||
paragraph('Fixture показывает все три ветки. Baseline сравнивается сам с собой и проходит. Candidate с теми же conditions падает по <code>scriptTicks</code>. Отдельный candidate меняет только <code>cache</code> с <code>not-modelled</code> на <code>changed-training-cache-state</code>; его comparison получает <code>measurement-contract-invalid</code>. В этой ветке нет component failure, потому что программа отказалась подменять различие условий числовым verdict.'),
|
||||
dataTable(
|
||||
'Три диагностических состояния budget comparison',
|
||||
['Состояние', 'Что известно', 'Что не известно', 'Следующий шаг'],
|
||||
[
|
||||
['Comparable PASS', 'все names внутри allowed при одном contract', 'что путь быстрый для всех пользователей', 'сохранить snapshot и повторить только при том же contract'],
|
||||
['Comparable FAIL', 'один named component превышает limit+tolerance', 'глобальная причина или browser effect', 'собрать один факт на границе failed component'],
|
||||
['Not comparable', 'contract baseline/candidate различается', 'есть ли регрессия', 'восстановить scenario и conditions до нового compare'],
|
||||
['Aggregate PASS + component FAIL', 'total в пределах, name не в пределах', 'что component можно игнорировать', 'считать overall FAIL и сузить действие'],
|
||||
],
|
||||
),
|
||||
heading('Прочитайте output в правильном порядке'),
|
||||
paragraph('Первой строкой читается validation: comparable ли объекты. Второй — список <code>failedComponents</code>. Третьей — diagnosis, где field и scope явно ограничены. Aggregate читается последним: он объясняет общий запас, но не принимает решение. Такой порядок помогает reviewer не споткнуться о знакомое зелёное слово PASS. В fixture aggregate находится в <code>candidateComparison.aggregate</code>, а final decision — в <code>candidateComparison.overall</code>.'),
|
||||
codeBlock(fieldExample),
|
||||
paragraph('Поле <code>diagnosis.nextAction</code> сознательно скупо: <code>inspect-script-input-and-one-route-boundary</code>. Оно не говорит «удалить библиотеку», «переписать приложение» или «ускорить сервер». По одному учебному числу нельзя выбрать такую архитектуру. Следующее действие должно породить проверяемый факт: например, список route inputs, diff зависимостей или отдельный профиль, если он действительно доступен и собран по понятному протоколу.'),
|
||||
codeBlock(fieldRecheckExample),
|
||||
heading('Диаграмма: зелёный total не отменяет красную ветку'),
|
||||
figure(
|
||||
'/assets/editorial/2022/performance-budget-diagnosis-2022.svg',
|
||||
'Диагностическая схема для budget comparison: сначала проверяются route, scenario и declared conditions; при несовпадении сравнение останавливается. При совпадении component checks идут раньше aggregate. Ветка scriptTicks 34 при allowed 30 ведёт к focused action, хотя total 93 при allowed 98 остаётся зелёным.',
|
||||
'Схема показывает границы вывода: red component создаёт контрактный fail и ограниченную проверку, но не доказывает глобальную скорость, trace браузера или пользовательский результат.',
|
||||
),
|
||||
heading('Маршрут: симптом → причина → проверка → действие'),
|
||||
orderedList([
|
||||
'<strong>Симптом.</strong> Total budget зелёный, но на маршруте появляется новый красный component или его запас постепенно сокращается.',
|
||||
'<strong>Причина.</strong> Итоговая сумма скрывает распределение. Либо baseline/candidate стали несопоставимыми, и цифры вообще нельзя читать вместе.',
|
||||
'<strong>Проверка contract.</strong> Сверьте route, scenario, workloadVersion, unit и every declared condition. При любом различии получите <code>measurement-contract-invalid</code>, а не performance verdict.',
|
||||
'<strong>Проверка component.</strong> Запишите actual, limit, tolerance и allowed. В этом case <code>scriptTicks</code> 34 против allowed 30; named fail остаётся виден независимо от total.',
|
||||
'<strong>Проверка aggregate.</strong> Используйте 93 против allowed 98 только для контекста: total не вышел за вторичный guard, но он не может перевести overall в PASS.',
|
||||
'<strong>Действие.</strong> Сохраните failed name и возьмите один input плюс одну boundary для следующего наблюдения. После изменения повторите тот же compare; если contract изменился, оформите новый snapshot.',
|
||||
]),
|
||||
heading('Как не сделать из диагностики глобальный performance verdict'),
|
||||
paragraph('У diagnosis есть поле <code>notClaim</code>. Оно явно запрещает интерпретировать результат как global speed, browser trace, real-user result или SLA verdict. Такое ограничение кажется избыточным, пока в отчёте не появляется знакомое число. Именно тогда человек склонен достроить историю: «34 значит страница медленнее». Нет. В fixture 34 — значение одного условного property при локальном object comparison. Оно говорит ровно то, что проверено: rule именованной части не соблюдён.'),
|
||||
paragraph('Нужен и обратный запрет. Aggregate PASS не даёт сказать «проблемы нет». Он лишь говорит, что учебный total не пересёк свой aggregate allowed. В реальном проекте этот total может быть полезен как guard против общего роста, но не как единственный gate. Если граница пользователя критична, ей нужен собственный named contract и owner. Если она не критична, тогда лучше не притворяться, что общий score защищает её.'),
|
||||
heading('Выберите границу следующего наблюдения'),
|
||||
paragraph('Для <code>scriptTicks</code> не стоит сразу открывать весь application. Начните с route boundary: entry, bundle boundary, dynamic input или другой реальный владелец, который выбран в mapping. Одного артефакта достаточно, чтобы сузить или отвергнуть гипотезу. Если видно, что changed input не относится к route, вернитесь к contract. Если input относится к route, тогда можно подготовить небольшое обратимое изменение и повторить compare. Оба пути лучше, чем широкая «оптимизация производительности». '),
|
||||
paragraph('Подход сохраняет развитие автора 2022 года: сначала пользовательский опыт и наблюдаемый контракт, затем система границ, затем решение с ценой. Он не делает вид, что автор владеет production telemetry или знает реальную причину без trace. Любой более сильный вывод требует нового доказательства. Это не осторожность ради тона — это способ не закрепить неверную архитектуру после одной зелёной или красной строки.'),
|
||||
heading('Источники помогают не перепутать поля'),
|
||||
paragraph('W3C Navigation Timing Level 2 в историческом snapshot описывает platform interface для navigation data. User Timing Level 3 описывает marks/measures и metadata. Performance Timeline Level 2 описывает retrieval performance entries и observer mechanics. В каждом случае источник помогает понять, что значит фактическое поле платформы. Но он не говорит, что <code>scriptTicks</code> fixture равно одному из этих полей или что эти поля уже собраны в этом проекте.'),
|
||||
paragraph('Перед переносом модели на настоящую страницу полезна маленькая таблица mapping: field учебного контракта, concrete source, data availability, transform, owner и known limitation. Если для одного поля mapping отсутствует, не подставляйте похожий термин. Либо оставьте field учебным, либо сначала соберите новый evidence. Такая дисциплина защищает не только от анахронизма, но и от обычной ошибки: неправильная метрика даёт очень точное число и очень плохое решение.'),
|
||||
heading('Сигнал для CI и отдельный сигнал для исследования'),
|
||||
paragraph('CI gate может работать как контрактный предохранитель: сравнить known snapshot и candidate, вывести failed components и завершиться с ошибкой. Но не стоит называть этот outcome «измерением браузерной производительности», если job не выполнял такое наблюдение. В fixture CI observation честно указано как <code>not-collected</code>. Так автоматизация остаётся полезной, но не получает чужой смысл.'),
|
||||
paragraph('Исследование начинается после fail, а не до него. Для него нужны реальные артефакты, которые отсутствуют в fixture: выбранный browser profile, trace, resource list, application markers или другой источник. Их нельзя выводить из teaching ticks. Напротив, contract budget делает исследование дешевле: он уже назвал route, scenario, failed component, owner boundary и запретил смешать условия. Значит следующий сбор материала может быть меньше и точнее.'),
|
||||
heading('Что проверить после focused action'),
|
||||
paragraph('После небольшого изменения не повышайте limit первым движением. Сначала повторите same contract compare. Если <code>scriptTicks</code> вернулся в allowed, зафиксируйте, какой input изменился и что всё ещё не известно. Если не вернулся, следующий шаг должен быть ещё одним проверяемым узким экспериментом, а не каскадом правок. Если route or scenario изменились, не сравнивайте с исходным baseline: оформите новую версию workload и причину пересмотра.'),
|
||||
heading('Историческая граница февраля 2022'),
|
||||
paragraph('Материал не использует будущие показатели взаимодействия и не ссылается на актуальные страницы, которые могли поменять советы. Ссылки ниже ведут на dated W3C drafts: январь 2022 для Navigation Timing и 2021 для User Timing и Performance Timeline. Все три документа различают официальные platform interfaces и этап working draft. Учебная диагностика поверх них остаётся собственной моделью пакета.'),
|
||||
],
|
||||
commonSources,
|
||||
);
|
||||
|
||||
export const revisions = [practiceArticle, mechanismArticle, fieldArticle].map(({ proseLength, ...revision }) => revision);
|
||||
export const february2022Revisions = revisions;
|
||||
|
||||
function verifyFixture() {
|
||||
const fixture = runPerformanceBudgetFixture();
|
||||
const failed = Object.entries(fixture.assertions)
|
||||
.filter(([, passed]) => passed !== true)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.error('FAIL fixture: ' + failed.join(', '));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PASS fixture: ' + Object.keys(fixture.assertions).length + '/' + Object.keys(fixture.assertions).length + ' assertions');
|
||||
}
|
||||
|
||||
if (process.argv.includes('--verify-fixture')) {
|
||||
verifyFixture();
|
||||
}
|
||||
|
||||
if (process.argv.includes('--print-revisions')) {
|
||||
console.log(JSON.stringify(revisions));
|
||||
}
|
||||
Reference in New Issue
Block a user