mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
283 lines
20 KiB
JavaScript
283 lines
20 KiB
JavaScript
// #600, серия 4: «Устройство на плане» по референсу docs/design/600-settings-dialogs/
|
||
// (§7 SPEC.md). Фокусный свидетель формы: состав карточек и отсутствие старой
|
||
// разметки (AC1), оболочка и полосы экранов (AC2), привязка сегментом и кнопкой
|
||
// выбора с «Show entities» внутри панели (Q8), «Ask for confirmation» только у
|
||
// действий (Q8), сегменты роли/свечения/стороны бейджа и плашки цвета (Q5),
|
||
// блок свечения отключается целиком с объяснением, Display preview целиком в
|
||
// блоке на тинте (Q3), размеры с числом, чипы источников, Save только при
|
||
// изменениях и вопрос при закрытии (AC6/К10), цели и семантика (AC9).
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
|
||
const { page, browser } = await launch({ width: 1280, height: 900 });
|
||
|
||
const out = await page.evaluate(async () => {
|
||
const o = {};
|
||
const c = window.__card;
|
||
const sr = () => c.shadowRoot || c.renderRoot;
|
||
const upd = async () => { c.requestUpdate(); await c.updateComplete; await new Promise((r) => setTimeout(r, 30)); };
|
||
const dlg = () => sr().querySelector('hp-dialog[data-kind="marker"]');
|
||
const q = (sel) => dlg()?.querySelector(sel);
|
||
const qa = (sel) => [...(dlg()?.querySelectorAll(sel) || [])];
|
||
const saveBtn = () => q('.dialog-action-commit [data-hp="dialog-confirm"]');
|
||
const statusText = () => q('.hpf-status')?.textContent.trim() || '';
|
||
const input = (el, value) => { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); };
|
||
const change = (el) => el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
const pickSeg = async (name, value) => {
|
||
const r = qa(`input[name="${name}"]`).find((x) => x.value === value);
|
||
r.checked = true; r.dispatchEvent(new Event('change', { bubbles: true })); await upd();
|
||
};
|
||
const select = async (el, value) => { el.value = value; el.dispatchEvent(new Event('change', { bubbles: true })); await upd(); };
|
||
|
||
c._setMode('devices'); await upd();
|
||
const lamp = c._devices.find((d) => d.id === 'd_light1') || c._devices.find((d) => d.bindingKind === 'device');
|
||
c._openMarkerDialog(lamp); await upd();
|
||
const d0 = c._markerDialog;
|
||
|
||
// --- AC1: пять карточек по §7.1, старой разметки нет --------------------
|
||
o.fiveCardsInOrder = JSON.stringify(qa('.hpf-card').map((card) => card.dataset.card))
|
||
=== JSON.stringify(['basics', 'tap', 'light', 'appearance', 'details']);
|
||
o.noLegacyMarkup = qa('.srcrow, .namein, .areasel, .dropbtn, .droppanel, .candlist, fieldset, .markerlightgroup, .markerradios, '
|
||
+ '.colorrow, .habindingbanner, .bindsel, .bindharow, .entcheck, .ctrlchips, .ctrllist, .ctrlopt, .opl, .opv, .descin, .tempin, '
|
||
+ '.markerhelpfield, .markerhelplabel, .helpfieldlabel, .rhint, .markerlightdisabled, .markerlightwarning').length === 0;
|
||
o.badgeShowsSpaceTitle = dlg().badge === c._spaceModelById(lamp.space)?.title && !!dlg().shadowRoot.querySelector('.badge');
|
||
o.shellIs560 = dlg().hasAttribute('form-shell') && Math.round(dlg().shadowRoot.querySelector('.surface').getBoundingClientRect().width) === 560;
|
||
o.bodyHasNoOwnScroller = getComputedStyle(q('.body')).overflowY === 'visible' && getComputedStyle(q('.body')).maxHeight === 'none';
|
||
o.footerGroups = !!q('.markeractions .btn.danger') && q('.markeractions').getBoundingClientRect().left < saveBtn().getBoundingClientRect().left;
|
||
|
||
// --- Basics: имя с подсказкой, сегмент привязки, кнопка выбора, «Show entities» в панели (Q8)
|
||
o.nameFieldWithoutRedundantHint = !!q('#marker-name.hpf-input')
|
||
&& !q('#marker-name')?.closest('.hpf-field')?.querySelector('.hpf-hint');
|
||
o.bindingIsASegment = qa('input[name="bmode"]').length === 2 && q('input[name="bmode"]:checked')?.value === 'ha'
|
||
&& !!q('input[name="bmode"]')?.closest('.hpf-seg[role="radiogroup"]');
|
||
o.bindingButtonShowsCurrent = !!q('#marker-binding.hpf-pick b') && q('#marker-binding').getAttribute('aria-expanded') === 'false'
|
||
&& !q('#marker-show-entities');
|
||
q('#marker-binding').click(); await upd();
|
||
o.panelHoldsSearchAndShowEntities = !!q('.hpf-card[data-card="basics"] .hpf-panel .hpf-toolbar input.hpf-input')
|
||
&& !!q('.hpf-card[data-card="basics"] .hpf-panel #marker-show-entities')
|
||
&& qa('.hpf-card[data-card="basics"] .hpf-cand[role="option"]').length > 0
|
||
&& getComputedStyle(q('.hpf-card[data-card="basics"] .hpf-panel')).position !== 'absolute';
|
||
q('#marker-show-entities').click(); await upd();
|
||
o.showEntitiesWritesOwnKey = c._markerDialog.showEntities === !d0.showEntities && c._markerDialog.binding === d0.binding;
|
||
q('#marker-binding').click(); await upd();
|
||
o.buttonClosesPanel = !q('.hpf-card[data-card="basics"] .hpf-panel') && c._markerDialog.bindingOpen === false;
|
||
o.roomIsASelectWithHint = !!q('#marker-room.hpf-select') && !!q('#marker-room')?.closest('.hpf-field')?.querySelector('.hpf-hint');
|
||
|
||
// --- Tap action: подтверждение только у действий (Q8), чипы источников -----
|
||
o.tapActionCardHasNoLargeHeading = !q('.hpf-card[data-card="tap"] > .hpf-head');
|
||
await select(q('#marker-tap-action'), 'none');
|
||
o.noConfirmForDoNothing = !q('#marker-tap-confirm') && !q('#marker-toggle-hint');
|
||
await select(q('#marker-tap-action'), 'toggle');
|
||
o.confirmToggleRowForToggle = !!q('#marker-tap-confirm') && !!q('#marker-tap-confirm')?.closest('.hpf-toggle')
|
||
&& !!q('#marker-toggle-hint');
|
||
q('#marker-tap-confirm').click(); await upd();
|
||
o.confirmWritesOwnKey = c._markerDialog.tapConfirm === true && c._markerDialog.tapAction === 'toggle';
|
||
input(q('#marker-controls-filter'), 'l'); await upd();
|
||
const controlCands = qa('.hpf-card[data-card="tap"] .hpf-panel .hpf-cand');
|
||
o.controlsSearchOpensPanelInFlow = controlCands.length > 0 && !q('.ctrllist');
|
||
const controlsBefore = c._markerDialog.controls.length;
|
||
controlCands[0].click(); await upd();
|
||
o.controlPickAddsAChip = c._markerDialog.controls.length === controlsBefore + 1
|
||
&& qa('.hpf-card[data-card="tap"] .hpf-chips .hpf-chip').length === c._markerDialog.controls.length;
|
||
q('.hpf-card[data-card="tap"] .hpf-chip button').click(); await upd();
|
||
o.chipRemoveWritesControls = c._markerDialog.controls.length === controlsBefore;
|
||
|
||
// --- Light and glow: сегменты, блок свечения отключается целиком -----------
|
||
o.roleIsASegmentWithAutoHint = qa('input[name="marker-light-role"]').length === 3
|
||
&& !!q('input[name="marker-light-role"]')?.closest('.hpf-seg[role="radiogroup"]')
|
||
&& (c._markerDialog.lightRole !== 'auto' || !!q('input[name="marker-light-role"]')?.closest('.hpf-field')?.querySelector('.hpf-hint'));
|
||
await pickSeg('marker-light-role', 'never');
|
||
o.neverDisablesGlowBlock = c._markerDialog.lightRole === 'never'
|
||
&& q('.markerglowblock')?.classList.contains('hpf-disabled') && q('.markerglowblock').getAttribute('aria-disabled') === 'true'
|
||
&& qa('input[name="marker-glow-mode"]').every((r) => r.disabled) && q('#marker-glow-radius')?.disabled === true
|
||
&& !!q('#marker-glow-disabled-hint.hpf-callout')
|
||
&& q('input[name="marker-glow-mode"]').getAttribute('aria-describedby') === 'marker-glow-disabled-hint'
|
||
&& !q('.markerglowblock hp-help button[disabled]');
|
||
await pickSeg('marker-light-role', 'always');
|
||
await pickSeg('marker-glow-mode', 'fixed');
|
||
o.fixedShowsColorPlateAndBrightness = c._markerDialog.glowMode === 'fixed'
|
||
&& !!q('.markerglowvalue .hpf-colorfield hp-color-opacity[hide-label]') && !!q('.markerglowvalue .hpf-hex')
|
||
&& !!q('#marker-glow-brightness') && qa('input[type="color"]').length === 0
|
||
&& !q('.markerglowblock.hpf-disabled');
|
||
input(q('#marker-glow-brightness'), '42'); change(q('#marker-glow-brightness')); await upd();
|
||
o.brightnessNumberWrites = c._markerDialog.glowBrightness === 42 && c._markerDialog.glowTouched === true;
|
||
o.radiusHintWhenEmpty = c._markerDialog.glowRadius === '' && !!q('#marker-glow-radius')?.closest('.hpf-field')?.querySelector('.hpf-hint');
|
||
// Ревью r1 M2: радиус — не условие Save. Нечисло или ноль, как на dev, при
|
||
// сохранении молча становится общим радиусом; поле не помечается ошибкой.
|
||
c._markerDialog = { ...c._markerDialog, glowRadius: 'x' }; await upd();
|
||
o.badRadiusDoesNotBlockSave = saveBtn().disabled === false && !q('#marker-glow-radius[aria-invalid="true"]')
|
||
&& !q('.hpf-card[data-card="light"] .hpf-error') && !q('.hpf-status .hpf-link');
|
||
input(q('#marker-glow-radius'), '2.5'); await upd();
|
||
o.radiusWrites = c._markerDialog.glowRadius === '2.5' && saveBtn().disabled === false;
|
||
// «Review N fields» ведёт к первому ошибочному полю — здесь к привязке (create-режим ниже проверяет ссылку).
|
||
input(q('#marker-glow-radius'), '0'); await upd();
|
||
o.zeroRadiusKeepsSaveEnabled = saveBtn().disabled === false;
|
||
input(q('#marker-glow-radius'), ''); await upd();
|
||
|
||
// --- Appearance: иконка, display, бейдж, превью (Q3), размеры ----------------
|
||
o.iconFieldHasPreview = !!q('#marker-icon') && !!q('.hpf-iconfield .hpf-iconpreview ha-icon');
|
||
const pin = q('.iconauto .hpf-link');
|
||
o.autoIconHintHasPinLink = !c._markerDialog.icon ? !!pin : true;
|
||
if (pin) { pin.click(); await upd(); }
|
||
o.pinWritesIcon = !!c._markerDialog.icon && c._markerDialog.icon === c._markerDialog.autoIcon && !!q('.hpf-iconclear');
|
||
q('.hpf-iconclear').click(); await upd();
|
||
o.clearResetsIcon = c._markerDialog.icon === '';
|
||
o.displaySelectWithHint = !!q('#marker-display.hpf-select') && !!q('#marker-display')?.closest('.hpf-field')?.querySelector('.hpf-hint');
|
||
o.badgeBlock = !!q('.markerbadgegroup.hpf-block .hpf-sub h4') && !!q('#marker-value-badge')?.closest('.hpf-toggle.hpf-compact');
|
||
if (!q('#marker-value-badge').checked && !q('#marker-value-badge').disabled) { q('#marker-value-badge').click(); await upd(); }
|
||
o.badgePositionIsASegment = qa('input[name="marker-value-badge-position"]').length === 4;
|
||
await pickSeg('marker-value-badge-position', 'top');
|
||
o.badgePositionWrites = c._markerDialog.valueBadgePosition === 'top' && c._markerDialog.valueBadgeTouched === true;
|
||
// Q3: hp-device-preview целиком, без второго заголовка над ним (у компонента свой)
|
||
o.previewIsTheComponentItself = !!q('.hpf-card[data-card="appearance"] hp-device-preview')
|
||
&& !q('.hpf-card[data-card="appearance"] .hpf-tint hp-device-preview');
|
||
o.sizeRowsInAGrid = !!q('.hpf-card[data-card="appearance"] .hpf-grid #marker-size') && !!q('.hpf-card[data-card="appearance"] .hpf-grid #marker-angle');
|
||
o.sizeAndAngleFieldsDoNotCoverSliders = ['#marker-size', '#marker-angle'].every((selector) => {
|
||
const number = q(selector);
|
||
const range = number.closest('.hpf-range');
|
||
const slider = range.querySelector('ha-slider, input[type="range"]');
|
||
const sliderBox = slider.getBoundingClientRect();
|
||
const unitBox = number.closest('.hpf-unit').getBoundingClientRect();
|
||
return sliderBox.right < unitBox.left && number.scrollWidth <= number.clientWidth
|
||
&& unitBox.right <= range.getBoundingClientRect().right + 3;
|
||
});
|
||
input(q('#marker-size'), '2'); change(q('#marker-size')); await upd();
|
||
input(q('#marker-angle'), '35'); change(q('#marker-angle')); await upd();
|
||
o.sizeAndAngleNumbersWrite = c._markerDialog.size === 2 && c._markerDialog.angle === 35;
|
||
|
||
// --- Details: Model и Link в ряд, описание, вложения ------------------------
|
||
const modelBox = q('#marker-model').getBoundingClientRect();
|
||
const linkBox = q('#marker-link').getBoundingClientRect();
|
||
o.modelAndLinkInOneRow = Math.abs(modelBox.top - linkBox.top) < 2 && linkBox.left > modelBox.right;
|
||
o.descriptionIsAKitTextarea = q('#marker-description')?.tagName === 'TEXTAREA' && q('#marker-description').classList.contains('hpf-input');
|
||
o.attachInActions = !!q('.hpf-card[data-card="details"] .hpf-actions .filebtn');
|
||
const radarToggle = q('#marker-radar-presence');
|
||
o.radarEntryIsAToggleInAdditionalActions = !!radarToggle
|
||
&& !!radarToggle.closest('.hpf-card[data-card="details"] .radaradditional')
|
||
&& !q('.hpf-card[data-card="basics"] .radargroup');
|
||
if (radarToggle && !radarToggle.checked) { radarToggle.click(); await upd(); }
|
||
const firstRadarDraft = c._markerDialog.radar;
|
||
const firstInstallation = firstRadarDraft?.installationId;
|
||
o.radarExpandsImmediatelyBelowToggle = !!firstRadarDraft && !c._markerDialog.radarRemove
|
||
&& !!q('#marker-radar-presence')?.closest('.radaradditional')?.querySelector(':scope > .radargroup')
|
||
&& !q('.hpf-card[data-card="basics"] .radargroup');
|
||
q('#marker-radar-presence')?.click(); await upd();
|
||
o.radarOffKeepsSessionDraft = c._markerDialog.radar === firstRadarDraft
|
||
&& c._markerDialog.radarRemove === true && !q('.radaradditional > .radargroup');
|
||
q('#marker-radar-presence')?.click(); await upd();
|
||
o.radarOnRestoresSessionDraft = c._markerDialog.radar === firstRadarDraft
|
||
&& c._markerDialog.radar?.installationId === firstInstallation
|
||
&& c._markerDialog.radarRemove === false && !!q('.radaradditional > .radargroup');
|
||
input(q('#marker-model'), 'Model 600'); await upd();
|
||
o.modelWritesOwnKey = c._markerDialog.model === 'Model 600' && c._markerDialog.link === d0.link;
|
||
|
||
// --- AC9: цели ≥ 44 ---------------------------------------------------------
|
||
o.toggleTargetsAre44 = qa('.hpf-toggle > input[type="checkbox"]').every((i) => { const b = i.getBoundingClientRect(); return b.width >= 44 && b.height >= 44; });
|
||
o.segmentsAreRadiogroups = qa('.hpf-seg').length >= 4 && qa('.hpf-seg').every((s) => s.getAttribute('role') === 'radiogroup' && s.getAttribute('aria-label'));
|
||
|
||
// --- К10: Save только при изменениях, вопрос при закрытии --------------------
|
||
c._closeMarkerDialog(); await upd();
|
||
c._openMarkerDialog(lamp); await upd();
|
||
o.saveDisabledWhenClean = saveBtn().disabled === true && statusText() === '';
|
||
const editName0 = c._markerDialog.name;
|
||
input(q('#marker-name'), `${editName0} x`); await upd();
|
||
o.saveEnabledWhenDirtyWithoutDuplicateStatus = saveBtn().disabled === false && statusText() === '';
|
||
input(q('#marker-name'), editName0); await upd();
|
||
o.cleanAgainWhenReverted = saveBtn().disabled === true && statusText() === '';
|
||
input(q('#marker-name'), `${editName0} staged`); await upd();
|
||
const runtime = c._editorRuntime;
|
||
const saveMarker = runtime._saveMarker;
|
||
runtime._saveMarker = async () => undefined;
|
||
saveBtn().click(); await upd();
|
||
runtime._saveMarker = saveMarker;
|
||
input(q('#marker-name'), editName0); await upd();
|
||
o.saveAttemptDoesNotForgetBaseline = saveBtn().disabled === true;
|
||
q('.markeractions .btn:not(.danger)').click(); await upd();
|
||
o.hideMakesDirty = c._markerDialog.hideFromPlan === true && saveBtn().disabled === false;
|
||
dlg().dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
|
||
await upd(); await new Promise((r) => setTimeout(r, 60));
|
||
const confirm = () => sr().querySelector('hp-confirm hp-dialog');
|
||
o.discardAsksFirst = !!confirm() && !!dlg();
|
||
confirm()?.querySelector('[data-hp="dialog-cancel"]')?.click(); await upd(); await new Promise((r) => setTimeout(r, 60));
|
||
o.keepEditingKeepsDialog = !!dlg() && !confirm() && c._markerDialog?.hideFromPlan === true;
|
||
dlg().dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
|
||
await upd(); await new Promise((r) => setTimeout(r, 60));
|
||
confirm()?.querySelector('[data-hp="dialog-confirm"]')?.click(); await upd(); await new Promise((r) => setTimeout(r, 60));
|
||
o.discardCloses = !dlg() && c._markerDialog === null && lamp.marker?.hidden !== true;
|
||
c._openMarkerDialog(lamp); await upd();
|
||
dlg().dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
|
||
await upd(); await new Promise((r) => setTimeout(r, 60));
|
||
o.cleanClosesImmediately = !dlg() && !confirm();
|
||
|
||
// --- create-режим: обязательные поля видны inline до вызова save ----------
|
||
c._openMarkerDialog(); await upd();
|
||
o.createHasNoHideDelete = !q('.markeractions .btn');
|
||
o.virtualNameIsAnInlineProblem = saveBtn().disabled === true
|
||
&& q('#marker-name').getAttribute('aria-invalid') === 'true'
|
||
&& !!q('#marker-name').closest('.hpf-field').querySelector('.hpf-error')
|
||
&& /1/.test(q('.hpf-status .hpf-link').textContent);
|
||
await pickSeg('bmode', 'ha');
|
||
o.createHaWithoutBindingIsAProblem = saveBtn().disabled === true && !!q('.hpf-status .hpf-link')
|
||
&& !!q('.hpf-card[data-card="basics"] .hpf-error') && q('#marker-binding').getAttribute('aria-expanded') === 'true';
|
||
q('.hpf-status .hpf-link').click(); await upd();
|
||
o.reviewLinkFocusesBinding = (sr().activeElement ?? document.activeElement)?.id === 'marker-binding';
|
||
await pickSeg('bmode', 'virtual');
|
||
o.createVirtualHasHint = c._markerDialog.binding === 'virtual' && saveBtn().disabled === true
|
||
&& !!q('input[name="bmode"]')?.closest('.hpf-field')?.querySelector('.hpf-hint');
|
||
input(q('#marker-name'), 'Virtual sensor'); await upd();
|
||
o.virtualNameUnblocksSave = saveBtn().disabled === false && !q('#marker-name[aria-invalid="true"]');
|
||
await select(q('#marker-tap-action'), 'run');
|
||
o.runWithoutTargetIsInline = saveBtn().disabled === true
|
||
&& q('#marker-run-target').getAttribute('aria-invalid') === 'true'
|
||
&& !!q('#marker-run-target').closest('.hpf-field').querySelector('.hpf-error');
|
||
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.demo', runFilter: '' }; await upd();
|
||
o.runTargetUnblocksSave = saveBtn().disabled === false;
|
||
c._markerDialog = {
|
||
...c._markerDialog, valueBadgeTouched: true, valueBadgeEnabled: true, valueBadgeSource: null,
|
||
}; await upd();
|
||
o.badgeWithoutSourceIsInline = saveBtn().disabled === true
|
||
&& q('#marker-value-badge-source').getAttribute('aria-invalid') === 'true'
|
||
&& !!q('#marker-value-badge-source').closest('.hpf-field').querySelector('.hpf-error');
|
||
c._markerDialog = null; await upd();
|
||
return o;
|
||
});
|
||
|
||
// --- AC2: полосы экранов ------------------------------------------------------
|
||
const bands = {};
|
||
for (const width of [320, 390, 480, 560, 768, 1280, 1920]) {
|
||
await page.setViewportSize({ width, height: 900 });
|
||
const m = await page.evaluate(async (w) => {
|
||
const c = window.__card;
|
||
const sr = () => c.shadowRoot || c.renderRoot;
|
||
c._markerDialog = null; c.requestUpdate(); await c.updateComplete;
|
||
const lamp = c._devices.find((d) => d.id === 'd_light1') || c._devices.find((d) => d.bindingKind === 'device');
|
||
c._openMarkerDialog(lamp); c.requestUpdate(); await c.updateComplete;
|
||
c._markerDialog = { ...c._markerDialog, lightRole: 'always', glowMode: 'fixed', display: 'icon_ripple' };
|
||
c.requestUpdate(); await c.updateComplete;
|
||
await new Promise((r) => setTimeout(r, 60));
|
||
const dlg = sr().querySelector('hp-dialog[data-kind="marker"]');
|
||
const surface = dlg.shadowRoot.querySelector('.surface');
|
||
const content = dlg.shadowRoot.querySelector('.content');
|
||
const body = dlg.querySelector('.body');
|
||
const sw = Math.round(surface.getBoundingClientRect().width);
|
||
const expected = w >= 600 ? sw === 560 : w > 480 ? sw <= Math.round(w * 0.94) + 1 : sw === w;
|
||
const noHorizontal = content.scrollWidth <= content.clientWidth + 1 && body.scrollWidth <= body.clientWidth + 1;
|
||
const gridCols = getComputedStyle(dlg.querySelector('.hpf-card[data-card="details"] .hpf-grid')).gridTemplateColumns.split(' ').length;
|
||
const footer = dlg.querySelector('.dialog-action-footer');
|
||
const footerVisible = footer.getBoundingClientRect().bottom <= innerHeight + 1;
|
||
const segFits = [...dlg.querySelectorAll('.hpf-seg label')].every((l) => {
|
||
const b = l.getBoundingClientRect(); return b.right <= content.getBoundingClientRect().right + 1;
|
||
});
|
||
c._markerDialog = null; c.requestUpdate(); await c.updateComplete;
|
||
return { sw, expected, noHorizontal, gridCols, footerVisible, segFits };
|
||
}, width);
|
||
bands[`w${width}_surfaceWidth`] = m.expected;
|
||
bands[`w${width}_noHorizontalScroll`] = m.noHorizontal;
|
||
bands[`w${width}_footerVisible`] = m.footerVisible;
|
||
bands[`w${width}_detailsColumns`] = m.gridCols === (width <= 480 ? 1 : 2);
|
||
bands[`w${width}_segmentsFit`] = m.segFits;
|
||
}
|
||
|
||
checkAll(out);
|
||
checkAll(bands);
|
||
await finish(browser, { ...out, ...bands });
|