fix(metadata): dropdowns for enum fields; manual Tested Pages insert works #9
No files matched your search
@@ -4,5 +4,9 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- Use dropdowns for tier, scope, source, strategy, metric, comparator and severity, preserving existing stored values.
|
||||
- Allow manual Tested Pages inserts with a URL. Validate absolute HTTPS URLs and fill in the site, path and Manual source automatically; validate URL edits too.
|
||||
|
||||
### Added
|
||||
- First implementation; see README.md for the tables, permissions and configuration.
|
||||
@@ -44,11 +44,17 @@ The host owns the database. Include the QBit's changelog from the host's master
|
||||
- include: { file: db/liquibase/qbit-web-performance/web_performance.changelog.sql }
|
||||
```
|
||||
|
||||
## Tested pages and dropdowns
|
||||
|
||||
Add a manual tested page with an absolute HTTPS URL, an optional label, a tier and an enabled flag. The QBit fills in its site key, path and Manual source. URLs cannot contain credentials or fragments; URL edits use the same validation and update the path. Sitemap sync remains available for automatic discovery.
|
||||
|
||||
Tier, Scope, Source, Strategy, Metric, Comparator and Severity use dropdowns with plain labels and the existing stored values. Each table uses its own applicable choices. Existing seeded budgets need no migration.
|
||||
|
||||
## Tables
|
||||
|
||||
| Table | Written by | Notes |
|
||||
|---|---|---|
|
||||
| `web_performance_target` (Tested Pages) | sitemap sync, people | `label`, `tier` and `enabled` are editable; field-audited |
|
||||
| `web_performance_target` (Tested Pages) | sitemap sync, people | `url`, `label`, `tier` and `enabled` are editable; field-audited |
|
||||
| `web_performance_lab_run` (Lab Runs) | the QBit | read-only; one Lighthouse run |
|
||||
| `web_performance_lab_finding` (Findings) | the QBit | read-only; keyed by `(lighthouse_version, audit_id)` |
|
||||
| `web_performance_lab_report` (Lighthouse Reports) | the QBit | read-only; the gzipped PSI response, kept forever by default |
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.webperf.metadata;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.webperf.WebPerfRuntime;
|
||||
import com.kingsrook.qbits.webperf.WebPerfSchema;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreInsertCustomizer;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate manually entered URLs and derive the fields used by sync and tests.
|
||||
*******************************************************************************/
|
||||
public class TargetCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Derive site identity before the unique site/URL check.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AbstractPreInsertCustomizer.WhenToRun whenToRunPreInsert(InsertInput input, boolean isPreview)
|
||||
{
|
||||
return (AbstractPreInsertCustomizer.WhenToRun.BEFORE_ALL_VALIDATIONS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Manual pages need only a URL, label, tier and enabled flag from the user.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput input, List<QRecord> records, boolean isPreview)
|
||||
{
|
||||
validate(input.getTableName(), records, true);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep URL edits subject to the same validation and refresh their path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecords)
|
||||
{
|
||||
validate(input.getTableName(), records, false);
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Accept absolute HTTPS URLs without embedded credentials or fragments.
|
||||
*******************************************************************************/
|
||||
private void validate(String tableName, List<QRecord> records, boolean inserting)
|
||||
{
|
||||
String prefix = tableName.substring(0, tableName.length() - WebPerfSchema.TARGET.length());
|
||||
WebPerfRuntime runtime = WebPerfRuntime.of(QContext.getQInstance(), prefix);
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!inserting && !record.getValues().containsKey("url"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String url = record.getValueString("url");
|
||||
try
|
||||
{
|
||||
URI uri = URI.create(url == null ? "" : url);
|
||||
if(!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Invalid page URL"));
|
||||
}
|
||||
record.setValue("path", uri.getPath().isEmpty() ? "/" : uri.getPath());
|
||||
if(inserting)
|
||||
{
|
||||
record.setValue("siteKey", runtime.service().siteKey());
|
||||
record.setValue("source", "MANUAL");
|
||||
record.setValue("inSitemap", false);
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("URL must be an absolute HTTPS URL without credentials or a fragment."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import com.kingsrook.qbits.webperf.WebPerfRuntime;
|
||||
import com.kingsrook.qbits.webperf.WebPerfSchema;
|
||||
import com.kingsrook.qbits.webperf.processes.WebPerfProcesses;
|
||||
import com.kingsrook.qbits.webperf.widgets.WebPerfWidgets;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
@@ -47,7 +48,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
@@ -65,7 +68,7 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDe
|
||||
** comparisons, change events) are written only by the QBit's processes:
|
||||
** read-only in the UI and not audited.
|
||||
** - Tested pages and budgets are edited by people (enable a page, change a
|
||||
** budget): only those fields are editable, and both are field-audited.
|
||||
** budget): URLs are validated on insert/edit, and both are field-audited.
|
||||
** Regressions change status only through record actions (dismiss,
|
||||
** resolve), so they are read-only in the UI but field-audited.
|
||||
**
|
||||
@@ -106,6 +109,7 @@ public final class WebPerfMetaData
|
||||
{
|
||||
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(config.applyPrefix(base)));
|
||||
}
|
||||
addEnumSources(qInstance);
|
||||
for(QProcessMetaData process : WebPerfProcesses.define(config, qInstance))
|
||||
{
|
||||
qInstance.addProcess(process);
|
||||
@@ -136,6 +140,88 @@ public final class WebPerfMetaData
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enumerated values retain the stored IDs used by runners and seeded budgets.
|
||||
*******************************************************************************/
|
||||
private void addEnumSources(QInstance qInstance)
|
||||
{
|
||||
addEnumSource(qInstance, "tier",
|
||||
new QPossibleValue<>("KEY", "Key page"),
|
||||
new QPossibleValue<>("STANDARD", "Standard page"));
|
||||
addEnumSource(qInstance, "target_source",
|
||||
new QPossibleValue<>("SITEMAP", "Sitemap"),
|
||||
new QPossibleValue<>("MANUAL", "Manual"),
|
||||
new QPossibleValue<>("CONTENT", "Content"));
|
||||
addEnumSource(qInstance, "strategy",
|
||||
new QPossibleValue<>("MOBILE", "Mobile"),
|
||||
new QPossibleValue<>("DESKTOP", "Desktop"));
|
||||
addEnumSource(qInstance, "strategy_or_form_factor",
|
||||
new QPossibleValue<>("MOBILE", "Mobile"),
|
||||
new QPossibleValue<>("DESKTOP", "Desktop"),
|
||||
new QPossibleValue<>("PHONE", "Phone"),
|
||||
new QPossibleValue<>("TABLET", "Tablet"),
|
||||
new QPossibleValue<>("ALL", "All devices"));
|
||||
addEnumSource(qInstance, "field_source",
|
||||
new QPossibleValue<>("CRUX_API", "CrUX API"),
|
||||
new QPossibleValue<>("CRUX_HISTORY", "CrUX history"),
|
||||
new QPossibleValue<>("PSI", "PageSpeed Insights"));
|
||||
addEnumSource(qInstance, "field_scope",
|
||||
new QPossibleValue<>("URL", "URL"),
|
||||
new QPossibleValue<>("ORIGIN", "Origin"));
|
||||
addEnumSource(qInstance, "budget_scope",
|
||||
new QPossibleValue<>("SITE", "Site"),
|
||||
new QPossibleValue<>("TIER", "Tier"),
|
||||
new QPossibleValue<>("TARGET", "Page"),
|
||||
new QPossibleValue<>("CONTENT_TABLE", "Content table"),
|
||||
new QPossibleValue<>("PATH_PREFIX", "Path prefix"));
|
||||
addEnumSource(qInstance, "measurement_source",
|
||||
new QPossibleValue<>("LAB", "Lab"),
|
||||
new QPossibleValue<>("RUM", "Real users"),
|
||||
new QPossibleValue<>("CRUX", "Google field data"));
|
||||
addEnumSource(qInstance, "comparison_source",
|
||||
new QPossibleValue<>("LAB", "Lab"),
|
||||
new QPossibleValue<>("RUM", "Real users"));
|
||||
addEnumSource(qInstance, "comparison_scope",
|
||||
new QPossibleValue<>("KEY", "Key pages"),
|
||||
new QPossibleValue<>("TARGET", "Page"),
|
||||
new QPossibleValue<>("SITE", "Site"));
|
||||
addEnumSource(qInstance, "change_source",
|
||||
new QPossibleValue<>("PROBE", "Probe"),
|
||||
new QPossibleValue<>("WEBHOOK", "Webhook"),
|
||||
new QPossibleValue<>("QBIT_CDN", "CDN"),
|
||||
new QPossibleValue<>("USER", "User"));
|
||||
addEnumSource(qInstance, "metric",
|
||||
new QPossibleValue<>("LCP", "LCP"),
|
||||
new QPossibleValue<>("INP", "INP"),
|
||||
new QPossibleValue<>("CLS", "CLS"),
|
||||
new QPossibleValue<>("FCP", "FCP"),
|
||||
new QPossibleValue<>("TTFB", "TTFB"),
|
||||
new QPossibleValue<>("TBT", "TBT"),
|
||||
new QPossibleValue<>("PERFORMANCE_SCORE", "Performance score"),
|
||||
new QPossibleValue<>("ACCESSIBILITY_SCORE", "Accessibility score"),
|
||||
new QPossibleValue<>("BEST_PRACTICES_SCORE", "Best practices score"),
|
||||
new QPossibleValue<>("SEO_SCORE", "SEO score"));
|
||||
addEnumSource(qInstance, "comparator",
|
||||
new QPossibleValue<>("<=", "At most"),
|
||||
new QPossibleValue<>(">=", "At least"));
|
||||
addEnumSource(qInstance, "severity",
|
||||
new QPossibleValue<>("WARNING", "Warning"),
|
||||
new QPossibleValue<>("CRITICAL", "Critical"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register an install-scoped dropdown with plain labels.
|
||||
*******************************************************************************/
|
||||
private void addEnumSource(QInstance qInstance, String name, QPossibleValue<?>... values)
|
||||
{
|
||||
qInstance.addPossibleValueSource(new QPossibleValueSource().withName(config.applyPrefix(name))
|
||||
.withType(QPossibleValueSourceType.ENUM).withEnumValues(List.of(values)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A card widget, full width, carrying the install's prefix so its
|
||||
** renderer finds the runtime.
|
||||
@@ -211,11 +297,11 @@ public final class WebPerfMetaData
|
||||
f("siteKey", QFieldType.STRING, "Site").withMaxLength(100),
|
||||
f("url", QFieldType.STRING, "URL").withMaxLength(1000).withIsRequired(true),
|
||||
f("path", QFieldType.STRING, "Path").withMaxLength(500),
|
||||
f("source", QFieldType.STRING, "Source").withMaxLength(20),
|
||||
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("target_source")).withMaxLength(20),
|
||||
f("contentTable", QFieldType.STRING, "Content Table").withMaxLength(100),
|
||||
f("contentId", QFieldType.LONG, "Content Id"),
|
||||
f("label", QFieldType.STRING, "Label").withMaxLength(200),
|
||||
f("tier", QFieldType.STRING, "Tier").withMaxLength(20).withDefaultValue("STANDARD"),
|
||||
f("tier", QFieldType.STRING, "Tier").withPossibleValueSourceName(config.applyPrefix("tier")).withMaxLength(20).withDefaultValue("STANDARD"),
|
||||
f("enabled", QFieldType.BOOLEAN, "Enabled").withDefaultValue(true),
|
||||
f("inSitemap", QFieldType.BOOLEAN, "In Sitemap"),
|
||||
f("lastSeenInSitemapAt", QFieldType.DATE_TIME, "Last Seen in Sitemap"),
|
||||
@@ -226,7 +312,9 @@ public final class WebPerfMetaData
|
||||
f("openRegressionCount", QFieldType.INTEGER, "Open Regressions")));
|
||||
|
||||
QTableMetaData table = table(WebPerfSchema.TARGET, "Tested Pages", "travel_explore", fields, true,
|
||||
Set.of("label", "tier", "enabled"))
|
||||
Set.of("url", "label", "tier", "enabled"))
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(TargetCustomizer.class))
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(TargetCustomizer.class))
|
||||
.withRecordLabelFormatAndFields("%s", "url")
|
||||
.withUniqueKey(new UniqueKey("siteKey", "url"))
|
||||
.withSection(section("page", "Page", "travel_explore", Tier.T1, "id", "url", "label", "tier", "enabled"))
|
||||
@@ -249,7 +337,7 @@ public final class WebPerfMetaData
|
||||
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
|
||||
f("url", QFieldType.STRING, "URL").withMaxLength(1000),
|
||||
f("finalUrl", QFieldType.STRING, "Final URL").withMaxLength(1000),
|
||||
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
|
||||
f("runner", QFieldType.STRING, "Runner").withMaxLength(20),
|
||||
f("triggerType", QFieldType.STRING, "Trigger").withMaxLength(20),
|
||||
f("triggerRef", QFieldType.STRING, "Trigger Reference").withMaxLength(200),
|
||||
@@ -300,7 +388,7 @@ public final class WebPerfMetaData
|
||||
List<QFieldMetaData> fields = withCommon(List.of(
|
||||
f("runId", QFieldType.LONG, "Run").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.LAB_RUN)),
|
||||
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
|
||||
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
|
||||
f("category", QFieldType.STRING, "Category").withMaxLength(20),
|
||||
f("auditId", QFieldType.STRING, "Audit").withMaxLength(100),
|
||||
f("lighthouseVersion", QFieldType.STRING, "Lighthouse Version").withMaxLength(20),
|
||||
@@ -350,8 +438,8 @@ public final class WebPerfMetaData
|
||||
QTableMetaData fieldSnapshot()
|
||||
{
|
||||
List<QFieldMetaData> fields = withCommon(List.of(
|
||||
f("source", QFieldType.STRING, "Source").withMaxLength(20),
|
||||
f("scope", QFieldType.STRING, "Scope").withMaxLength(10),
|
||||
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("field_source")).withMaxLength(20),
|
||||
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("field_scope")).withMaxLength(10),
|
||||
f("scopeKey", QFieldType.STRING, "URL or Origin").withMaxLength(1000),
|
||||
f("formFactor", QFieldType.STRING, "Form Factor").withMaxLength(10),
|
||||
f("status", QFieldType.STRING, "Status").withMaxLength(10),
|
||||
@@ -382,14 +470,14 @@ public final class WebPerfMetaData
|
||||
{
|
||||
List<QFieldMetaData> fields = withCommon(List.of(
|
||||
f("name", QFieldType.STRING, "Name").withMaxLength(200).withIsRequired(true),
|
||||
f("scope", QFieldType.STRING, "Scope").withMaxLength(20),
|
||||
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("budget_scope")).withMaxLength(20),
|
||||
f("scopeValue", QFieldType.STRING, "Scope Value").withMaxLength(500),
|
||||
f("source", QFieldType.STRING, "Source").withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy").withMaxLength(10),
|
||||
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
|
||||
f("comparator", QFieldType.STRING, "Comparator").withMaxLength(5),
|
||||
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("measurement_source")).withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy").withPossibleValueSourceName(config.applyPrefix("strategy")).withMaxLength(10),
|
||||
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
|
||||
f("comparator", QFieldType.STRING, "Comparator").withPossibleValueSourceName(config.applyPrefix("comparator")).withMaxLength(5),
|
||||
f("threshold", QFieldType.DECIMAL, "Threshold"),
|
||||
f("severity", QFieldType.STRING, "Severity").withMaxLength(10),
|
||||
f("severity", QFieldType.STRING, "Severity").withPossibleValueSourceName(config.applyPrefix("severity")).withMaxLength(10),
|
||||
f("enabled", QFieldType.BOOLEAN, "Enabled"),
|
||||
f("seeded", QFieldType.BOOLEAN, "Seeded")));
|
||||
|
||||
@@ -412,16 +500,16 @@ public final class WebPerfMetaData
|
||||
f("targetId", QFieldType.LONG, "Page").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.TARGET)),
|
||||
f("contentTable", QFieldType.STRING, "Content Table").withMaxLength(100),
|
||||
f("contentId", QFieldType.LONG, "Content Id"),
|
||||
f("source", QFieldType.STRING, "Source").withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withMaxLength(10),
|
||||
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
|
||||
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("measurement_source")).withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withPossibleValueSourceName(config.applyPrefix("strategy_or_form_factor")).withMaxLength(10),
|
||||
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
|
||||
f("baselineValue", QFieldType.DECIMAL, "Baseline"),
|
||||
f("baselineRef", QFieldType.STRING, "Baseline From").withMaxLength(500),
|
||||
f("currentValue", QFieldType.DECIMAL, "Current"),
|
||||
f("currentRef", QFieldType.STRING, "Current From").withMaxLength(500),
|
||||
f("delta", QFieldType.DECIMAL, "Change"),
|
||||
f("budgetId", QFieldType.LONG, "Budget").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.BUDGET)),
|
||||
f("severity", QFieldType.STRING, "Severity").withMaxLength(10),
|
||||
f("severity", QFieldType.STRING, "Severity").withPossibleValueSourceName(config.applyPrefix("severity")).withMaxLength(10),
|
||||
f("status", QFieldType.STRING, "Status").withMaxLength(20),
|
||||
f("causeChangeEventId", QFieldType.LONG, "Likely Cause").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.CHANGE_EVENT)),
|
||||
f("detectedAt", QFieldType.DATE_TIME, "Detected"),
|
||||
@@ -451,7 +539,7 @@ public final class WebPerfMetaData
|
||||
f("fromValue", QFieldType.STRING, "From").withMaxLength(200),
|
||||
f("toValue", QFieldType.STRING, "To").withMaxLength(200),
|
||||
f("observedAt", QFieldType.DATE_TIME, "Observed"),
|
||||
f("source", QFieldType.STRING, "Source").withMaxLength(20),
|
||||
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("change_source")).withMaxLength(20),
|
||||
f("note", QFieldType.STRING, "Note").withMaxLength(2000)));
|
||||
|
||||
return (table(WebPerfSchema.CHANGE_EVENT, "Site Changes", "change_circle", fields, false, Set.of())
|
||||
@@ -470,10 +558,10 @@ public final class WebPerfMetaData
|
||||
{
|
||||
List<QFieldMetaData> fields = withCommon(List.of(
|
||||
f("changeEventId", QFieldType.LONG, "Change").withPossibleValueSourceName(config.applyPrefix(WebPerfSchema.CHANGE_EVENT)),
|
||||
f("scope", QFieldType.STRING, "Scope").withMaxLength(20),
|
||||
f("source", QFieldType.STRING, "Source").withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withMaxLength(10),
|
||||
f("metric", QFieldType.STRING, "Metric").withMaxLength(30),
|
||||
f("scope", QFieldType.STRING, "Scope").withPossibleValueSourceName(config.applyPrefix("comparison_scope")).withMaxLength(20),
|
||||
f("source", QFieldType.STRING, "Source").withPossibleValueSourceName(config.applyPrefix("comparison_source")).withMaxLength(10),
|
||||
f("strategy", QFieldType.STRING, "Strategy or Form Factor").withPossibleValueSourceName(config.applyPrefix("strategy_or_form_factor")).withMaxLength(10),
|
||||
f("metric", QFieldType.STRING, "Metric").withPossibleValueSourceName(config.applyPrefix("metric")).withMaxLength(30),
|
||||
f("segment", QFieldType.STRING, "Segment").withMaxLength(100),
|
||||
f("beforeValue", QFieldType.DECIMAL, "Before"),
|
||||
f("beforeN", QFieldType.INTEGER, "Before n"),
|
||||
|
||||
@@ -23,12 +23,16 @@ package com.kingsrook.qbits.webperf;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.webperf.sources.HttpBuildIdProbe;
|
||||
import com.kingsrook.qbits.webperf.sources.SitemapUrlSource;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
@@ -110,6 +114,53 @@ class WebPerfQBitProducerTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every enum field resolves to an install-scoped ENUM with stable stored IDs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testEnumFields() throws Exception
|
||||
{
|
||||
QInstance instance = WebPerfTestSupport.newInstance();
|
||||
new WebPerfQBitProducer().withQBitConfig(WebPerfTestSupport.config().withTableNamePrefix("perf_")).produce(instance);
|
||||
Map<String, List<String>> expected = Map.ofEntries(
|
||||
Map.entry("tier", List.of("KEY", "STANDARD")),
|
||||
Map.entry("target_source", List.of("SITEMAP", "MANUAL", "CONTENT")),
|
||||
Map.entry("strategy", List.of("MOBILE", "DESKTOP")),
|
||||
Map.entry("strategy_or_form_factor", List.of("MOBILE", "DESKTOP", "PHONE", "TABLET", "ALL")),
|
||||
Map.entry("field_source", List.of("CRUX_API", "CRUX_HISTORY", "PSI")),
|
||||
Map.entry("field_scope", List.of("URL", "ORIGIN")),
|
||||
Map.entry("budget_scope", List.of("SITE", "TIER", "TARGET", "CONTENT_TABLE", "PATH_PREFIX")),
|
||||
Map.entry("measurement_source", List.of("LAB", "RUM", "CRUX")),
|
||||
Map.entry("comparison_source", List.of("LAB", "RUM")),
|
||||
Map.entry("comparison_scope", List.of("KEY", "TARGET", "SITE")),
|
||||
Map.entry("change_source", List.of("PROBE", "WEBHOOK", "QBIT_CDN", "USER")),
|
||||
Map.entry("metric", List.of("LCP", "INP", "CLS", "FCP", "TTFB", "TBT", "PERFORMANCE_SCORE", "ACCESSIBILITY_SCORE", "BEST_PRACTICES_SCORE", "SEO_SCORE")),
|
||||
Map.entry("comparator", List.of("<=", ">=")),
|
||||
Map.entry("severity", List.of("WARNING", "CRITICAL")));
|
||||
int count = 0;
|
||||
for(QTableMetaData table : instance.getTables().values())
|
||||
{
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(List.of("tier", "scope", "source", "strategy", "metric", "comparator", "severity").contains(field.getName()))
|
||||
{
|
||||
count++;
|
||||
assertThat(field.getPossibleValueSourceName()).as(table.getName() + "." + field.getName()).startsWith("perf_");
|
||||
QPossibleValueSource source = instance.getPossibleValueSource(field.getPossibleValueSourceName());
|
||||
assertEquals(QPossibleValueSourceType.ENUM, source.getType());
|
||||
assertThat(source.getEnumValues()).extracting(v -> (String) v.getId()).containsExactlyElementsOf(expected.get(source.getName().substring(5)));
|
||||
assertThat(source.getEnumValues()).allMatch(v -> v.getLabel() != null && !v.getLabel().isBlank());
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(21, count);
|
||||
assertEquals("Key page", instance.getPossibleValueSource("perf_tier").getEnumValues().get(0).getLabel());
|
||||
assertTrue(instance.getTable("perf_target").getField("url").getIsEditable());
|
||||
assertTrue(instance.getTable("perf_target").isCapabilityEnabled(instance.getBackend(WebPerfTestSupport.BACKEND), Capability.TABLE_INSERT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The app nests under a host parent app (kof22: CMS, D5).
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -31,6 +31,9 @@ import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
@@ -41,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -130,6 +134,18 @@ class WebPerfSchemaTest
|
||||
assertEquals(13, budgets.size());
|
||||
assertThat(budgets).allMatch(r -> Boolean.TRUE.equals(r.getValueBoolean("seeded")) && Boolean.TRUE.equals(r.getValueBoolean("enabled")));
|
||||
|
||||
for(QRecord budget : budgets)
|
||||
{
|
||||
for(String field : List.of("scope", "source", "strategy", "metric", "comparator", "severity"))
|
||||
{
|
||||
if(budget.getValue(field) != null)
|
||||
{
|
||||
QPossibleValueSource source = qInstance.getPossibleValueSource(qInstance.getTable("web_performance_budget").getField(field).getPossibleValueSourceName());
|
||||
assertThat(source.getEnumValues()).extracting(v -> (String) v.getId()).contains(budget.getValueString(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QRecord inp = budgets.stream().filter(r -> "INP good (real users, p75)".equals(r.getValueString("name"))).findFirst().orElseThrow();
|
||||
assertEquals("RUM", inp.getValueString("source"));
|
||||
assertEquals(0, new BigDecimal("200").compareTo(inp.getValueBigDecimal("threshold")));
|
||||
@@ -151,16 +167,68 @@ class WebPerfSchemaTest
|
||||
{
|
||||
installed("perf_");
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
|
||||
new QRecord().withValue("siteKey", "site:1").withValue("url", "https://www.example.com/about").withValue("path", "/about")
|
||||
.withValue("source", "MANUAL").withValue("tier", "KEY").withValue("enabled", true))));
|
||||
new QRecord().withValue("url", "https://www.example.com/about").withValue("label", "About")
|
||||
.withValue("tier", "KEY").withValue("enabled", true))));
|
||||
assertThat(output.getRecords().get(0).getErrors()).isEmpty();
|
||||
|
||||
QRecord stored = new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords().get(0);
|
||||
assertEquals("https://www.example.com/about", stored.getValueString("url"));
|
||||
assertEquals("KEY", stored.getValueString("tier"));
|
||||
assertEquals("host:www.example.com", stored.getValueString("siteKey"));
|
||||
assertEquals("/about", stored.getValueString("path"));
|
||||
assertEquals("MANUAL", stored.getValueString("source"));
|
||||
assertEquals(false, stored.getValueBoolean("inSitemap"));
|
||||
|
||||
InsertOutput duplicate = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
|
||||
new QRecord().withValue("siteKey", "site:1").withValue("url", "https://www.example.com/about"))));
|
||||
assertThat(duplicate.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid manual URLs return a clear error and never become tested pages.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testManualPageUrlValidation() throws Exception
|
||||
{
|
||||
installed("perf_");
|
||||
for(String url : List.of("", "/about", "http://www.example.com/about", "https:///about", "https://user:pass@example.com/", "https://www.example.com/#section", "not a URL"))
|
||||
{
|
||||
InsertOutput output = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(new QRecord().withValue("url", url))));
|
||||
assertThat(output.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
assertThat(output.getRecords().get(0).getErrors().toString()).contains("absolute HTTPS URL");
|
||||
}
|
||||
assertThat(new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Editing a URL refreshes its path; invalid edits leave the stored URL intact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testManualPageUrlUpdates() throws Exception
|
||||
{
|
||||
installed("perf_");
|
||||
QRecord inserted = new InsertAction().execute(new InsertInput("perf_target").withRecords(List.of(
|
||||
new QRecord().withValue("url", "https://www.example.com")))).getRecords().get(0);
|
||||
assertThat(inserted.getErrors()).isEmpty();
|
||||
assertEquals("/", inserted.getValueString("path"));
|
||||
UpdateOutput valid = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
|
||||
new QRecord().withValue("id", inserted.getValue("id")).withValue("url", "https://www.example.com/new"))));
|
||||
assertThat(valid.getRecords().get(0).getErrors()).isEmpty();
|
||||
UpdateOutput invalid = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
|
||||
new QRecord().withValue("id", inserted.getValue("id")).withValue("url", "/bad"))));
|
||||
assertThat(invalid.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
UpdateOutput labelOnly = new UpdateAction().execute(new UpdateInput("perf_target").withRecords(List.of(
|
||||
new QRecord().withValue("id", inserted.getValue("id")).withValue("label", "New page"))));
|
||||
assertThat(labelOnly.getRecords().get(0).getErrors()).isEmpty();
|
||||
QRecord stored = new QueryAction().execute(new QueryInput("perf_target").withFilter(new QQueryFilter())).getRecords().get(0);
|
||||
assertEquals("https://www.example.com/new", stored.getValueString("url"));
|
||||
assertEquals("/new", stored.getValueString("path"));
|
||||
assertEquals("New page", stored.getValueString("label"));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user