Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1df463d7cf | ||
|
|
2c7abe7938 | ||
|
|
9bae4d283a | ||
|
|
216990e469 | ||
|
|
0cbc7aff7a | ||
|
|
856a2c6e12 | ||
|
|
5790abaa1f | ||
|
|
fcce2af1ff | ||
|
|
ad4000f95a | ||
|
|
e5fe4364dc | ||
|
|
25a9607618 | ||
|
|
258896cb6f | ||
|
|
807d9917c0 | ||
|
|
c22c04d647 | ||
|
|
e66ce808d4 | ||
|
|
dded4fbd5c | ||
|
|
c23c7927de | ||
|
|
6aca447fa0 | ||
|
|
6103e7de68 | ||
|
|
84a7773e29 | ||
|
|
542650234d | ||
|
|
7d08a28de7 | ||
|
|
264fba0902 | ||
|
|
60f93a10ed | ||
|
|
032616f223 | ||
|
|
27126ad367 | ||
|
|
0af582daa5 | ||
|
|
ba74cd1556 | ||
|
|
412ca0f6ce | ||
|
|
f9ed9d2ae8 | ||
|
|
6de854a03d | ||
|
|
fcc6c747a3 | ||
|
|
9fd9e6d978 | ||
|
|
a65c88bc11 | ||
|
|
733cd5b86b | ||
|
|
9cb1f4be6c | ||
|
|
f724cca61a | ||
|
|
7ecdafef94 | ||
|
|
dedcedc8cc | ||
|
|
dbb1da5d25 | ||
|
|
03983946c5 | ||
|
|
6706182a27 | ||
|
|
db7eef5306 | ||
|
|
a03ccd65a5 | ||
|
|
c53fd1a54f | ||
|
|
5c850ad01f | ||
|
|
2642fdb446 | ||
|
|
e0098c8d00 | ||
|
|
717308f0fb | ||
|
|
954eeff45a | ||
|
|
b35551884f | ||
|
|
ad886b7f3c | ||
|
|
cc0b41ba61 | ||
|
|
aa13e2264e | ||
|
|
8fa2568cf9 | ||
|
|
e36767773a | ||
|
|
c8b3606c57 | ||
|
|
c3a749949f | ||
|
|
66bf277da2 | ||
|
|
d5e0c49198 | ||
|
|
d0ecd53831 | ||
|
|
e66e82fc86 | ||
|
|
e3bf893e3d | ||
|
|
dbd435ad1a | ||
|
|
f601182a90 | ||
|
|
cc66474035 | ||
|
|
55db3d4986 | ||
|
|
c83f19ae52 | ||
|
|
be4b54e4d1 | ||
|
|
06cec9669c | ||
|
|
62e0eff219 | ||
|
|
d785a4aa07 | ||
|
|
23f48829c2 | ||
|
|
7b69726217 | ||
|
|
d07aac3581 | ||
|
|
31ef70cee6 | ||
|
|
6102aabbdc | ||
|
|
d89b55ccf0 | ||
|
|
4d8ad3db32 | ||
|
|
5bda3d4393 | ||
|
|
43ba198d7f | ||
|
|
80e3fee527 | ||
|
|
48b9649d45 | ||
|
|
08701ba092 | ||
|
|
723ec6d362 | ||
|
|
5b0cb687c9 | ||
|
|
b15d357fa2 | ||
|
|
76d8017e87 | ||
|
|
e85849b86e | ||
|
|
6c6f53491f | ||
|
|
f34ca4090b | ||
|
|
5fc596c748 | ||
|
|
a388243129 | ||
|
|
0fd6977b47 | ||
|
|
fdb0d0743d | ||
|
|
a8ae5a45e1 | ||
|
|
9c269c302d | ||
|
|
01ce4817c3 | ||
|
|
d6180f7f5d | ||
|
|
a66649a13b | ||
|
|
7b58f73e57 | ||
|
|
f538825925 | ||
|
|
3934f8d8e5 | ||
|
|
f6e5651ccf | ||
|
|
744f502ba3 | ||
|
|
9c08d583ab | ||
|
|
c1c3743f5d | ||
|
|
eb77224e0c | ||
|
|
c53f9ffc02 | ||
|
|
bb35af993a | ||
|
|
d6b33b115b | ||
|
|
d527ff8dcc | ||
|
|
b26d969402 | ||
|
|
51ead265f0 | ||
|
|
49130d4ffd | ||
|
|
3aa9ffbd1a | ||
|
|
81daebd788 | ||
|
|
036a15aade | ||
|
|
ce6650b1e0 | ||
|
|
a1fac44d72 | ||
|
|
3342910b27 | ||
|
|
4a91d5cbe8 | ||
|
|
6d2facc2e3 | ||
|
|
6502f516f9 | ||
|
|
0f06b60084 | ||
|
|
63816a4ec3 | ||
|
|
66a6485418 | ||
|
|
7baa1cfa13 | ||
|
|
293309355b | ||
|
|
62d5446114 | ||
|
|
c291384b7a | ||
|
|
ccfd2565a7 | ||
|
|
b53046b8e1 | ||
|
|
1ef35883e0 | ||
|
|
2c6410bb87 | ||
|
|
e921d5906d | ||
|
|
bb031d8599 | ||
|
|
1e1880f010 | ||
|
|
4906f0a97f | ||
|
|
05a2c68da2 | ||
|
|
063de0eef4 | ||
|
|
a52dcab77a | ||
|
|
1ca3b5fae3 | ||
|
|
e63460f068 | ||
|
|
ac1e69be7f | ||
|
|
5f0dfa84e1 | ||
|
|
5afdb644c7 | ||
|
|
a9a6d37bba | ||
|
|
1fb84755c2 | ||
|
|
e40d3f18d2 | ||
|
|
8f68cb51bd | ||
|
|
41ae5e9986 | ||
|
|
1cfe7fa53c | ||
|
|
3322c20c09 | ||
|
|
ced9b41642 | ||
|
|
7a81d41bab | ||
|
|
590f5ab127 | ||
|
|
11ccd5b095 | ||
|
|
e09bb2faee | ||
|
|
73c13e37b4 | ||
|
|
e74823737f | ||
|
|
acea695069 | ||
|
|
9584933c98 | ||
|
|
8afba7e6ed | ||
|
|
116cfd5fef | ||
|
|
f65d07e3ea | ||
|
|
45d44608a1 | ||
|
|
0d63529e90 | ||
|
|
77a3d2607c | ||
|
|
9d1ba1103c | ||
|
|
af3be59a2c | ||
|
|
58e31668c9 | ||
|
|
9d35ab35ac | ||
|
|
2e26116bbb | ||
|
|
d21797dc66 | ||
|
|
b4a16ad704 | ||
|
|
a261e54ce7 | ||
|
|
af2081cbc4 | ||
|
|
8f5a08a609 | ||
|
|
db39b620ba | ||
|
|
85e5d61fb1 | ||
|
|
49912ac989 | ||
|
|
b736878c0b | ||
|
|
2977177283 | ||
|
|
e408b6fb70 | ||
|
|
bde05697d1 | ||
|
|
b9e40aebc2 | ||
|
|
0a0a9f0f4c | ||
|
|
7e39c3715c | ||
|
|
f37ccdcebc | ||
|
|
78bc76073e | ||
|
|
4451cf50d9 | ||
|
|
9609040d31 | ||
|
|
9ad8b1f710 | ||
|
|
b59e245e32 | ||
|
|
cd47a59d3f | ||
|
|
6b64801603 | ||
|
|
f4625dd4fb | ||
|
|
8ba87b3ff7 | ||
|
|
ab296302d1 | ||
|
|
8d5bade656 | ||
|
|
9edef041a9 | ||
|
|
95175a7616 | ||
|
|
19e421b3cf | ||
|
|
3f3583ac58 | ||
|
|
9a3af808d9 | ||
|
|
deebcfb095 | ||
|
|
0715321fdc | ||
|
|
53585b451b | ||
|
|
fa7ac02c0f | ||
|
|
548e7a8563 | ||
|
|
9592092b8e | ||
|
|
c50bc725c8 | ||
|
|
b2346eaac3 | ||
|
|
a325294d7a | ||
|
|
3066fec17e | ||
|
|
462b56453c | ||
|
|
49a02637ca | ||
|
|
a625871538 | ||
|
|
e181b08f2b | ||
|
|
50fa7f9be2 | ||
|
|
b49721a0bd | ||
|
|
ad2f9afd2b | ||
|
|
27ea23c1ec | ||
|
|
3d43980568 | ||
|
|
37ef970dfc | ||
|
|
914e84028d | ||
|
|
d5575f6393 | ||
|
|
1950df608f | ||
|
|
94ea4694f4 | ||
|
|
be6d57e96f | ||
|
|
5acf04fa33 | ||
|
|
93c6c7551b | ||
|
|
bac63dbcaf | ||
|
|
31c2583e46 | ||
|
|
2f41c0b29e | ||
|
|
a3e7ac1fc6 | ||
|
|
7194fb2df0 | ||
|
|
a44fbd373d | ||
|
|
c21e9de32b | ||
|
|
9bbba66d11 | ||
|
|
b4a2d5b952 | ||
|
|
b6a66a1bac | ||
|
|
88eb382403 | ||
|
|
f6eac1d971 | ||
|
|
67c780a742 | ||
|
|
2783ceff94 | ||
|
|
0c74828240 | ||
|
|
9b00002a5b | ||
|
|
19b14d3976 | ||
|
|
172d9d5a00 | ||
|
|
588ef42546 | ||
|
|
e2bb1e5390 | ||
|
|
398281a93f | ||
|
|
9d375dcb40 | ||
|
|
57f38fedba | ||
|
|
ae8f6728d7 | ||
|
|
d204bf5d77 | ||
|
|
156835c048 | ||
|
|
e20560afa8 | ||
|
|
6b31e94501 | ||
|
|
a824acc1b1 | ||
|
|
a72f5274ca | ||
|
|
441d3cc3de | ||
|
|
9ab4896c90 | ||
|
|
9293109ba1 | ||
|
|
96bfd94313 | ||
|
|
3ad5d0baea | ||
|
|
440460255b | ||
|
|
aeb0a473db | ||
|
|
0ce5e4eed3 | ||
|
|
ed6d1d76db | ||
|
|
a8a9b7d99e | ||
|
|
39571e20d6 | ||
|
|
0796a01571 | ||
|
|
57b19f9914 | ||
|
|
c50458a098 | ||
|
|
d20ef60037 | ||
|
|
8f037a74dc | ||
|
|
ad2858a80c | ||
|
|
5a1cddeaf0 | ||
|
|
3c1b4ebee2 | ||
|
|
be93291065 | ||
|
|
6ac513caa0 | ||
|
|
e38beed796 | ||
|
|
6b592c13c0 | ||
|
|
72764cff90 | ||
|
|
f02af9330b | ||
|
|
c544c26b66 | ||
|
|
fa01aa00bf | ||
|
|
0b9acd6382 | ||
|
|
f5fb7a0f8a | ||
|
|
61905bacdc | ||
|
|
9b65d4bcf7 | ||
|
|
e96389778f | ||
|
|
ffe2ec51c0 | ||
|
|
b7d8b9e6ee | ||
|
|
e5a62f7ffd | ||
|
|
7674b78c33 | ||
|
|
3f402a3481 | ||
|
|
278d9257eb | ||
|
|
33cb131b3a | ||
|
|
615181050b | ||
|
|
8acbd333d8 | ||
|
|
00130e08aa | ||
|
|
f6e64ba985 | ||
|
|
cbece6324f | ||
|
|
97dfa457a4 | ||
|
|
34c5c2d134 | ||
|
|
73d6e2eeeb | ||
|
|
24cd39fca5 | ||
|
|
65526a6a7f | ||
|
|
b593489041 | ||
|
|
a91ba16a5c | ||
|
|
1063156f12 | ||
|
|
2b8b7b2aa7 | ||
|
|
eed7239aa3 | ||
|
|
9d8f89d260 | ||
|
|
590b3a7e67 | ||
|
|
6842b69eed | ||
|
|
ceab345215 | ||
|
|
d72cb704f3 | ||
|
|
913e3187f1 | ||
|
|
43809178ee | ||
|
|
581d165bda | ||
|
|
a9d0cab2dd |
@@ -3,9 +3,14 @@ name: Анонс релиза
|
||||
# Stable releases are announced; prereleases are deliberately silent.
|
||||
# workflow_dispatch exists purely as a connectivity test button and therefore
|
||||
# remains allowed to send a test message.
|
||||
# #538: НЕТ триггера `release: published`. Три воркфлоу висели на одном событии
|
||||
# и бежали параллельно, а анонсу нечего было проверять — он выигрывал гонку
|
||||
# всегда. 12.09 v1.75.0 объявили в канале в ту же минуту, когда гейт ассетов
|
||||
# отказал: релиз остался без `houseplan-card.js`, E2E не выполнялся вовсе, а
|
||||
# подписчики получили сообщение. Анонс вызывается только после того, как работа
|
||||
# сделана: `release.yml` зовёт его после выкладки ассетов,
|
||||
# `publish-prerelease.yml` — после публикации беты.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
@@ -37,12 +42,12 @@ permissions:
|
||||
jobs:
|
||||
telegram:
|
||||
name: Оповещение в Telegram (только стабильные)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_call' && inputs.prerelease == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
@@ -54,32 +59,20 @@ jobs:
|
||||
INPUT_NAME: ${{ inputs.release_name }}
|
||||
INPUT_URL: ${{ inputs.url }}
|
||||
INPUT_PRE: ${{ inputs.prerelease }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ github.event.release.name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
RELEASE_PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
RELEASE_BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
if [ "$CALLED" = "true" ]; then
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
else
|
||||
TAG=$RELEASE_TAG
|
||||
NAME=$RELEASE_NAME
|
||||
URL=$RELEASE_URL
|
||||
PRE=$RELEASE_PRE
|
||||
BODY=$RELEASE_BODY
|
||||
fi
|
||||
# Единственный путь к сообщению о релизе — вызов из воркфлоу, который
|
||||
# уже закончил работу (#538). Тело берётся из файла заметок ветки
|
||||
# тега, а не из события: события здесь больше нет.
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
if [ "$PRE" = "true" ]; then
|
||||
echo "Prerelease Telegram announcement is disabled"
|
||||
exit 0
|
||||
|
||||
@@ -33,10 +33,10 @@ jobs:
|
||||
name: Съёмка и сверка скриншот-индекса
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# системные библиотеки Chromium уже в образе раннера.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
echo "ВЕРДИКТ: ничего не изменилось, принимать нечего."
|
||||
fi
|
||||
- name: Upload candidate
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: docs-screenshots
|
||||
path: |
|
||||
|
||||
@@ -5,11 +5,12 @@ name: Мутационный гейт
|
||||
# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит
|
||||
# защитой, и это хуже его отсутствия.
|
||||
#
|
||||
# Прогон дорогой, поэтому он не входит в Validate и не идёт на каждый push.
|
||||
# Его место — перед стабильным релизом (PROCESS.md §8) и раз в неделю по
|
||||
# расписанию, чтобы дрейф тестов не копился до релиза. Дешёвая половина —
|
||||
# «якоря патчей живы, guard-файлы существуют» — идёт с обычными юнитами:
|
||||
# test/mutation-gate.test.mjs.
|
||||
# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в
|
||||
# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца
|
||||
# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472).
|
||||
# Мутанты, задетые диффом, конвейер ревью гоняет отдельно на кандидате (#510).
|
||||
# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с
|
||||
# обычными юнитами: test/mutation-gate.test.mjs.
|
||||
#
|
||||
# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle),
|
||||
# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный
|
||||
@@ -25,14 +26,15 @@ on:
|
||||
required: false
|
||||
default: dev
|
||||
schedule:
|
||||
# Понедельник, 05:20 UTC — до начала рабочего дня владельца.
|
||||
- cron: '20 5 * * 1'
|
||||
# Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного
|
||||
# полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513).
|
||||
- cron: '0 1 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Группа зависит от события (#472). Прежде она была одна на всё, и ручной
|
||||
# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб
|
||||
# запуск (отладка гейта) отменял идущий по расписанию — так 24.08 погиб
|
||||
# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать
|
||||
# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job,
|
||||
# включая любой репортёр. Значит отмену надо не ловить, а не допускать.
|
||||
@@ -41,8 +43,30 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# #549: moving ref разрешается ровно один раз. Все шарды ниже получают один
|
||||
# commit/tree, а не самостоятельно читают dev в разное время.
|
||||
material:
|
||||
name: "Зафиксировать неизменяемый материал"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.identity.outputs.sha }}
|
||||
tree: ${{ steps.identity.outputs.tree }}
|
||||
ref: ${{ steps.identity.outputs.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
fetch-depth: 0
|
||||
- name: Зафиксировать commit и tree
|
||||
id: identity
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
mutants:
|
||||
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
|
||||
needs: material
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -52,19 +76,19 @@ jobs:
|
||||
# Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
@@ -78,7 +102,7 @@ jobs:
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -99,42 +123,85 @@ jobs:
|
||||
# `tee` не съел код выхода раннера.
|
||||
- name: Каждый тест ловит свою поломку
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
mkdir -p artifacts/mutation-shard-${{ matrix.shard }}
|
||||
set -o pipefail
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
- name: Сохранить лог шарда
|
||||
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
|
||||
- name: Записать identity шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--shard=${{ matrix.shard }} --shards=4
|
||||
- name: Сохранить лог и identity шарда
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: mutation-shard-${{ matrix.shard }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}.log
|
||||
name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||||
path: artifacts/mutation-shard-${{ matrix.shard }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручные прогоны перед
|
||||
# релизом падают по замыслу, их результат владелец смотрит сам — issue на
|
||||
# Результат нельзя приписывать material, пока не доказаны все четыре шарда.
|
||||
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
|
||||
evidence:
|
||||
name: "Доказать единый material всех шардов"
|
||||
needs: [material, mutants]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# CLI отчётчика принадлежит зафиксированному material. В main лежит
|
||||
# только workflow и может не быть dev-версии scripts/**.
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать evidence всех попыток
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
- name: Проверить полноту и identity
|
||||
run: |
|
||||
node scripts/mutation-gate-report.mjs --verify-only \
|
||||
--logs=artifacts/mutation-logs --shards=4 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }}
|
||||
|
||||
# Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся
|
||||
# для отладки самого гейта, его результат смотрят в прогоне — issue на
|
||||
# каждый такой отказ был бы шумом, который снова перестанут читать.
|
||||
#
|
||||
# Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент —
|
||||
# validate.yml, job с actions: read): перечислены все три.
|
||||
report:
|
||||
name: "Отказ расписания: issue и Telegram"
|
||||
needs: mutants
|
||||
if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success'
|
||||
needs: [material, mutants, evidence]
|
||||
if: always() && github.event_name == 'schedule' && (needs.mutants.result != 'success' || needs.evidence.result != 'success')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
- uses: actions/setup-node@v7
|
||||
# Не перечитываем moving dev: код отчётчика берётся из уже
|
||||
# зафиксированного material. В стабильном main dev-скриптов нет.
|
||||
ref: ${{ needs.material.outputs.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Забрать логи шардов
|
||||
uses: actions/download-artifact@v7
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
pattern: mutation-shard-*
|
||||
path: artifacts/mutation-logs
|
||||
@@ -143,14 +210,14 @@ jobs:
|
||||
env:
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
# SHA — того дерева, которое чекаутили и гоняли (dev), а не
|
||||
# github.sha: для расписания это вершина default-ветки main, и отчёт
|
||||
# называл бы «dev @ <SHA main>» (ревью r1). Образец — process.yml.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
mkdir -p artifacts
|
||||
node scripts/mutation-gate-report.mjs \
|
||||
--logs=artifacts/mutation-logs --shards=4 \
|
||||
--run-url="$RUN_URL" --ref=dev --sha="$SHA" \
|
||||
--require-evidence --logs=artifacts/mutation-logs --shards=4 \
|
||||
--sha=${{ needs.material.outputs.sha }} \
|
||||
--tree=${{ needs.material.outputs.tree }} \
|
||||
--workflow-sha=${{ github.workflow_sha }} \
|
||||
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
|
||||
--run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \
|
||||
--body-out=artifacts/mutation-report.md \
|
||||
--telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT"
|
||||
# Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- space-glow
|
||||
steps:
|
||||
- name: Check out candidate
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: candidate
|
||||
fetch-depth: 2
|
||||
@@ -131,12 +131,12 @@ jobs:
|
||||
echo "Comparison base: $sha ($source)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out base SHA
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ steps.base.outputs.sha }}
|
||||
path: baseline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
# То же, что в validate.yml: кэш браузеров, apt не трогаем (#206).
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('candidate/package-lock.json') }}
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
|
||||
- name: Upload full performance report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: full-performance-${{ matrix.profile }}
|
||||
path: artifacts/performance
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Сверка очереди ревью
|
||||
run-name: "reconcile process queue · ${{ github.event_name }}"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '7,37 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
apply:
|
||||
description: "Повторно будить потерянные запросы и публиковать диагностику"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
name: "Один снимок S4/S7 без polling модели"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Расписание читается из main, а исполняемый reconciler — из dev: так
|
||||
# после штатного merge действует та же версия кода, которую проверил CI.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Сопоставить labels, requests, runs и sealed evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-reconcile
|
||||
node scripts/process-reconcile.mjs \
|
||||
--repo "$REPO" \
|
||||
--apply="$APPLY" \
|
||||
--max-actions=5 \
|
||||
--output=artifacts/process-reconcile/summary.json
|
||||
- name: Опубликовать компактный machine-readable итог
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/process-reconcile/summary.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Ревью-конвейер
|
||||
run-name: "process #${{ github.event.issue.number }} · ${{ github.event.label.name }} · ${{ github.event.issue.title }}"
|
||||
|
||||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||||
@@ -28,16 +29,20 @@ on:
|
||||
# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает
|
||||
# ни одной job (`if` на guard) и в группу не входит.
|
||||
|
||||
# #556: права выдаются по job, а не одной строкой на весь workflow. Общий блок
|
||||
# давал `issues: write` и OIDC даже той стадии, которая ничего не пишет, —
|
||||
# модели. Ниже минимум на каждую: писать в issue умеют только детерминированные
|
||||
# стадии, OIDC нужен исключительно `claude-code-action`.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
# Обязательно: claude-code-action получает OIDC-токен для авторизации
|
||||
# GitHub App. Без этого прогон падает с «Could not fetch an OIDC token».
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: "Страж: ребейз на dev и предпосылки ревью"
|
||||
# Читает и переставляет метки, комментирует отказ.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
# Только статусные метки этапов ревью запускают конвейер (#499). Остальные
|
||||
# события помечаются skipped и не занимают место в группе concurrency.
|
||||
if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review'
|
||||
@@ -55,7 +60,7 @@ jobs:
|
||||
# scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что
|
||||
# inline-shell не покрывается тестами (#454). Node на раннере
|
||||
# предустановлен, setup-node не нужен.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: dev
|
||||
@@ -254,18 +259,46 @@ jobs:
|
||||
echo "spent=$spent" >> "$GITHUB_OUTPUT"
|
||||
echo "limit=$limit" >> "$GITHUB_OUTPUT"
|
||||
|
||||
review:
|
||||
name: "Ревью (Claude): вердикт в issue"
|
||||
prepare:
|
||||
name: "Ревью: материал и deterministic gates"
|
||||
# Ребейзит ветку задачи и комментирует возврат автору.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
needs: guard
|
||||
if: needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Время — единственный настоящий ограничитель зациклившегося прогона.
|
||||
timeout-minutes: 45
|
||||
# Ожидание Validate не отнимает бюджет у модели (#551). Сам gate может
|
||||
# ждать 45 минут; подготовке оставлен отдельный запас на checkout/rebase.
|
||||
timeout-minutes: 55
|
||||
outputs:
|
||||
proceed: ${{ steps.gate.outputs.proceed }}
|
||||
branch: ${{ steps.branch.outputs.name }}
|
||||
rebase_note: ${{ steps.rebase.outputs.note }}
|
||||
material_sha: ${{ steps.material.outputs.sha }}
|
||||
material_tree: ${{ steps.material.outputs.tree }}
|
||||
material_specs: ${{ steps.material.outputs.specs }}
|
||||
material_issue_body: ${{ steps.material.outputs.issue_body }}
|
||||
reuse: ${{ steps.reuse.outputs.reuse }}
|
||||
reuse_doc: ${{ steps.reuse.outputs.doc }}
|
||||
reuse_round: ${{ steps.reuse.outputs.round }}
|
||||
reuse_tree: ${{ steps.reuse.outputs.tree }}
|
||||
validate_result: ${{ steps.gate.outputs.result }}
|
||||
validate_url: ${{ steps.gate.outputs.url }}
|
||||
validated_note: ${{ steps.validated.outputs.note }}
|
||||
spec_body_changed: ${{ steps.spec_body.outputs.changed }}
|
||||
spec_body_doc: ${{ steps.spec_body.outputs.doc }}
|
||||
spec_body_recorded: ${{ steps.spec_body.outputs.recorded }}
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
@@ -295,7 +328,7 @@ jobs:
|
||||
# кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели
|
||||
# тратились на работу инфраструктуры. В validate.yml кэш стоит на всех
|
||||
# тяжёлых job, здесь его не было.
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -321,20 +354,10 @@ jobs:
|
||||
git checkout -q "origin/$branch"
|
||||
echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)"
|
||||
echo "name=$branch" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||||
# хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что
|
||||
# ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую
|
||||
# ветку, и спрашивать его поздно.
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||||
# длиннее одного.
|
||||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||||
# Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ
|
||||
# ребейза (#515): снятые здесь, они после force-push приведённой
|
||||
# ветки указывали на осиротевший коммит, и ни reuse (#499), ни
|
||||
# страховка #414 не находили дерева в свежем клоне.
|
||||
else
|
||||
echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev"
|
||||
echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -357,6 +380,8 @@ jobs:
|
||||
if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != ''
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
# #539: тем же токеном спрашивается REST — через него идёт и диспатч.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
# rebase, в отличие от commit, не принимает -c user.*: он запускает
|
||||
# свои процессы и требует личность в окружении, иначе падает с
|
||||
@@ -399,6 +424,25 @@ jobs:
|
||||
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
|
||||
exit 1
|
||||
fi
|
||||
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
|
||||
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
|
||||
# нельзя. 12.09 на #536 диспатч, отправленный через три секунды после
|
||||
# этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на
|
||||
# материале и вернул задачу автору, которому чинить было нечего.
|
||||
# Поэтому шаг не заканчивается, пока REST не отдаст новую вершину —
|
||||
# именно REST, потому что через него же идёт и сам диспатч.
|
||||
after=$(git rev-parse HEAD)
|
||||
settled=false
|
||||
for _ in $(seq 1 30); do
|
||||
seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \
|
||||
--jq .object.sha 2>/dev/null || true)
|
||||
if [ "$seen" = "$after" ]; then settled=true; break; fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA"
|
||||
exit 1
|
||||
fi
|
||||
# Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH,
|
||||
# и без этого он ребейзил бы заново уже приведённое.
|
||||
git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"
|
||||
@@ -413,9 +457,46 @@ jobs:
|
||||
- name: Зафиксировать SHA материала ревью
|
||||
id: material
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
echo "материал ревью: $(git rev-parse --short HEAD)"
|
||||
# Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита
|
||||
# ребейз меняет — содержимое нет: git адресует деревья и блобы их
|
||||
# хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая
|
||||
# копия равна тому, что ревьюер прочтёт, и коммит с этим деревом
|
||||
# уже запушен в ветку — следующий прогон найдёт его в свежем клоне.
|
||||
# В шаге публикации дерево уже сброшено на целевую ветку, и
|
||||
# спрашивать его поздно.
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
# ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст
|
||||
# где-нибудь достижим. Файлов может не быть (инфраструктурная
|
||||
# задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо
|
||||
# длиннее одного.
|
||||
specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \
|
||||
| awk '{print $2" "$4}' | tr '\n' ';')
|
||||
echo "specs=$specs" >> "$GITHUB_OUTPUT"
|
||||
# Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это
|
||||
# единственный якорь, доказывающий «вердикт вынесен на этом тексте».
|
||||
# Читается здесь, а не из github.event.issue.body: между событием
|
||||
# метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание
|
||||
# Validate), и снимок события описывал бы не тот текст.
|
||||
body=$(mktemp)
|
||||
if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then
|
||||
digest=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8")));
|
||||
});
|
||||
' "$body")
|
||||
echo "issue_body=$digest" >> "$GITHUB_OUTPUT"
|
||||
echo "тело issue: ${digest:0:12}"
|
||||
else
|
||||
echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт"
|
||||
fi
|
||||
echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')"
|
||||
|
||||
# Повторное применение зелёного вердикта без вызова модели (#499). Сценарий
|
||||
# #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же
|
||||
@@ -431,8 +512,13 @@ jobs:
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
# Правка ТЗ между раундами обязана отменять повторное применение
|
||||
# зелёного вердикта: иначе вызов модели пропускается и находку
|
||||
# «ТЗ менялось» некому напечатать (#517 AC6).
|
||||
ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
run: |
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD)
|
||||
out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \
|
||||
--issue-body="${ISSUE_BODY}")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
if printf '%s\n' "$out" | grep -qx 'reuse=true'; then
|
||||
@@ -474,6 +560,64 @@ jobs:
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
|
||||
|
||||
# Мутанты по диффу бегут только по запросу (#510): до ревью конвейер
|
||||
# запускает Validate с мутантами на материале и ждёт его. Красный или
|
||||
# пропавший прогон возвращает задачу автору без ревью — цикл не
|
||||
# тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6
|
||||
# после запущенного 15-минутного ревью). Этап spec кода не несёт и
|
||||
# гейт не проходит; повторное применение вердикта (#499) — тоже: там
|
||||
# слияние само дожидается Validate на кандидате.
|
||||
- name: Validate с мутантами на материале
|
||||
id: gate
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
run: |
|
||||
if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then
|
||||
echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}"
|
||||
{ echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then
|
||||
echo 'proceed=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'proceed=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Validate красный — вернуть автору без ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SHA: ${{ steps.material.outputs.sha }}
|
||||
RESULT: ${{ steps.gate.outputs.result }}
|
||||
NOTE: ${{ steps.gate.outputs.note }}
|
||||
URL: ${{ steps.gate.outputs.url }}
|
||||
run: |
|
||||
short=$(git rev-parse --short "$SHA")
|
||||
cat > /tmp/gate.md <<EOF
|
||||
**Ревью не запускалось:** Validate с мутантами на материале \`$short\` (ветка \`$BRANCH\`) — **$RESULT**: $NOTE.${URL:+ [Прогон]($URL).} Код никто не читал, вердикта нет, цикл ревью не израсходован.
|
||||
|
||||
Гейт стоит до ревью намеренно (#510): красный CI всё равно вернул бы задачу, но уже после потраченного ревью.
|
||||
|
||||
Задача переведена в \`S6-in-progress\`. Осталось:
|
||||
|
||||
1. починить то, что назвал прогон, и запушить ветку **одним** коммитом-заходом;
|
||||
2. дождаться зелёного дешёвого Validate на пуше;
|
||||
3. вернуть метку \`S7-code-review\` — конвейер сам запустит Validate с мутантами и ревью.
|
||||
|
||||
[Прогон конвейера](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
EOF
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file /tmp/gate.md
|
||||
gh issue edit "$NUM" --repo "${{ github.repository }}" \
|
||||
--add-label S6-in-progress --remove-label S7-code-review
|
||||
echo "S7-code-review -> S6-in-progress (Validate с мутантами: $RESULT)"
|
||||
|
||||
# Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя
|
||||
# Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет
|
||||
# ревью тратится на повторение CI вместо чтения кода.
|
||||
@@ -483,7 +627,7 @@ jobs:
|
||||
# ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам.
|
||||
- name: Зелёные гейты на этом SHA
|
||||
id: validated
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
run: |
|
||||
@@ -509,24 +653,198 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi
|
||||
|
||||
# Это deterministic evidence для промпта, поэтому вычисляется до запуска
|
||||
# модели и передаётся вместе с неизменяемым контрактом материала (#551).
|
||||
- name: "ТЗ менялось после зелёного ревью ТЗ (#517)"
|
||||
id: spec_body
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
DIGEST: ${{ steps.material.outputs.issue_body }}
|
||||
run: |
|
||||
if [ -z "$DIGEST" ]; then
|
||||
echo "хеша тела нет — сравнивать не с чем"
|
||||
exit 0
|
||||
fi
|
||||
out=$(node -e '
|
||||
import("./scripts/review-doc-guard.mjs").then(async (m) => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const [num, digest] = process.argv.slice(1);
|
||||
const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } };
|
||||
const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n")
|
||||
.filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name));
|
||||
const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) }));
|
||||
const changed = m.issueBodyChanged(docs, digest);
|
||||
if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`);
|
||||
else process.stdout.write("changed=false\n");
|
||||
});
|
||||
' "$NUM" "$DIGEST")
|
||||
printf '%s\n' "$out"
|
||||
printf '%s\n' "$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Собрать контракт материала между стадиями
|
||||
id: prepared
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.material.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.material.outputs.specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }}
|
||||
VALIDATE_RESULT: ${{ steps.gate.outputs.result }}
|
||||
VALIDATE_URL: ${{ steps.gate.outputs.url }}
|
||||
REBASE_NOTE: ${{ steps.rebase.outputs.note }}
|
||||
VALIDATED_NOTE: ${{ steps.validated.outputs.note }}
|
||||
SPEC_BODY_CHANGED: ${{ steps.spec_body.outputs.changed }}
|
||||
SPEC_BODY_DOC: ${{ steps.spec_body.outputs.doc }}
|
||||
SPEC_BODY_RECORDED: ${{ steps.spec_body.outputs.recorded }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-prepared"
|
||||
mkdir -p "$dir"
|
||||
jq -n -S \
|
||||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
|
||||
--arg branch "$BRANCH" --arg material_sha "$MATERIAL_SHA" \
|
||||
--arg material_tree "$MATERIAL_TREE" --arg material_specs "$MATERIAL_SPECS" \
|
||||
--arg material_issue_body "$MATERIAL_ISSUE_BODY" \
|
||||
--arg validate_result "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
|
||||
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
|
||||
--arg spec_body_changed "$SPEC_BODY_CHANGED" --arg spec_body_doc "$SPEC_BODY_DOC" \
|
||||
--arg spec_body_recorded "$SPEC_BODY_RECORDED" \
|
||||
'{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,cycle:$cycle,branch:$branch,material_sha:$material_sha,material_tree:$material_tree,material_specs:$material_specs,material_issue_body:$material_issue_body,validate_result:$validate_result,validate_url:$validate_url,rebase_note:$rebase_note,validated_note:$validated_note,spec_body_changed:$spec_body_changed,spec_body_doc:$spec_body_doc,spec_body_recorded:$spec_body_recorded}' \
|
||||
> "$dir/prepared.json"
|
||||
(cd "$dir" && sha256sum prepared.json > manifest.sha256)
|
||||
echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Передать подтверждённый материал модели
|
||||
if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ steps.prepared.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/review-prepared
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Зафиксировать длительность подготовки
|
||||
id: duration
|
||||
if: always()
|
||||
env:
|
||||
STARTED: ${{ steps.clock.outputs.started }}
|
||||
run: |
|
||||
seconds=$(( $(date +%s) - STARTED ))
|
||||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||||
echo "- deterministic prerequisites: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
model_review:
|
||||
name: "Ревью: работа модели"
|
||||
# Единственная недоверенная стадия: модель читает материал и кладёт
|
||||
# запечатанный artifact. Писать в issue и в репозиторий ей незачем — это
|
||||
# делает `integrate`, проверив происхождение: в репозиторий модель не пишет
|
||||
# вообще, а в issue — только своим комментарием и отдельным issue по §12.
|
||||
#
|
||||
# Права здесь реальны только вместе с `github_token` у шага Review. Без него
|
||||
# claude-code-action меняет OIDC на собственный App-токен с дефолтом
|
||||
# `contents/issues/pull_requests: write` (`src/github/token.ts`), и блок ниже
|
||||
# не ограничивает ничего: токен `ghs_…` от claude[bot] лежит прямо в
|
||||
# окружении Bash-инструмента модели — это поймало ревью r1 по #556 в
|
||||
# собственной же сессии. С переданным `secrets.GITHUB_TOKEN` обмена не
|
||||
# происходит, `id-token` больше не нужен, и этот список становится потолком.
|
||||
#
|
||||
# `issues: write` остаётся: процесс требует от ревьюера комментарий с
|
||||
# вердиктом (§7.2) и отдельный issue на Medium вне скоупа (§12). Снять его
|
||||
# можно только перенеся и то и другое в `integrate` — это отдельная правка
|
||||
# конвейера, не эта задача. Что остаётся модели этим правом: комментарий,
|
||||
# метки, правка тела issue. Чего не остаётся: запись в репозиторий, слияние
|
||||
# (его решает запечатанный verdict.json в `integrate`), релиз.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
needs: [guard, prepare]
|
||||
if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Весь бюджет принадлежит модели и её локальным проверкам; ожидания Validate
|
||||
# в этом job больше нет (#551).
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
duration_seconds: ${{ steps.duration.outputs.seconds }}
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.prepare.outputs.material_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Получить контракт подготовленного материала
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: review-prepared-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/review-prepared
|
||||
|
||||
- name: Проверить контракт и exact material
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||||
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
|
||||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||||
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
|
||||
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
|
||||
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
|
||||
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
|
||||
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/review-prepared"
|
||||
(cd "$dir" && sha256sum -c manifest.sha256)
|
||||
jq -e \
|
||||
--arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \
|
||||
--arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \
|
||||
--arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \
|
||||
--arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \
|
||||
--arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \
|
||||
--arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \
|
||||
--arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \
|
||||
--arg spec_recorded "$SPEC_BODY_RECORDED" \
|
||||
'.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \
|
||||
"$dir/prepared.json"
|
||||
test "$(git rev-parse HEAD)" = "$MATERIAL_SHA"
|
||||
test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE"
|
||||
|
||||
# Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог
|
||||
# измениться именно в ней, и установка по копии из dev дала бы не то дерево.
|
||||
- name: Установить зависимости
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: npm ci
|
||||
|
||||
# Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте),
|
||||
# но когда нужен — качать его заново дороже, чем держать в кэше.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Установить Chromium
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true'
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
# Без --with-deps: системные библиотеки Chromium предустановлены в
|
||||
# образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета
|
||||
# ревью и подолгу перебирал недоступное azure-зеркало (#175). Если
|
||||
@@ -542,7 +860,6 @@ jobs:
|
||||
# скачан в _actions к началу job), контрольную сумму — из манифеста релиза.
|
||||
- name: Установить Claude Code детерминированно
|
||||
id: claude_bin
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
run: |
|
||||
src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1)
|
||||
ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
@@ -559,8 +876,7 @@ jobs:
|
||||
|
||||
- name: Review
|
||||
id: review
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
uses: anthropics/claude-code-action@v1
|
||||
uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1
|
||||
env:
|
||||
# Вне рабочей копии: восстановление дерева ревьюером не должно
|
||||
# уничтожать его собственный артефакт (#220).
|
||||
@@ -569,6 +885,11 @@ jobs:
|
||||
# Подписка, а не отдельный счёт API: токен выпускается через
|
||||
# `claude setup-token` (Pro/Max). Действуют лимиты подписки.
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Ambient job-scoped токен вместо App-обмена (#556). Со ним
|
||||
# `permissions:` этой job — настоящий потолок прав модели: ни записи в
|
||||
# репозиторий, ни постановки метки, ни комментария. Строку нельзя
|
||||
# снять, не вернув модели право двигать процесс.
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }}
|
||||
prompt: |
|
||||
Ты ревьюер проекта House Plan. Язык ответа — русский.
|
||||
@@ -584,7 +905,9 @@ jobs:
|
||||
Номер захода нужен для имени документа — два документа с
|
||||
одинаковым номером затёрли бы друг друга.
|
||||
|
||||
${{ steps.rebase.outputs.note }}
|
||||
${{ needs.prepare.outputs.rebase_note }}
|
||||
|
||||
${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }}
|
||||
|
||||
**Если цикл не первый — объём разбора по дельте, а не заново**
|
||||
(PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для
|
||||
@@ -631,8 +954,9 @@ jobs:
|
||||
LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md,
|
||||
CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md.
|
||||
|
||||
Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и
|
||||
файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/<NN>-*.md.
|
||||
Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл.
|
||||
Файлы docs/specs/<NN>-*.md — архив ТЗ до 2026-09-10: если такой файл
|
||||
есть у старой задачи, он и есть материал, новые не создаются.
|
||||
Проверь обязательные разделы §7.1, однозначность каждого AC и
|
||||
указание способа доказательства. Отдельно проверь, что автор не
|
||||
выдал догадку за решение: утверждение о поведении, которого нет ни
|
||||
@@ -646,7 +970,7 @@ jobs:
|
||||
|
||||
Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD`
|
||||
и `git diff origin/dev...HEAD`. **Материал ревью — ровно
|
||||
`${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не
|
||||
`${{ needs.prepare.outputs.material_sha }}`, рабочая копия уже на нём.** Не
|
||||
делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт
|
||||
привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с
|
||||
ним. Если автор в issue называет более новый коммит, которого в
|
||||
@@ -666,7 +990,7 @@ jobs:
|
||||
правке — не тщательность, а потеря времени: полные наборы это
|
||||
предрелизный гейт (PROCESS.md §8), а не гейт ревью.
|
||||
|
||||
${{ steps.validated.outputs.note }}
|
||||
${{ needs.prepare.outputs.validated_note }}
|
||||
|
||||
Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые,
|
||||
и в повторном раунде тоже: код изменился, а стоят они минуты:
|
||||
@@ -780,23 +1104,167 @@ jobs:
|
||||
--allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read
|
||||
--json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}'
|
||||
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
- name: Запечатать результат модели
|
||||
id: result
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-document.md
|
||||
MATERIAL_SHA: ${{ steps.branch.outputs.sha }}
|
||||
MATERIAL_TREE: ${{ steps.branch.outputs.tree }}
|
||||
MATERIAL_SPECS: ${{ steps.branch.outputs.specs }}
|
||||
run: |
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
legacy="docs/reviews/${marker}-${NUM}-r${CYCLE}.md"
|
||||
if [ ! -f "$SOURCE" ] && [ -f "$legacy" ]; then cp "$legacy" "$SOURCE"; fi
|
||||
test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; }
|
||||
printf '%s' "$OUT" > "$RUNNER_TEMP/verdict.json"
|
||||
jq -e '
|
||||
(.verdict == "green" or .verdict == "yellow" or .verdict == "red")
|
||||
and (.high | type == "number") and (.medium | type == "number")
|
||||
and (.summary | type == "string")' "$RUNNER_TEMP/verdict.json" >/dev/null
|
||||
dir="$RUNNER_TEMP/review-result"
|
||||
mkdir -p "$dir"
|
||||
cp "$RUNNER_TEMP/review-prepared/prepared.json" "$dir/prepared.json"
|
||||
cp "$SOURCE" "$dir/review-document.md"
|
||||
cp "$RUNNER_TEMP/verdict.json" "$dir/verdict.json"
|
||||
(cd "$dir" && sha256sum prepared.json review-document.md verdict.json > manifest.sha256)
|
||||
echo "artifact=review-result-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Передать результат интеграции
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: ${{ steps.result.outputs.artifact }}
|
||||
path: ${{ runner.temp }}/review-result
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Зафиксировать длительность модели
|
||||
id: duration
|
||||
if: always()
|
||||
env:
|
||||
STARTED: ${{ steps.clock.outputs.started }}
|
||||
run: |
|
||||
seconds=$(( $(date +%s) - STARTED ))
|
||||
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
|
||||
echo "- model review: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
integrate:
|
||||
name: "Ревью: публикация и интеграция"
|
||||
# Публикует разбор, переставляет метку, сливает проверенный материал.
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
needs: [guard, prepare, model_review]
|
||||
if: always() && needs.guard.outputs.stage != ''
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: process-issue-${{ github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
# Слияние кандидата при ушедшем dev может само ждать Validate до 45 минут;
|
||||
# оно не должно обрывать уже оплаченный model review (#551).
|
||||
timeout-minutes: 55
|
||||
steps:
|
||||
- name: Начать измерение стадии
|
||||
id: clock
|
||||
run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Проверить исходы предыдущих стадий
|
||||
id: ready
|
||||
env:
|
||||
PREPARE_RESULT: ${{ needs.prepare.result }}
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
run: |
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PROCEED" != "true" ]; then
|
||||
echo "подготовка уже вернула задачу автору; интегрировать нечего"
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
echo "::error::стадия model review завершилась: $MODEL_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Получить результат модели
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/review-result
|
||||
|
||||
- name: Проверить полноту и происхождение результата
|
||||
id: result
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
# #556: единственное, что недоверенная стадия модели может передать
|
||||
# дальше, — этот artifact, и принимается он как ввод противника: полный
|
||||
# набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с
|
||||
# тем, что посчитала детерминированная `prepare`. Проверка вынесена из
|
||||
# inline-shell в `scripts/review-result-gate.mjs` ради враждебных
|
||||
# фикстур — в YAML её нельзя прогнать ни одним отрицательным случаем.
|
||||
env:
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||||
VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }}
|
||||
VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }}
|
||||
REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }}
|
||||
VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }}
|
||||
SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }}
|
||||
SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }}
|
||||
SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dir="$RUNNER_TEMP/review-result"
|
||||
node scripts/review-result-gate.mjs --dir="$dir"
|
||||
printf 'structured_output<<EOF_RESULT\n' >> "$GITHUB_OUTPUT"
|
||||
cat "$dir/verdict.json" >> "$GITHUB_OUTPUT"
|
||||
# structured_output не обязан оканчиваться LF: delimiter команды
|
||||
# GitHub должен начинаться с отдельной строки.
|
||||
printf '\nEOF_RESULT\n' >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит,
|
||||
# решает этот шаг, а не модель: всё остальное откатывается.
|
||||
- name: Опубликовать документ ревью
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
env:
|
||||
TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
SOURCE: ${{ runner.temp }}/review-result/review-document.md
|
||||
# После ребейза конвейером — якоря приведённого материала (#515).
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }}
|
||||
MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }}
|
||||
MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }}
|
||||
# Вердикт из structured_output попадает в блок якорей (#499): по нему
|
||||
# следующий заход решает, можно ли применить зелёный вердикт повторно.
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
OUT: ${{ steps.result.outputs.structured_output }}
|
||||
run: |
|
||||
verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true)
|
||||
high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true)
|
||||
@@ -855,6 +1323,7 @@ jobs:
|
||||
node scripts/review-doc-guard.mjs --anchor="$doc" \
|
||||
--sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \
|
||||
--branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \
|
||||
--issue-body="$MATERIAL_ISSUE_BODY" \
|
||||
--verdict="$verdict" --high="$high"
|
||||
else
|
||||
echo "::warning::$SOURCE не найден — документа для публикации нет"
|
||||
@@ -945,12 +1414,12 @@ jobs:
|
||||
# достижим там из необновлённой локальной ветки. Читателю отчёта от этого
|
||||
# пользы нет — он достанет только то, что есть на origin.
|
||||
- name: "Материал раунда воспроизводим (#413)"
|
||||
if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true'
|
||||
env:
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
CYCLE: ${{ needs.guard.outputs.cycle }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
run: |
|
||||
marker=CODE-REVIEW
|
||||
if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi
|
||||
@@ -962,14 +1431,14 @@ jobs:
|
||||
git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=-
|
||||
- name: Решение по вердикту
|
||||
id: decide
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
env:
|
||||
OUT: ${{ steps.review.outputs.structured_output }}
|
||||
OUT: ${{ steps.result.outputs.structured_output }}
|
||||
STAGE: ${{ needs.guard.outputs.stage }}
|
||||
REUSE: ${{ steps.reuse.outputs.reuse }}
|
||||
REUSE_DOC: ${{ steps.reuse.outputs.doc }}
|
||||
REUSE_ROUND: ${{ steps.reuse.outputs.round }}
|
||||
REUSE_TREE: ${{ steps.reuse.outputs.tree }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }}
|
||||
REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }}
|
||||
REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
@@ -1017,11 +1486,11 @@ jobs:
|
||||
# dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот
|
||||
# случится (#364).
|
||||
- name: dev ушёл вперёд, пока шло ревью
|
||||
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
|
||||
if: steps.ready.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL: ${{ needs.prepare.outputs.material_sha }}
|
||||
GREEN: ${{ steps.decide.outputs.green }}
|
||||
run: |
|
||||
git fetch -q origin dev
|
||||
@@ -1050,9 +1519,9 @@ jobs:
|
||||
env:
|
||||
HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
BRANCH: ${{ steps.branch.outputs.name }}
|
||||
BRANCH: ${{ needs.prepare.outputs.branch }}
|
||||
NUM: ${{ github.event.issue.number }}
|
||||
MATERIAL_SHA: ${{ steps.material.outputs.sha }}
|
||||
MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }}
|
||||
run: |
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::ветки задачи нет — сливать нечего"
|
||||
@@ -1063,7 +1532,7 @@ jobs:
|
||||
--issue="$NUM" --repo="${{ github.repository }}"
|
||||
|
||||
- name: Переставить метку
|
||||
if: steps.rebase.outputs.conflict != 'true'
|
||||
if: steps.ready.outputs.proceed == 'true'
|
||||
env:
|
||||
# Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится.
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
@@ -1080,18 +1549,51 @@ jobs:
|
||||
--add-label "$TO" --remove-label "$FROM"
|
||||
echo "$FROM -> $TO"
|
||||
|
||||
- name: Позвать владельца, если ревью упало
|
||||
- name: Сводка длительности стадий
|
||||
if: always()
|
||||
env:
|
||||
STARTED: ${{ steps.clock.outputs.started }}
|
||||
PREPARE_SECONDS: ${{ needs.prepare.outputs.duration_seconds }}
|
||||
MODEL_SECONDS: ${{ needs.model_review.outputs.duration_seconds }}
|
||||
run: |
|
||||
integration=$(( $(date +%s) - STARTED ))
|
||||
{
|
||||
echo "## Бюджеты стадий (#551)"
|
||||
echo ""
|
||||
echo "| Стадия | Длительность | Лимит |"
|
||||
echo "|---|---:|---:|"
|
||||
echo "| deterministic prerequisites | ${PREPARE_SECONDS:-нет полного измерения}s | 55 min |"
|
||||
echo "| model review | ${MODEL_SECONDS:-не запускалась}s | 45 min |"
|
||||
echo "| publication/integration | ${integration}s | 55 min |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Позвать владельца, если стадия упала
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
PREPARE_RESULT: ${{ needs.prepare.result }}
|
||||
MODEL_RESULT: ${{ needs.model_review.result }}
|
||||
PROCEED: ${{ needs.prepare.outputs.proceed }}
|
||||
REUSE: ${{ needs.prepare.outputs.reuse }}
|
||||
run: |
|
||||
stage="публикация/интеграция"
|
||||
detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён."
|
||||
if [ "$PREPARE_RESULT" != "success" ]; then
|
||||
stage="deterministic prerequisites"
|
||||
detail="Модель не запускалась, цикл ревью не израсходован."
|
||||
elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then
|
||||
stage="model review"
|
||||
detail="Полного валидного результата модели нет; метка не менялась."
|
||||
fi
|
||||
# Тело через heredoc, а не многострочный --body: строка с нулевым
|
||||
# отступом обрывает блок YAML и оставляет незакрытую кавычку.
|
||||
cat > /tmp/failure.md <<EOF
|
||||
Автоматическое ревью не отработало: [прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||||
Автоматическое ревью не отработало: стадия **$stage** остановилась. [Прогон]($RUN_URL). Статусная метка не менялась, задача осталась на месте.
|
||||
|
||||
Если вердикт выше всё же опубликован — сбой произошёл после него. Перестановку метки в этом случае выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||||
$detail
|
||||
|
||||
Если вердикт выше всё же опубликован — перестановку метки выполняет чат обслуживания или владелец, но не автор задачи: автор не толкует вердикт о своей же работе.
|
||||
EOF
|
||||
gh issue comment "${{ github.event.issue.number }}" \
|
||||
--repo "${{ github.repository }}" --body-file /tmp/failure.md
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
issues: read
|
||||
|
||||
concurrency:
|
||||
group: publish-prerelease-${{ inputs.tag }}
|
||||
@@ -25,13 +26,13 @@ jobs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- name: Pin the current dev candidate
|
||||
- name: Pin the dev candidate or the existing annotated tag
|
||||
id: candidate
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
@@ -42,12 +43,23 @@ jobs:
|
||||
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
|
||||
exit 1
|
||||
}
|
||||
SHA=$(git rev-parse HEAD)
|
||||
git fetch origin dev
|
||||
test "$(git rev-parse origin/dev)" = "$SHA" || {
|
||||
echo "::error::The dispatched SHA is no longer the origin/dev tip"
|
||||
exit 1
|
||||
}
|
||||
DISPATCHED_SHA=$(git rev-parse HEAD)
|
||||
git fetch --force origin dev --tags
|
||||
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
||||
if [ -n "$REMOTE" ]; then
|
||||
SHA=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
||||
test -n "$SHA" || {
|
||||
echo "::error::Existing remote tag $TAG is not annotated"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
SHA=$DISPATCHED_SHA
|
||||
test "$(git rev-parse origin/dev)" = "$SHA" || {
|
||||
echo "::error::The dispatched SHA is no longer the origin/dev tip"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
git checkout --detach "$SHA"
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify version, changelogs and bilingual release notes
|
||||
@@ -77,6 +89,35 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
- name: Bind issue membership to the exact candidate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.candidate.outputs.tag }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release-membership
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--dir release-membership --pattern RELEASE-MEMBERSHIP.json --clobber || true
|
||||
fi
|
||||
if [ -s release-membership/RELEASE-MEMBERSHIP.json ]; then
|
||||
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
|
||||
--input=release-membership/RELEASE-MEMBERSHIP.json
|
||||
else
|
||||
ISSUES=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--label S8-merged --limit 1000 --json number --jq 'map(.number)|join(",")')
|
||||
node scripts/release-membership.mjs create --tag="$TAG" --candidate="$SHA" \
|
||||
--issues="$ISSUES" --allow-unmatched \
|
||||
--output=release-membership/RELEASE-MEMBERSHIP.json
|
||||
fi
|
||||
- name: Preserve candidate membership for publication
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-membership
|
||||
path: release-membership/RELEASE-MEMBERSHIP.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Публикация тега и релиза
|
||||
@@ -86,15 +127,20 @@ jobs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: release-membership
|
||||
path: release-assets
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
@@ -103,12 +149,20 @@ jobs:
|
||||
npm run bundle:budget
|
||||
VERSION=${TAG#v}
|
||||
grep -RFq "$VERSION" dist
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
# #540: тот же способ, что у release.yml и release-prerelease.mjs —
|
||||
# архив закоммиченного дерева точного коммита, детерминированный.
|
||||
git -c core.autocrlf=false archive --format=zip --output=houseplan.zip \
|
||||
"$SHA:custom_components/houseplan"
|
||||
node scripts/verify-houseplan-zip.mjs houseplan.zip \
|
||||
custom_components/houseplan/frontend "$VERSION"
|
||||
test -s dist/houseplan-card.js
|
||||
test -s dist/houseplan-panel.js
|
||||
test -s houseplan.zip
|
||||
mkdir -p release-assets
|
||||
cp dist/houseplan-card.js houseplan.zip release-assets/
|
||||
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
|
||||
--input=release-assets/RELEASE-MEMBERSHIP.json
|
||||
node scripts/release-assets.mjs sums release-assets --include-membership
|
||||
- name: Create or verify the annotated tag
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
@@ -147,7 +201,22 @@ jobs:
|
||||
fi
|
||||
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
|
||||
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
|
||||
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
|
||||
if [ "$WAS_DRAFT" = "false" ]; then
|
||||
mkdir -p existing-public
|
||||
if gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir existing-public \
|
||||
--pattern houseplan-card.js --pattern houseplan.zip \
|
||||
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber \
|
||||
&& diff -u release-assets/SHA256SUMS existing-public/SHA256SUMS \
|
||||
&& node scripts/release-assets.mjs check existing-public release-assets/SHA256SUMS \
|
||||
&& node scripts/release-membership.mjs verify --tag="$TAG" --candidate="${{ needs.gate.outputs.sha }}" \
|
||||
--input=existing-public/RELEASE-MEMBERSHIP.json; then
|
||||
echo "release is already public and byte-identical; publication skipped"
|
||||
exit 0
|
||||
fi
|
||||
echo "existing public assets need recovery; verified files will be uploaded again"
|
||||
fi
|
||||
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
|
||||
release-assets/RELEASE-MEMBERSHIP.json release-assets/SHA256SUMS \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
@@ -156,7 +225,7 @@ jobs:
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
@@ -178,17 +247,26 @@ jobs:
|
||||
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
|
||||
throw new Error('release is not a public prerelease for the requested tag');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip', 'RELEASE-MEMBERSHIP.json', 'SHA256SUMS']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
# #540: публичные байты — ровно те, что собраны и проверены выше.
|
||||
mkdir -p public
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
|
||||
--pattern houseplan-card.js --pattern houseplan.zip \
|
||||
--pattern RELEASE-MEMBERSHIP.json --pattern SHA256SUMS --clobber
|
||||
diff -u release-assets/SHA256SUMS public/SHA256SUMS
|
||||
node scripts/release-assets.mjs check public release-assets/SHA256SUMS
|
||||
node scripts/release-membership.mjs verify --tag="$TAG" --candidate="$SHA" \
|
||||
--input=public/RELEASE-MEMBERSHIP.json
|
||||
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
|
||||
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
|
||||
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n\n```\n%s```\n' \
|
||||
"$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Verify HACS prerelease discovery order
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
||||
with:
|
||||
@@ -206,26 +284,31 @@ jobs:
|
||||
);
|
||||
}
|
||||
|
||||
# PROCESS.md 10.2 item 10: closing issues and stripping status labels happens
|
||||
# because a beta was published, not because someone remembered to do it. The
|
||||
# manual step was skipped twice, and both times it broke the invariant that a
|
||||
# closed issue carries no status label — the one thing `verify` relies on.
|
||||
#
|
||||
# A manual step after a successful release is the worst kind: by the time it is
|
||||
# due, the work already looks finished, which is exactly why it gets forgotten.
|
||||
# #547: bookkeeping is driven by the immutable candidate manifest, not by the
|
||||
# mutable S8 queue. It also runs on a verified retry of an already-public beta.
|
||||
close-merged:
|
||||
name: Закрытие вошедших issue
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
# Deliberately the stock token, not a PAT: events caused by GITHUB_TOKEN do
|
||||
# not start workflows, so removing the label cannot wake the review
|
||||
# pipeline. A PAT here would build a cascade out of a bookkeeping step.
|
||||
issues: write
|
||||
steps:
|
||||
- name: Close the S8-merged queue and strip status labels
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: release-membership
|
||||
path: release-membership
|
||||
- name: Finish only the issues proven in the candidate manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
@@ -233,33 +316,9 @@ jobs:
|
||||
URL: ${{ needs.publish.outputs.url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Only the owner's issues take part in the process; issues filed by
|
||||
# anyone else never carry status labels and are not ours to close.
|
||||
numbers=$(gh issue list --repo "$REPO" --state open --label S8-merged \
|
||||
--author Matysh --limit 100 --json number --jq '.[].number')
|
||||
if [ -z "$numbers" ]; then
|
||||
echo "the S8-merged queue is empty, nothing to close"
|
||||
else
|
||||
for n in $numbers; do
|
||||
gh issue comment "$n" --repo "$REPO" \
|
||||
--body "Выпущено в \`$TAG\` · [релиз]($URL)"
|
||||
# Label first, then close. If the run dies between the two steps an
|
||||
# open issue without a status is visible and fixable in the flow;
|
||||
# the reverse order would recreate the exact breakage this job is
|
||||
# here to prevent.
|
||||
gh issue edit "$n" --repo "$REPO" --remove-label S8-merged
|
||||
gh issue close "$n" --repo "$REPO" --reason completed
|
||||
echo "closed #$n"
|
||||
done
|
||||
fi
|
||||
# Targeted at the defect that actually recurs, not at the invariant in
|
||||
# general: no closed issue may still carry S8-merged.
|
||||
leftover=$(gh issue list --repo "$REPO" --state closed --label S8-merged \
|
||||
--limit 100 --json number --jq 'length')
|
||||
test "$leftover" = "0" || {
|
||||
echo "::error::$leftover closed issues still carry S8-merged"
|
||||
exit 1
|
||||
}
|
||||
node scripts/release-bookkeeping.mjs --repo="$REPO" --tag="$TAG" \
|
||||
--candidate="${{ needs.gate.outputs.sha }}" --url="$URL" \
|
||||
--membership=release-membership/RELEASE-MEMBERSHIP.json
|
||||
|
||||
announce:
|
||||
name: Комментарий о публикации
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
name: HACS-zip к релизу
|
||||
# hacs.json declares zip_release + filename=houseplan.zip, so every release
|
||||
# (prereleases included) must carry the asset — HACS installs from it and
|
||||
# GitHub's public download counter becomes a free per-version install metric
|
||||
# (owner request, 2026-08-08). Like announce.yml, the workflow file lives at
|
||||
# the TAGGED commit: betas cut from dev pick it up as soon as this file is on
|
||||
# dev, stable tags once it reaches main.
|
||||
# workflow_dispatch lets us attach the zip to an EXISTING release (needed
|
||||
# once for the latest stable after the hacs.json change reaches main).
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing release tag to attach the zip to"
|
||||
required: true
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
zip:
|
||||
name: Собрать houseplan.zip и приложить к релизу
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
env:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: echo "tag=${EVENT_TAG:-$INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
- name: Build houseplan.zip (contents of custom_components/houseplan at zip root)
|
||||
run: cd custom_components/houseplan && zip -qr ../../houseplan.zip .
|
||||
- name: Sanity check
|
||||
run: node scripts/verify-houseplan-zip.mjs houseplan.zip custom_components/houseplan/frontend
|
||||
- name: Upload asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ steps.tag.outputs.tag }}" houseplan.zip --clobber --repo "$GITHUB_REPOSITORY"
|
||||
@@ -1,99 +1,455 @@
|
||||
name: "Релиз: ассеты после зелёной проверки"
|
||||
name: "Релиз: проверка, сборка и публикация ассетов"
|
||||
run-name: "Release ${{ inputs.tag || github.event.release.tag_name }}"
|
||||
|
||||
# #540: единственный путь, по которому установочные ассеты стабильного релиза
|
||||
# (`houseplan.zip` для HACS и `houseplan-card.js` для ручной установки) попадают
|
||||
# наружу. До этого публикаторов было четыре, и `release-zip.yml` выкладывал ZIP
|
||||
# в ту же секунду, когда релиз становился публичным, — до Validate, Full
|
||||
# Performance и E2E. Порядок теперь один: закрепить SHA → релиз в черновике →
|
||||
# гейты на этом SHA → одна сборка и `SHA256SUMS` → загрузка в черновик →
|
||||
# публикация → сверка публичных байтов с паспортом → анонс.
|
||||
#
|
||||
# Два входа, один порядок:
|
||||
# • `workflow_dispatch(tag)` — штатный выпуск и ремонт. Тега ещё нет — он
|
||||
# ставится на вершину ветки, с которой запущен workflow (main для
|
||||
# стабильного, dev для беты). Тег есть — берётся его коммит.
|
||||
# • `release: published` — человек опубликовал стабильный релиз руками.
|
||||
# Fail-closed: релиз немедленно возвращается в черновик и проходит тот же
|
||||
# путь; снаружи ничего установочного не остаётся, пока идут проверки.
|
||||
# Беты это событие пропускают — у них свой staged-путь
|
||||
# (`publish-prerelease.yml`, `release-prerelease.mjs`).
|
||||
#
|
||||
# Ремонт публичного релиза (dispatch на существующий тег): недостающие ассеты
|
||||
# догружаются только при зелёных гейтах; присутствующий ассет с другим хешем —
|
||||
# отказ без правок, публичные байты не подменяются молча.
|
||||
#
|
||||
# Событие `release` исполняет workflow с коммита тега: новая редакция файла
|
||||
# действует для стабильных тегов только после того, как она есть на main.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Exact release tag, for example v1.75.1; created on the dispatched branch tip when missing"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ inputs.tag || github.event.release.tag_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# AUD-159B7-02: publishing a GitHub Release used to BE the gate — this
|
||||
# workflow only built and uploaded, so an asset shipped while both Validate
|
||||
# runs for the very same commit were red. The asset now waits for a green
|
||||
# Validate of the EXACT commit the tag points at, and is withheld otherwise.
|
||||
#
|
||||
# Needs a push with a token that has the `workflow` scope (the ordinary
|
||||
# Personal Access Token used for `git push` refuses workflow file updates).
|
||||
candidate:
|
||||
name: "Кандидат: точный SHA, режим и черновик"
|
||||
# Публикация беты руками — не наш случай: у бет свой staged-путь.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || !github.event.release.prerelease }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.resolve.outputs.sha }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
prerelease: ${{ steps.resolve.outputs.prerelease }}
|
||||
mode: ${{ steps.release.outputs.mode }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Resolve the tag to its exact commit
|
||||
id: resolve
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
TAG: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || {
|
||||
echo "::error::$TAG is not a release tag (vX.Y.Z or vX.Y.Z-pre)"
|
||||
exit 1
|
||||
}
|
||||
VERSION=${TAG#v}
|
||||
case "$TAG" in *-*) PRERELEASE=true ;; *) PRERELEASE=false ;; esac
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null; then
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
# Peeled commit both for annotated and lightweight tags (a release
|
||||
# form makes lightweight ones). Neither target_commitish nor the
|
||||
# event SHA is trusted: the former may be a branch name.
|
||||
SHA=$(git rev-list -n 1 "refs/tags/$TAG")
|
||||
echo "tag $TAG exists → $SHA"
|
||||
else
|
||||
test "$EVENT" = "workflow_dispatch" || {
|
||||
echo "::error::release event for a tag that does not exist: $TAG"
|
||||
exit 1
|
||||
}
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "tag $TAG is new → dispatched branch tip $SHA"
|
||||
fi
|
||||
if [ "$PRERELEASE" = "false" ]; then
|
||||
git fetch origin main
|
||||
git merge-base --is-ancestor "$SHA" origin/main || {
|
||||
echo "::error::stable candidate $SHA is not on main"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
git fetch origin dev
|
||||
git merge-base --is-ancestor "$SHA" origin/dev || {
|
||||
echo "::error::prerelease candidate $SHA is not on dev"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
{
|
||||
echo "sha=$SHA"
|
||||
echo "tag=$TAG"
|
||||
echo "version=$VERSION"
|
||||
echo "prerelease=$PRERELEASE"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Take the release off the public surface until it is verified
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft > /tmp/is-draft 2>/dev/null; then
|
||||
echo "mode=fresh" >> "$GITHUB_OUTPUT"
|
||||
echo "no release for $TAG yet: it will be created as a draft after the gates"
|
||||
elif [ "$(cat /tmp/is-draft)" = "true" ]; then
|
||||
echo "mode=staged" >> "$GITHUB_OUTPUT"
|
||||
echo "release $TAG is a draft: staging into it"
|
||||
elif [ "$EVENT" = "release" ]; then
|
||||
# Published by hand: nothing here has been verified. Back to draft
|
||||
# first, gates second — the order is the whole point (#540).
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft
|
||||
echo "mode=event" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::$TAG was published by hand and is a draft again until the gates pass"
|
||||
else
|
||||
echo "mode=repair" >> "$GITHUB_OUTPUT"
|
||||
echo "release $TAG is public: repair mode — only missing assets may be added"
|
||||
fi
|
||||
|
||||
gate:
|
||||
name: "Гейт: зелёная Проверка точного SHA тега"
|
||||
name: "Гейт: контракт, Validate, Full Performance и E2E на точном SHA"
|
||||
needs: candidate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
ref: ${{ needs.candidate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
# #479: тяжёлые job Validate идут только на коммите с трейлером `Release:`;
|
||||
# без него зелёный Validate — прогон без смоков и golden. Трейлер обязан
|
||||
# называть ровно этот тег: кандидат сам объявляет, чем он выпускается.
|
||||
- name: Require the Release trailer naming this exact tag
|
||||
env:
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git log -1 --format=%B "$SHA" > /tmp/head-message.txt
|
||||
if ! grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' /tmp/head-message.txt; then
|
||||
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fxq "Release: $TAG" /tmp/head-message.txt; then
|
||||
echo "::error::$SHA declares $(grep -E '^Release:' /tmp/head-message.txt | head -1), not $TAG"
|
||||
exit 1
|
||||
fi
|
||||
- name: Verify version, changelogs and bilingual release notes
|
||||
env:
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
||||
else
|
||||
node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY" --stable
|
||||
fi
|
||||
- name: Require a green Validate for this exact commit
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# HEAD is the peeled commit even when TAG is annotated. Do not trust
|
||||
# target_commitish (it may be a branch name) or an event-context SHA.
|
||||
SHA=$(git rev-parse HEAD)
|
||||
echo "release tag: $TAG; exact commit: $SHA"
|
||||
# #479: тяжёлые job Validate идут только на коммите с трейлером
|
||||
# `Release:`; без него зелёный Validate прогона без смоков не доказывает.
|
||||
if ! git log -1 --format=%B "$SHA" | grep -Eq '^Release:[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::$SHA has no Release: trailer — Validate ran without the heavy gates (#479)"
|
||||
exit 1
|
||||
fi
|
||||
node scripts/release-gate.mjs "$SHA"
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
- name: Require full performance for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA=$(git rev-parse HEAD)
|
||||
node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
build:
|
||||
name: Сборка бандла и загрузка ассетов
|
||||
needs: gate
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA" --workflow=performance.yml --label="Полные бенчмарки производительности"
|
||||
# #514/#540: единственная проверка на настоящем Home Assistant. Раньше
|
||||
# houseplan-e2e ставил `houseplan.zip` из публичного релиза — то есть
|
||||
# релиз должен был быть публичным ДО проверки. Теперь он ставит дерево
|
||||
# `custom_components/houseplan` коммита-кандидата (tarball codeload),
|
||||
# а ZIP строится `git archive` из того же дерева: тождество «что
|
||||
# тестировали = что публикуем» — хеш дерева, он печатается на сборке.
|
||||
# Cross-repository dispatch needs a token with Actions: write on
|
||||
# houseplan-e2e; HP_PROCESS_TOKEN (classic, repo scope) has it,
|
||||
# E2E_DISPATCH_TOKEN is the fallback for a fine-grained token.
|
||||
- name: Require green E2E on a real Home Assistant for a stable release
|
||||
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
run: node scripts/e2e-gate.mjs --ref="$SHA" --tag="$TAG"
|
||||
|
||||
stage:
|
||||
name: "Сборка: ассеты, SHA256SUMS и загрузка в черновик"
|
||||
needs: [candidate, gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.release.tag_name }}
|
||||
- uses: actions/setup-node@v7
|
||||
ref: ${{ needs.candidate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- run: npm ci && npm run build
|
||||
- name: Build once and verify both installable assets
|
||||
id: build
|
||||
env:
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
VERSION: ${{ needs.candidate.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
npm run bundle:budget
|
||||
grep -RFq "$VERSION" dist
|
||||
test -s dist/houseplan-card.js
|
||||
test -s dist/houseplan-panel.js
|
||||
# The ZIP is the committed integration tree of the exact commit —
|
||||
# the same tree E2E installed from the codeload tarball. `git archive`
|
||||
# is deterministic for a commit, so a repair rebuilds identical bytes.
|
||||
git -c core.autocrlf=false archive --format=zip --output=houseplan.zip \
|
||||
"$SHA:custom_components/houseplan"
|
||||
node scripts/verify-houseplan-zip.mjs houseplan.zip \
|
||||
custom_components/houseplan/frontend "$VERSION"
|
||||
mkdir -p release-assets
|
||||
cp dist/houseplan-card.js houseplan.zip release-assets/
|
||||
node scripts/release-assets.mjs sums release-assets
|
||||
TREE=$(git rev-parse "$SHA:custom_components/houseplan")
|
||||
echo "tree=$TREE" >> "$GITHUB_OUTPUT"
|
||||
printf '### Staged assets for %s\n\n- exact commit: `%s`\n- `custom_components/houseplan` tree (what E2E installed): `%s`\n\n```\n%s```\n' \
|
||||
"$VERSION" "$SHA" "$TREE" "$(cat release-assets/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Verify compositor frame continuity for a stable release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
if: ${{ needs.candidate.outputs.prerelease != 'true' }}
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
node scripts/bundle-sync.mjs
|
||||
npm run continuity:screencast
|
||||
- name: Upload failed continuity frames
|
||||
if: ${{ failure() && !github.event.release.prerelease }}
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ failure() && needs.candidate.outputs.prerelease != 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: continuity-screencast
|
||||
path: artifacts/continuity-screencast
|
||||
- name: Verify the complete committed frontend tree
|
||||
run: |
|
||||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||||
test -s dist/houseplan-card.js
|
||||
test -s dist/houseplan-panel.js
|
||||
- name: Attach card to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
- name: Keep the passport for the publication step
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
files: dist/houseplan-card.js
|
||||
name: release-assets-${{ needs.candidate.outputs.tag }}
|
||||
path: release-assets/SHA256SUMS
|
||||
if-no-files-found: error
|
||||
# Also for a draft made in the release form: its tag may not exist yet,
|
||||
# and publishing such a draft would let GitHub tag target_commitish —
|
||||
# not necessarily the verified commit. The tag is pinned here, first.
|
||||
- name: Create or verify the tag at the exact commit
|
||||
env:
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
||||
if [ -n "$REMOTE" ]; then
|
||||
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
||||
test -n "$PEELED" || PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG" '$2 == ref {print $1}')
|
||||
test "$PEELED" = "$SHA" || {
|
||||
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" "$SHA" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
- name: Stage the verified assets into the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
MODE: ${{ needs.candidate.outputs.mode }}
|
||||
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$MODE" = "fresh" ]; then
|
||||
FLAG="--prerelease=false"
|
||||
if [ "$PRERELEASE" = "true" ]; then FLAG="--prerelease"; fi
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --draft "$FLAG" \
|
||||
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
fi
|
||||
if [ "$MODE" = "repair" ]; then
|
||||
# Public release: what is already outside must be the bytes we just
|
||||
# rebuilt; anything else is a finding, not a --clobber. Only missing
|
||||
# assets are added, and only now — after the gates.
|
||||
mkdir -p public
|
||||
for name in $(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name'); do
|
||||
case "$name" in
|
||||
houseplan-card.js|houseplan.zip|SHA256SUMS)
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public --pattern "$name" --clobber ;;
|
||||
esac
|
||||
done
|
||||
if [ -f public/SHA256SUMS ]; then
|
||||
diff -u public/SHA256SUMS release-assets/SHA256SUMS || {
|
||||
echo "::error::public SHA256SUMS of $TAG differ from the rebuilt assets"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
node scripts/release-assets.mjs check public release-assets/SHA256SUMS --allow-missing
|
||||
missing=""
|
||||
for name in houseplan-card.js houseplan.zip SHA256SUMS; do
|
||||
[ -f "public/$name" ] || missing="$missing release-assets/$name"
|
||||
done
|
||||
if [ -z "$missing" ]; then
|
||||
echo "nothing to repair: every asset of $TAG is present and matches"
|
||||
else
|
||||
echo "adding missing assets:$missing"
|
||||
# shellcheck disable=SC2086
|
||||
gh release upload "$TAG" $missing --repo "$GITHUB_REPOSITORY"
|
||||
fi
|
||||
else
|
||||
# Draft: whatever a hand-made publication put here was never
|
||||
# verified, so the verified bytes replace it.
|
||||
gh release upload "$TAG" release-assets/houseplan-card.js release-assets/houseplan.zip \
|
||||
release-assets/SHA256SUMS --repo "$GITHUB_REPOSITORY" --clobber
|
||||
fi
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json tagName,isDraft,assets)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
|
||||
publish:
|
||||
name: "Публикация и сверка публичных байтов"
|
||||
needs: [candidate, gate, stage]
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
name: ${{ steps.verify.outputs.name }}
|
||||
newly_published: ${{ steps.flip.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.candidate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: release-assets-${{ needs.candidate.outputs.tag }}
|
||||
path: passport
|
||||
- name: Publish the verified draft
|
||||
id: flip
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
MODE: ${{ needs.candidate.outputs.mode }}
|
||||
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$MODE" = "repair" ]; then
|
||||
echo "newly_published=false" >> "$GITHUB_OUTPUT"
|
||||
echo "repair of a public release: nothing to publish"
|
||||
exit 0
|
||||
fi
|
||||
FLAG="--prerelease=false"
|
||||
if [ "$PRERELEASE" = "true" ]; then FLAG="--prerelease"; fi
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false "$FLAG" \
|
||||
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
echo "newly_published=true" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify the public release against the passport
|
||||
id: verify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.candidate.outputs.tag }}
|
||||
SHA: ${{ needs.candidate.outputs.sha }}
|
||||
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,name,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG PRERELEASE
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG || release.isDraft) throw new Error('release is not public for the requested tag');
|
||||
if (String(release.isPrerelease) !== process.env.PRERELEASE) throw new Error('release prerelease flag does not match the tag');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip', 'SHA256SUMS']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
# The bytes anyone downloads now are the bytes the gates saw.
|
||||
mkdir -p public
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir public \
|
||||
--pattern houseplan-card.js --pattern houseplan.zip --pattern SHA256SUMS --clobber
|
||||
diff -u passport/SHA256SUMS public/SHA256SUMS
|
||||
node scripts/release-assets.mjs check public passport/SHA256SUMS
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test "$(git rev-list -n 1 "refs/tags/$TAG")" = "$SHA"
|
||||
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
|
||||
NAME=$(node -p "JSON.parse(process.env.RELEASE_JSON).name || process.env.TAG")
|
||||
{
|
||||
echo "url=$URL"
|
||||
echo "name=$NAME"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub release](%s)\n\n```\n%s```\n' \
|
||||
"$TAG" "$SHA" "$URL" "$(cat public/SHA256SUMS)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
announce:
|
||||
# #538: анонс — последнее звено, а не параллельное. Пока он висел на самом
|
||||
# событии `release: published`, он обгонял гейт: 12.09 v1.75.0 объявили в
|
||||
# канале в ту же минуту, когда проверка отказала выкладывать ассеты.
|
||||
# `needs: publish` означает, что молчание — это тоже ответ: красный гейт или
|
||||
# несостоявшаяся выкладка сообщения не рождают. Ремонт не анонсируется.
|
||||
name: Оповещение о релизе после выкладки
|
||||
needs: [candidate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
with:
|
||||
reusable: true
|
||||
tag: ${{ needs.candidate.outputs.tag }}
|
||||
release_name: ${{ needs.publish.outputs.name }}
|
||||
url: ${{ needs.publish.outputs.url }}
|
||||
prerelease: ${{ needs.candidate.outputs.prerelease == 'true' }}
|
||||
ref: ${{ needs.candidate.outputs.tag }}
|
||||
secrets: inherit
|
||||
|
||||
hacs-discovery:
|
||||
name: HACS-видимость пре-релиза (порядок бет)
|
||||
# HACS 2.0.x takes the first prerelease in GitHub's response instead of
|
||||
# sorting SemVer. A valid asset can therefore be invisible to beta users
|
||||
# (beta.10 appeared after beta.9). Keep the release asset, but
|
||||
# make that distribution failure impossible to miss in the release run.
|
||||
if: ${{ github.event.release.prerelease }}
|
||||
needs: build
|
||||
if: ${{ needs.candidate.outputs.prerelease == 'true' }}
|
||||
needs: [candidate, publish]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify the published tag is the prerelease HACS will discover
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.candidate.outputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
@@ -102,7 +458,7 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((r) => r.prerelease && !r.draft);
|
||||
const expected = context.payload.release.tag_name;
|
||||
const expected = process.env.EXPECTED_TAG;
|
||||
if (first?.tag_name !== expected) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name ?? 'none'} before ${expected}. ` +
|
||||
|
||||
@@ -18,9 +18,15 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
full:
|
||||
description: 'Полный набор: смоки, golden, performance_smoke'
|
||||
description: 'Полный набор: смоки, golden, performance_smoke, мутанты по диффу'
|
||||
type: boolean
|
||||
default: true
|
||||
# Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние
|
||||
# кандидата запускают `-f full=false -f mutants=true` на материале.
|
||||
mutants:
|
||||
description: 'Мутанты по диффу на этом SHA (кандидат ревью)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||||
# Exact-SHA release gates never depend on an obsolete commit.
|
||||
@@ -41,7 +47,7 @@ jobs:
|
||||
# запись «job независимый: краснеет сам и не роняет остальные» продолжает
|
||||
# действовать — на уровне шагов, с той же гранулярностью в логе.
|
||||
preflight:
|
||||
name: "Предполётные проверки: документация, провенанс, процесс"
|
||||
name: "Предполёт: документация, провенанс, процесс"
|
||||
runs-on: ubuntu-latest
|
||||
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
|
||||
# проверка 8 процессного гейта. Права перечислены явно, потому что job
|
||||
@@ -59,9 +65,9 @@ jobs:
|
||||
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
|
||||
# работают как раньше. Единственная догрузка по требованию здесь —
|
||||
# `git show origin/main:.github/workflows/process.yml`: один блоб.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
|
||||
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
|
||||
@@ -169,12 +175,22 @@ jobs:
|
||||
# Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе
|
||||
# слияние job стоило бы автору лишнего круга «починил одно — узнал о
|
||||
# втором».
|
||||
# #556: перемещаемая ссылка в `uses:` — доверие чужому владельцу тега
|
||||
# здесь и сейчас, а не коду, который читали. Проверка стоит в preflight:
|
||||
# она стоит миллисекунды и ловит правку, которая иначе доехала бы до
|
||||
# привилегированной стадии.
|
||||
- name: "Сторонние Actions закреплены полным SHA"
|
||||
id: action_pins
|
||||
continue-on-error: true
|
||||
run: node scripts/action-pins.mjs
|
||||
|
||||
- name: Вердикт предполётных проверок
|
||||
env:
|
||||
DOCS: ${{ steps.docs.outcome }}
|
||||
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
|
||||
PROVENANCE: ${{ steps.provenance.outcome }}
|
||||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||||
ACTION_PINS: ${{ steps.action_pins.outcome }}
|
||||
run: |
|
||||
fail=0
|
||||
check() {
|
||||
@@ -191,6 +207,7 @@ jobs:
|
||||
check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC"
|
||||
check "провенанс коммитов" "$PROVENANCE"
|
||||
check "процессный гейт" "$PROCESS_GATE"
|
||||
check "пины сторонних Actions" "$ACTION_PINS"
|
||||
exit $fail
|
||||
|
||||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||||
@@ -210,6 +227,7 @@ jobs:
|
||||
actions: read
|
||||
outputs:
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
geometry_parity: ${{ steps.classify.outputs.geometry_parity }}
|
||||
backend: ${{ steps.classify.outputs.backend }}
|
||||
integration: ${{ steps.classify.outputs.integration }}
|
||||
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
|
||||
@@ -218,15 +236,15 @@ jobs:
|
||||
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
|
||||
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
|
||||
heavy: ${{ steps.heavy.outputs.heavy }}
|
||||
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
|
||||
base: ${{ steps.base.outputs.base }}
|
||||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||||
# классифицировать файлы ветки (#387), `range_base` — от какого судить
|
||||
# диапазон на dev (#388): общее имя — чужая база у потребителя.
|
||||
# `base` — до какого коммита классифицировать файлы ветки (#387),
|
||||
# `range_base` — от какого судить диапазон на dev (#388).
|
||||
range_base: ${{ steps.base.outputs.range_base }}
|
||||
steps:
|
||||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
|
||||
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
|
||||
@@ -241,6 +259,7 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||||
FULL_INPUT: ${{ inputs.full }}
|
||||
MUTANTS_INPUT: ${{ inputs.mutants }}
|
||||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||||
- id: base
|
||||
if: github.event_name != 'pull_request'
|
||||
@@ -363,15 +382,32 @@ jobs:
|
||||
smoke: ${{ steps.probe.outputs.smoke }}
|
||||
golden: ${{ steps.probe.outputs.golden }}
|
||||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||||
geometry_parity: ${{ steps.probe.outputs.geometry_parity }}
|
||||
backend: ${{ steps.probe.outputs.backend }}
|
||||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||||
golden_key: ${{ steps.keys.outputs.golden }}
|
||||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||||
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
|
||||
geometry_parity_key: ${{ steps.keys.outputs.geometry_parity }}
|
||||
backend_key: ${{ steps.keys.outputs.backend }}
|
||||
smoke_source_run: ${{ steps.p_smoke.outputs.source_run }}
|
||||
smoke_source_attempt: ${{ steps.p_smoke.outputs.source_attempt }}
|
||||
smoke_source_sha: ${{ steps.p_smoke.outputs.source_sha }}
|
||||
golden_source_run: ${{ steps.p_golden.outputs.source_run }}
|
||||
golden_source_attempt: ${{ steps.p_golden.outputs.source_attempt }}
|
||||
golden_source_sha: ${{ steps.p_golden.outputs.source_sha }}
|
||||
performance_smoke_source_run: ${{ steps.p_perf.outputs.source_run }}
|
||||
performance_smoke_source_attempt: ${{ steps.p_perf.outputs.source_attempt }}
|
||||
performance_smoke_source_sha: ${{ steps.p_perf.outputs.source_sha }}
|
||||
geometry_parity_source_run: ${{ steps.p_geometry_parity.outputs.source_run }}
|
||||
geometry_parity_source_attempt: ${{ steps.p_geometry_parity.outputs.source_attempt }}
|
||||
geometry_parity_source_sha: ${{ steps.p_geometry_parity.outputs.source_sha }}
|
||||
backend_source_run: ${{ steps.p_backend.outputs.source_run }}
|
||||
backend_source_attempt: ${{ steps.p_backend.outputs.source_attempt }}
|
||||
backend_source_sha: ${{ steps.p_backend.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- name: Ключи переиспользования
|
||||
id: keys
|
||||
@@ -379,7 +415,7 @@ jobs:
|
||||
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
|
||||
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
|
||||
run: |
|
||||
for job in smoke golden performance_smoke backend; do
|
||||
for job in smoke golden performance_smoke geometry_parity backend; do
|
||||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||||
echo "$job: $key"
|
||||
@@ -390,42 +426,81 @@ jobs:
|
||||
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
|
||||
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
|
||||
echo "performance_smoke set: $set"
|
||||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||||
# Маркер восстанавливается во временный файл: #541 требует не только
|
||||
# cache-hit, но и SHA/run исходного успешного job. Между lookup файл
|
||||
# удаляется, чтобы один маркер не был принят за другой.
|
||||
- name: Маркер smoke
|
||||
id: m_smoke
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||||
lookup-only: true
|
||||
- name: Доказательство источника smoke
|
||||
id: p_smoke
|
||||
if: steps.m_smoke.outputs.cache-hit == 'true'
|
||||
run: node scripts/ci-proof.mjs --marker=.reuse-marker
|
||||
- name: Очистить маркер smoke перед следующим lookup
|
||||
if: always()
|
||||
run: rm -f .reuse-marker
|
||||
- name: Маркер golden
|
||||
id: m_golden
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||||
lookup-only: true
|
||||
- name: Доказательство источника golden
|
||||
id: p_golden
|
||||
if: steps.m_golden.outputs.cache-hit == 'true'
|
||||
run: node scripts/ci-proof.mjs --marker=.reuse-marker
|
||||
- name: Очистить маркер golden перед следующим lookup
|
||||
if: always()
|
||||
run: rm -f .reuse-marker
|
||||
- name: Маркер performance_smoke
|
||||
id: m_perf
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
|
||||
lookup-only: true
|
||||
- name: Доказательство источника performance_smoke
|
||||
id: p_perf
|
||||
if: steps.m_perf.outputs.cache-hit == 'true'
|
||||
run: node scripts/ci-proof.mjs --marker=.reuse-marker
|
||||
- name: Очистить маркер performance_smoke перед следующим lookup
|
||||
if: always()
|
||||
run: rm -f .reuse-marker
|
||||
- name: Маркер geometry_parity
|
||||
id: m_geometry_parity
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-geometry_parity-${{ steps.keys.outputs.geometry_parity }}
|
||||
- name: Доказательство источника geometry_parity
|
||||
id: p_geometry_parity
|
||||
if: steps.m_geometry_parity.outputs.cache-hit == 'true'
|
||||
run: node scripts/ci-proof.mjs --marker=.reuse-marker
|
||||
- name: Очистить маркер geometry_parity перед следующим lookup
|
||||
if: always()
|
||||
run: rm -f .reuse-marker
|
||||
- name: Маркер backend
|
||||
id: m_backend
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||||
lookup-only: true
|
||||
- name: Доказательство источника backend
|
||||
id: p_backend
|
||||
if: steps.m_backend.outputs.cache-hit == 'true'
|
||||
run: node scripts/ci-proof.mjs --marker=.reuse-marker
|
||||
- name: Очистить маркер backend
|
||||
if: always()
|
||||
run: rm -f .reuse-marker
|
||||
- name: Что переиспользуем
|
||||
id: probe
|
||||
env:
|
||||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||||
GEOMETRY_PARITY: ${{ steps.m_geometry_parity.outputs.cache-hit }}
|
||||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||||
run: |
|
||||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||||
@@ -444,6 +519,7 @@ jobs:
|
||||
waive smoke "$SMOKE"
|
||||
waive golden "$GOLDEN"
|
||||
waive performance_smoke "$PERF"
|
||||
waive geometry_parity "$GEOMETRY_PARITY"
|
||||
waive backend "$BACKEND"
|
||||
|
||||
hacs:
|
||||
@@ -452,9 +528,9 @@ jobs:
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: HACS validation
|
||||
uses: hacs/action@main
|
||||
uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa # main@2026-09-13
|
||||
with:
|
||||
category: integration
|
||||
|
||||
@@ -464,9 +540,9 @@ jobs:
|
||||
if: needs.changes.outputs.integration == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
uses: home-assistant/actions/hassfest@58bff37c8947f690ace498be413a9b78d6f30f93 # master@2026-09-13
|
||||
|
||||
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
|
||||
# файл, который он патчит, либо изменился его гард. `--check` этого не
|
||||
@@ -476,46 +552,40 @@ jobs:
|
||||
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
|
||||
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
|
||||
# детерминированных непересекающихся шарда вместо ослабления проверки.
|
||||
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
|
||||
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
|
||||
# материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на
|
||||
# промежуточных пушах она стоила 48 из 56 часов job-минут и в основном
|
||||
# отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при
|
||||
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
|
||||
# её исход по job, и skipped был бы неотличим от «не запрашивали».
|
||||
changed_mutants:
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
|
||||
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
|
||||
if: needs.changes.outputs.mutants_requested == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
|
||||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||||
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
|
||||
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||||
# (файлы патча и гарда, без строки версии) повторно не гоняется.
|
||||
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
|
||||
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
|
||||
# пропуск станет известен только после установки окружения.
|
||||
- name: Журнал свидетелей (последний по шарду)
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
# Cache immutable: rerun с тем же run_id обязан получить новый key,
|
||||
@@ -524,7 +594,13 @@ jobs:
|
||||
restore-keys: |
|
||||
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
|
||||
mutation-ledger-${{ matrix.shard }}-
|
||||
- name: Затронутые мутанты ловятся
|
||||
# #518: план шарда считается до установки окружения. npm ci, Python с
|
||||
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
|
||||
# их платили даже тогда, когда гонять было нечего: правка задела чужой
|
||||
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
|
||||
# git и node из образа.
|
||||
- name: План шарда
|
||||
id: plan
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
@@ -546,11 +622,45 @@ jobs:
|
||||
fi
|
||||
if [ -z "$base" ]; then
|
||||
echo "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
|
||||
echo "count=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
|
||||
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
|
||||
echo "к прогону в этом шарде: ${count:-0}"
|
||||
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
run: npm ci
|
||||
- if: steps.plan.outputs.count != '0'
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Backend test dependencies
|
||||
if: steps.plan.outputs.count != '0'
|
||||
run: pip install -r tests_backend/requirements.txt
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
if: steps.plan.outputs.count != '0'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- name: Установить Chromium
|
||||
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
- name: Затронутые мутанты ловятся
|
||||
if: steps.plan.outputs.count != '0'
|
||||
env:
|
||||
BASE: ${{ steps.plan.outputs.base }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
|
||||
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||||
@@ -559,7 +669,7 @@ jobs:
|
||||
# попытку этого run либо самый новый журнал шарда.
|
||||
- name: Сохранить журнал свидетелей
|
||||
if: always()
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
@@ -572,9 +682,9 @@ jobs:
|
||||
steps:
|
||||
# Полная история без блобов (#342): гейт «новый код не добавляет any»
|
||||
# diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет.
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -632,7 +742,7 @@ jobs:
|
||||
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
|
||||
# дерева, и каждая браузерная job делает это перед первым кадром.
|
||||
- name: Выложить бандл для браузерных job
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist/
|
||||
@@ -642,7 +752,7 @@ jobs:
|
||||
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
|
||||
# входит в production-бандл и потому едет отдельным артефактом (#336).
|
||||
- name: Выложить тестовые модули для смоков
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: card-test-build
|
||||
path: test-build/
|
||||
@@ -664,8 +774,8 @@ jobs:
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -677,7 +787,7 @@ jobs:
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -688,12 +798,12 @@ jobs:
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
- name: Забрать тестовые модули
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: card-test-build
|
||||
path: test-build
|
||||
@@ -747,7 +857,7 @@ jobs:
|
||||
exit $fail
|
||||
- name: Upload smoke logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: smoke-logs-${{ matrix.shard }}
|
||||
path: /tmp/smoke-logs
|
||||
@@ -765,8 +875,9 @@ jobs:
|
||||
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
"попытка: ${{ github.run_attempt }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
@@ -783,8 +894,8 @@ jobs:
|
||||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -796,7 +907,7 @@ jobs:
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -807,7 +918,7 @@ jobs:
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
@@ -817,7 +928,7 @@ jobs:
|
||||
# прогона, потому что после падения шаги кэша уже не помогут отличить
|
||||
# виновника от свидетеля.
|
||||
- name: Прежнее падение на тех же входах
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .fail-marker
|
||||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
@@ -833,7 +944,7 @@ jobs:
|
||||
fi
|
||||
- name: Upload golden candidates/diffs
|
||||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: golden-images
|
||||
path: artifacts/golden
|
||||
@@ -852,7 +963,7 @@ jobs:
|
||||
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
|
||||
# это тем более не отказ гейта — job и так красная по существу.
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .fail-marker
|
||||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||||
@@ -863,8 +974,9 @@ jobs:
|
||||
printf '%s\n' "golden прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
"попытка: ${{ github.run_attempt }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
@@ -886,8 +998,8 @@ jobs:
|
||||
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -899,7 +1011,7 @@ jobs:
|
||||
# внятной ошибкой — тогда флаг вернуть.
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -910,7 +1022,7 @@ jobs:
|
||||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||||
- name: Забрать собранный бандл
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: card-bundle
|
||||
path: dist
|
||||
@@ -946,7 +1058,7 @@ jobs:
|
||||
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.json
|
||||
- name: Upload performance smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: performance-smoke
|
||||
path: artifacts/performance-smoke
|
||||
@@ -957,8 +1069,9 @@ jobs:
|
||||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
"попытка: ${{ github.run_attempt }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
@@ -967,18 +1080,52 @@ jobs:
|
||||
path: .reuse-marker
|
||||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }}
|
||||
|
||||
geometry_parity:
|
||||
name: "Геометрия: TS/Python parity исполнена"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.geometry_parity == 'true' && needs.reuse.outputs.geometry_parity != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version-file: .python-version
|
||||
- run: npm ci
|
||||
- name: Компилировать узкое TS-зеркало П1–П4
|
||||
run: |
|
||||
npx --no-install tsc -p tsconfig.junction-parity.json
|
||||
node scripts/fix-test-build.mjs
|
||||
- name: Сравнить настоящие TS/Python validators (fail closed)
|
||||
run: python tests_backend/junction_parity.py --build-dir=test-build/junction-parity
|
||||
- name: Записать маркер успеха
|
||||
run: |
|
||||
printf '%s\n' "geometry_parity прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
"попытка: ${{ github.run_attempt }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-geometry_parity-${{ needs.reuse.outputs.geometry_parity_key }}
|
||||
|
||||
backend:
|
||||
name: "Бэкенд: pytest в Home Assistant"
|
||||
needs: [changes, reuse]
|
||||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
# Browser fixtures are generated by their real ESM factories and then
|
||||
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with: { python-version: "3.14" }
|
||||
- run: |
|
||||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||||
@@ -1026,7 +1173,7 @@ jobs:
|
||||
echo "coverage: $actual% (baseline $baseline%)"
|
||||
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
|
||||
- name: Артефакт coverage.xml
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: coverage.xml
|
||||
@@ -1037,8 +1184,9 @@ jobs:
|
||||
printf '%s\n' "backend прогнана успешно" \
|
||||
"SHA: ${{ github.sha }}" \
|
||||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||||
"попытка: ${{ github.run_attempt }}" \
|
||||
> .reuse-marker
|
||||
- uses: actions/cache/save@v6
|
||||
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||||
# краснеть из-за этого не должна.
|
||||
@@ -1046,3 +1194,41 @@ jobs:
|
||||
with:
|
||||
path: .reuse-marker
|
||||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|
||||
|
||||
# #541: общий conclusion workflow не говорит, какие условные job реально
|
||||
# исполнились, а какие были законно переиспользованы. Этот всегда исполняемый
|
||||
# финальный job публикует неизменяемый proof, привязанный к SHA/tree,
|
||||
# run_id/run_attempt и фактическим outputs всех prerequisite job. Consumers
|
||||
# review/merge/release принимают Validate только через scripts/ci-proof.mjs.
|
||||
proof:
|
||||
name: "Доказательство выполненных проверок"
|
||||
if: always()
|
||||
needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, geometry_parity, backend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with: { node-version: 22 }
|
||||
- name: Зафиксировать tree кандидата
|
||||
id: candidate
|
||||
run: echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
- name: Собрать единый CI proof
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ github.sha }}
|
||||
CANDIDATE_TREE: ${{ steps.candidate.outputs.tree }}
|
||||
CI_RUN_ID: ${{ github.run_id }}
|
||||
CI_RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
CI_EVENT: ${{ github.event_name }}
|
||||
REQUEST_FULL: ${{ inputs.full }}
|
||||
REQUEST_MUTANTS: ${{ inputs.mutants }}
|
||||
NEEDS_JSON: ${{ toJSON(needs) }}
|
||||
run: node scripts/ci-proof.mjs --emit=artifacts/ci-proof/proof.json
|
||||
- name: Опубликовать proof точной попытки
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ci-proof-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/ci-proof/proof.json
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
# Без слэша: с ним шаблон не покрывает СИМВОЛИЧЕСКУЮ ССЫЛКУ с этим именем,
|
||||
# и `git add -A` в чужом worktree затягивает её в коммит (#520, прежде #429).
|
||||
node_modules
|
||||
tsout/
|
||||
test-build/
|
||||
*.log
|
||||
|
||||
@@ -38,8 +38,11 @@ task records: problem, scope, acceptance criteria and discussion.
|
||||
|
||||
**Status lives in labels:** `S1-new`, `S2-analysis`, `S3-spec`, `S4-spec-review`,
|
||||
`S5-ready`, `S6-in-progress`, `S7-code-review`, `S8-merged`, plus `blocked` on top
|
||||
of a status and `rejected` on a closed issue. Exactly one `S*` label per open
|
||||
issue. Labels are the whole of it: GitHub Projects is no longer used.
|
||||
of a status and `rejected` on a closed issue. A product issue in flight carries
|
||||
exactly one `S*` label. An infrastructure-only issue is the deliberate exception:
|
||||
it may carry no `S*` label while being implemented, enters the common flow at
|
||||
`S7-code-review`, and from then on carries exactly one. Labels are the whole of
|
||||
it: GitHub Projects is no longer used.
|
||||
|
||||
**The light track is the default, not a shortcut** (owner's decision 2026-08-27,
|
||||
issue #338). `small` — the spec lives in the issue body and its review is a
|
||||
@@ -53,14 +56,16 @@ where a criterion is broken plainly and saying which one is easy.
|
||||
`S5-ready`, with the AC written into the issue body first. `trivial` requires a bug confined to one surface with no new UX
|
||||
contract, no migration, no i18n, no perf or touch impact, at most three checkable
|
||||
AC, **and expected behaviour already on record** — nothing left to decide. Code
|
||||
review is never skipped on either track; it is what stands in for testing.
|
||||
review is never skipped on either track: it checks scope, risks and the evidence
|
||||
from executed tests, but does not stand in for executing them.
|
||||
`PROCESS.md` §5 and §5.1 hold the criteria.
|
||||
|
||||
An issue filed by an outsider is worked exactly like one of the owner's own, once
|
||||
the owner has decided to take it. The check sits **at the entrance**, not on every
|
||||
step: while an issue carries no status label it is outside the process and the
|
||||
invariants do not apply to it; once a label is on, the task is in flight and **who
|
||||
filed it stops mattering**.
|
||||
the owner has decided to take it. For product work the check sits **at the
|
||||
entrance**, not on every step: the first status label admits it to the flow. For
|
||||
infrastructure work an explicit assignment by the owner is the entrance, and the
|
||||
issue may remain without an `S*` label until its first `S7-code-review`. In either
|
||||
case, **who filed it stops mattering** once the owner has admitted it.
|
||||
|
||||
Applying that first label *is* the owner's explicit decision, and the platform
|
||||
already guarantees it — only someone with write access can label. The earlier rule
|
||||
@@ -185,32 +190,36 @@ way. The layout is therefore fixed:
|
||||
or the owner — never reset or clean them away.
|
||||
- **`houseplan-card-src/hp-dev`** — the owner's worktree, permanently on `dev`. For owner-side operations that must not disturb the
|
||||
author's tree: pushing `dev`, restoring a hook's executable bit, emergencies.
|
||||
- **The reviewer and the infrastructure agent own no local tree.** The reviewer
|
||||
runs in CI on a fresh checkout. The infrastructure agent reads via `git show`
|
||||
and publishes through the GitHub API; it makes no local commits at all, so it
|
||||
needs no `HEAD` of its own. Its scratch worktrees live outside the repo and are
|
||||
pruned after use.
|
||||
- **The reviewer owns no author tree.** It runs in CI on a fresh checkout. The
|
||||
agent implementing an infrastructure task is an ordinary task author and uses
|
||||
the same author-tree rules as product work; task branches must not share a
|
||||
mutable checkout concurrently.
|
||||
|
||||
A worktree is only usable on the machine that created it: the `.git` file records
|
||||
an absolute path in that machine's format. One created from a Linux sandbox is
|
||||
dead on Windows and vice versa — create worktrees on the machine that will use
|
||||
them, which for `hp-dev` means the owner's.
|
||||
|
||||
## Two-agent workflow
|
||||
## Agent-neutral workflow
|
||||
|
||||
**Codex** writes analysis, specs and all product code. **Claude** reviews specs and
|
||||
code and owns infrastructure and distribution. The owner rules on disputes, closes
|
||||
issues and commands releases.
|
||||
No task type is reserved for Codex, Claude or any other named model. **Any agent
|
||||
may take any task**: analysis, spec, product implementation, infrastructure or a
|
||||
release explicitly commanded by the owner. Roles describe the current artifact,
|
||||
not the agent brand. The owner rules on product disputes, closes issues and
|
||||
commands releases.
|
||||
|
||||
Author and reviewer are different models, which is what "a fresh session without
|
||||
implementation context" means in practice. The reviewer never edits product code;
|
||||
the author never grades their own work.
|
||||
Author and reviewer are independent agents/sessions. They need not use different
|
||||
model families, but the reviewer must start without implementation context and
|
||||
must not be the author grading their own work. The reviewer does not edit the
|
||||
material under review.
|
||||
|
||||
**Infrastructure-only work runs outside this flow.** CI, scripts, labels, demo
|
||||
stands, the landing page and distribution are Claude's alone, and running them
|
||||
through spec-writing and review buys nothing: the spec would restate what is
|
||||
already unambiguous, and author and reviewer would be the same role. So no spec
|
||||
file, no spec review, no code review, no walk through `S1`…`S8`.
|
||||
**Infrastructure-only work uses an accelerated entry into the common flow.** It
|
||||
is implemented immediately by any agent, without analysis, spec, spec review or
|
||||
the statuses `S1`…`S6`. Once the branch is ready and pushed, apply
|
||||
`S7-code-review`. From there the ordinary controller applies: green review rebases
|
||||
and merges the checked material into `dev` and then sets `S8-merged`; findings or
|
||||
a failed merge return the issue to `S6-in-progress`, and after correction it is
|
||||
submitted to `S7-code-review` again.
|
||||
|
||||
The test for "infrastructure only" is mechanical: **not a single class A file** —
|
||||
nothing under `src/**`, no `custom_components/**/*.py`, no manifests, no i18n. A
|
||||
@@ -220,22 +229,28 @@ a loose reading would turn this into the route by which product changes skip
|
||||
review.
|
||||
|
||||
What stays mandatory either way: an issue exists, both trailers are on every
|
||||
commit, `typecheck`, `test` and `build` are green, and any non-obvious decision is
|
||||
written down in the code or the issue rather than kept in someone's head.
|
||||
commit, proportionate local gates are green (normally `typecheck`, `test` and
|
||||
`build`), and any non-obvious decision is written down in the code or the issue
|
||||
rather than kept in someone's head. Infrastructure work skips specification, not
|
||||
code review.
|
||||
|
||||
**Review starts by itself.** Applying `S4-spec-review` or `S7-code-review` fires the
|
||||
pipeline, which reviews without anyone asking and takes ten to forty-five minutes.
|
||||
pipeline. Deterministic gates, model review and integration have independent
|
||||
55/45/55-minute budgets (#551); typical runs finish well before those ceilings.
|
||||
|
||||
**Having applied one of those labels, wait for the result instead of ending the
|
||||
session.** Reporting "handed over for review" stops a conveyor that could have kept
|
||||
moving on its own. An agent has no clock — it exists only during its own turn — so
|
||||
waiting means polling: every 90 seconds, at most 30 times. A single long sleep hits
|
||||
waiting means polling: every 90 seconds, at most 110 times. A single long sleep hits
|
||||
the command timeout. Do the polling with `node scripts/wait-verdict.mjs --issue NN
|
||||
[--sha <tip>]` (#496): it watches the label, the pipeline's own comments (conflict,
|
||||
cancelled merge, failed run) and optionally Validate on the SHA, prints only when
|
||||
the state changes and exits 0 on a new label, 3 on an event that needs a hand,
|
||||
4 on timeout — the same 90 s × 30 without a model turn per tick. It writes
|
||||
nothing. Watch the **label**, not the comment: the label is the state,
|
||||
4 on timeout — the same 90 s × 110 without a model turn per tick. It writes
|
||||
nothing. Pipeline comments older than the latest application of `S4`/`S7` are
|
||||
the baseline, not an outcome of the new round; an outcome from the current round
|
||||
which already exists when the waiter starts is still delivered immediately (#546).
|
||||
Watch the **label**, not the comment: the label is the state,
|
||||
the comment only explains it. Do not wait at all while `blocked` is set — the task
|
||||
is waiting on the owner, not on the reviewer. On exhausting the attempts, stop and
|
||||
tell the owner: a failed run leaves the label where it was, forever.
|
||||
@@ -253,6 +268,15 @@ What the new label means:
|
||||
**After a review run the label always changes.** If it did not, the run itself
|
||||
failed rather than the work — say so to the owner instead of polling on.
|
||||
|
||||
The repository also has a bounded queue reconciler (#555). It takes one S4/S7
|
||||
snapshot every thirty minutes and exits. It may re-apply the same review label
|
||||
only when the matching event was lost or its run ended with a transient
|
||||
cancellation/timeout before a sealed result existed. It never applies verdicts or
|
||||
merges. Running work, `blocked`, `review-4`, a foreign material/stage/attempt, a
|
||||
guard failure, or an unintegrated sealed model result is left untouched and gets
|
||||
at most one machine-keyed diagnostic. This is a safety net, not permission for an
|
||||
agent to stop waiting for the result of the review it started.
|
||||
|
||||
**A failed pre-release gate does not send the issue back to review.** The
|
||||
implementation loop runs only typecheck, unit and build; golden, browser smokes,
|
||||
performance and the full HA harness run before a beta, which is after the code
|
||||
@@ -305,10 +329,14 @@ does not meet.
|
||||
|
||||
## Specs
|
||||
|
||||
`docs/specs/<NN>-<slug>.md`, linked to its issue in both directions. Required
|
||||
sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this,
|
||||
on which surface, at what moment; and what the person sees before and after, in one
|
||||
sentence without implementation terms.
|
||||
The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision
|
||||
2026-09-10, #517); `docs/specs/` is an archive of specs written before that date
|
||||
and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two
|
||||
product ones: which persona meets this, on which surface, at what moment; and what
|
||||
the person sees before and after, in one sentence without implementation terms.
|
||||
Proof that a verdict was passed on a given text is the pipeline's job: it writes
|
||||
the `sha256` of the normalised body into the review document's anchor block, and
|
||||
an edit made after a green spec review reaches the code reviewer as a finding.
|
||||
|
||||
**Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as
|
||||
fact is the worst kind of defect: it passes review because it looks like a decision.
|
||||
@@ -373,7 +401,8 @@ push (#479).** `smoke`, `golden` and `performance_smoke` in Validate are gated
|
||||
on the `heavy` output: true for a head commit carrying a `Release:` trailer, for
|
||||
`workflow_dispatch full=true` (which `nightly.yml` issues on `dev` every night)
|
||||
and for pull requests. A plain push to `dev` runs preflight, frontend (types,
|
||||
units, build, bundle sync, no-new-any), backend, hacs and hassfest. Screenshot
|
||||
units, build, bundle sync, no-new-any), the narrow TS/Python geometry parity
|
||||
guard when its inputs changed, backend, hacs and hassfest. Screenshot
|
||||
freshness in `check-docs` is likewise a warning on a plain push and an error on
|
||||
the candidate; `publish-prerelease.yml` and `release.yml` refuse a candidate
|
||||
without the `Release:` trailer, so a green Validate without the heavy jobs can
|
||||
@@ -387,6 +416,18 @@ demo/smoke_<name>.mjs`. A red smoke that reaches the review costs a cycle; run
|
||||
locally it costs a minute. Precedent: on #89 a fixture error lived through a
|
||||
whole review round that a local run would have caught immediately.
|
||||
|
||||
**One handoff, one push (#510).** Run `node scripts/process-gate.mjs --issues`
|
||||
locally with `gh` available before pushing (without `gh` the hook cannot check the
|
||||
issue status and stays silent). After `S7-code-review` do not push to the branch
|
||||
until the verdict or the return arrives: a push on top of a running review cancels
|
||||
it (10–20 runner minutes) and, after the material is fixed, also the merge (#312).
|
||||
Set `S7` once per round, not after every CI fix: the pipeline now runs Validate
|
||||
with the diff mutants on the material itself and returns a red one to `S6` without
|
||||
spending a review cycle. Mutants by diff no longer run on ordinary pushes — only
|
||||
on the review candidate, the merge candidate, the beta candidate, PRs and the
|
||||
nightly run — so a routine push costs ~3 minutes; 08–09.09 they cost 48 of 56
|
||||
Validate job-hours and were mostly cancelled by the next push.
|
||||
|
||||
The full smoke set, `golden` and `performance_smoke` still belong to the
|
||||
pre-beta run — which is then mandatory and complete. WSL runs of the full HA
|
||||
harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**:
|
||||
@@ -409,7 +450,14 @@ simply the only one we have). The deliberate override is
|
||||
`HP_ALLOW_FOREIGN_CAPTURE="reason"`; the reason travels into the output and the
|
||||
manifest. Baselines are still accepted only via
|
||||
`npm run golden:accept -- --reviewed` on a complete artefact, and the accepted
|
||||
index records the platform next to the Chromium build.
|
||||
index records the platform next to the Chromium build. The one local
|
||||
shortcut is `npm run docs:accept -- --identical` (#512): it re-captures on this
|
||||
machine, compares decoded pixels with the committed frames and, only when every
|
||||
frame is identical, refreshes the manifest fingerprints — frames that differ go
|
||||
through the artefact as before. The displayed card version reaches the DOM
|
||||
through `displayVersion()` (`src/card-version.ts`); the global
|
||||
`__HP_VERSION_OVERRIDE__` behind it is for harnesses only and the product never
|
||||
sets it.
|
||||
|
||||
**Backend.** A full Home Assistant harness cannot run on native Windows at all:
|
||||
Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL.
|
||||
@@ -442,21 +490,36 @@ assertions go red and part stay green, which reads as a logic defect.
|
||||
success` run for the candidate's SHA, not "the last green one"; a new push cancels
|
||||
an unfinished Validate for the same branch. Gate jobs, matching the actual
|
||||
`validate.yml` (#191): `docs`, `provenance`, `process-gate`, `hacs`, `hassfest`,
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `backend`. The `changes` job
|
||||
`frontend`, `smoke`, `golden`, `performance_smoke`, `geometry_parity`, `backend`.
|
||||
The `changes` job
|
||||
is a service path-filter, not a gate. `docs` is a real blocker: it checks the
|
||||
screenshots `sourceFingerprint` against current `src/**`, which is exactly what
|
||||
went red after the #113 merge.
|
||||
went red after the #113 merge. A stable release additionally waits for Full
|
||||
Performance and for a green E2E run on a real Home Assistant (`houseplan-e2e`,
|
||||
dispatched on the candidate SHA by `release.yml`, #514/#540); betas and the
|
||||
development cycle never run E2E. Stable installable assets (`houseplan.zip`,
|
||||
`houseplan-card.js` and their `SHA256SUMS`) reach the public stable release only
|
||||
from `release.yml` after those gates; a release published by hand is turned back
|
||||
into a draft first (#540). The prerelease publisher additionally ships a
|
||||
candidate-bound `RELEASE-MEMBERSHIP.json` covered by the same passport (#547).
|
||||
|
||||
**"Verified" without a named command and its result is not evidence.**
|
||||
|
||||
## Environments
|
||||
|
||||
**Local Windows checkout** is the day-to-day environment: Node 22 and Python 3.14
|
||||
as in CI (`npm run toolchain:check` compares the machine with the pins CI actually
|
||||
uses — `.nvmrc` and `.python-version` are derived from the same sources, #496),
|
||||
`gh` authenticated. `.venv-backend` does **not** exist there — it is
|
||||
provisioned only by cloud agent startup scripts, which also run `npm ci` and install
|
||||
Playwright Chromium.
|
||||
**Local Windows checkout** is the day-to-day environment: repository-pinned Node
|
||||
and Python as in CI (`npm run toolchain:check` compares the machine with the pins
|
||||
CI actually uses — `.nvmrc` and `.python-version` are derived from the same
|
||||
sources, #496),
|
||||
`gh` authenticated. On the owner's machine do not trust the ambient PATH:
|
||||
`.\scripts\windows-toolchain.ps1 setup|check` owns a verified portable Node and
|
||||
dedicated `.venv-ci`, and its `npm`/`node`/`python`/`playwright` actions are the
|
||||
explicit pinned entrypoints (#557). It changes no persistent PATH and never
|
||||
deletes a mismatched venv. In WSL, work from an ext4 clone and use
|
||||
`bash scripts/wsl-setup.sh --verify` for the real HA subset plus one Linux visual
|
||||
capture; exact-SHA Linux CI remains authoritative. `.venv-backend` does **not**
|
||||
exist there — it is provisioned only by cloud agent startup scripts, which also
|
||||
run `npm ci` and install Playwright Chromium.
|
||||
|
||||
Known environment-sensitive smoke: `demo/smoke_opening_measure.mjs` fails two
|
||||
sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned
|
||||
@@ -499,4 +562,7 @@ the work itself and follows the ordinary rules, trailers included.
|
||||
|
||||
Issues are closed in a batch when a beta ships, not when implementation ends: that
|
||||
way a bug found in the beta returns to the same task, and the beta announcement can
|
||||
list what went in. Status labels are stripped as the issues close.
|
||||
list what went in. The batch is immutable candidate membership proven by Git
|
||||
trailers, never the mutable S8 queue at close time; authorship does not change
|
||||
membership. Status labels are stripped as the issues close, and retries resume the
|
||||
same manifest without duplicating the release comment (#547).
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Процесс работы над House Plan
|
||||
|
||||
> **Статус документа: канон** (редакция 2026-08-13). Решения владельца, на
|
||||
> **Статус документа: канон** (редакция 2026-08-13, ролевое уточнение
|
||||
> 2026-09-13). Решения владельца, на
|
||||
> которых он стоит: прямые коммиты в `dev` **без PR** · канон статуса — **метки**,
|
||||
> имена английские · лёгкий трек **включён** · автор и ревьюер — разные модели ·
|
||||
> инфраструктурные задачи идут **вне** флоу.
|
||||
> имена английские · лёгкий трек **включён** · автор и ревьюер — независимые
|
||||
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
|
||||
> входят в общий флоу сразу на `S7-code-review`.
|
||||
>
|
||||
> **Область действия:** обязателен для владельца и для любого агента. Читается
|
||||
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
|
||||
@@ -12,10 +14,15 @@
|
||||
>
|
||||
> **Приоритет источников.** Канонический бэклог — GitHub Issues; статус живёт в
|
||||
> метках и больше нигде: Project v2 не используется. При расхождении
|
||||
> документации с GitHub побеждает GitHub. При расхождении этого документа с
|
||||
> `.github/workflows/*.yml` и `scripts/*` побеждает **фактическая автоматизация**:
|
||||
> она исполняется, а описание — нет. Расхождение при этом не игнорируется, а
|
||||
> заводится issue с меткой `process`.
|
||||
> документации с GitHub побеждает GitHub. Этот файл — единственный полный канон
|
||||
> процесса в репозитории; `AGENTS.md` — его короткое обязательное резюме, а
|
||||
> внешний `CODEX-RUNBOOK.md` — только маршрутизатор к канону и историческим
|
||||
> инструкциям. Текущие версии, состояние конкретных issue, runtime pins и списки
|
||||
> jobs не копируются в производную прозу: они читаются из своих исполняемых
|
||||
> источников.
|
||||
> При расхождении этого документа с `.github/workflows/*.yml` и `scripts/*`
|
||||
> побеждает **фактическая автоматизация**: она исполняется, а описание — нет.
|
||||
> Расхождение при этом не игнорируется, а заводится issue с меткой `process`.
|
||||
>
|
||||
> При расхождении процесса и привычки побеждает процесс.
|
||||
|
||||
@@ -44,13 +51,20 @@
|
||||
лежит внутри `custom_components/houseplan/frontend/`, и без этого правила он
|
||||
считался бы продуктовым исходником.
|
||||
|
||||
**Инфраструктурная задача идёт вне флоу** (решение владельца 2026-08-13,
|
||||
issue #118). Признак механический: **ни одного файла класса A**. Такая задача
|
||||
делается без ТЗ, ревью ТЗ, код-ревью и без прохода по статусам — флоу построен
|
||||
для изменений, у которых есть персона и видимое поведение, а в инфраструктуре ТЗ
|
||||
пересказывало бы очевидное, и автор с ревьюером оказались бы одной ролью.
|
||||
Проверкой служат гейты и CI. Обязательным остаётся issue, трейлеры и зелёные
|
||||
`typecheck`, `test`, `build`.
|
||||
**Инфраструктурная задача использует ускоренный вход в общий флоу** (решение
|
||||
владельца 2026-09-13, issue #562). Признак механический: **ни одного файла класса
|
||||
A**. Любой агент может сразу реализовать её по issue и в ветке
|
||||
`issue/<NN>-<slug>`, без аналитики, ТЗ, ревью ТЗ и статусов `S1`…`S6`. Когда
|
||||
материал готов, локальные гейты зелёные и ветка запушена, исполнитель ставит
|
||||
`S7-code-review`. Дальше действует тот же контроллер, что для продуктового кода:
|
||||
зелёное ревью сливает проверенный материал в `dev` и ставит `S8-merged`, а
|
||||
замечания или неудавшееся слияние возвращают задачу в `S6-in-progress`; после
|
||||
исправлений она снова идёт в `S7-code-review`.
|
||||
|
||||
Отсутствие ТЗ не означает отсутствие проверки. Для инфраструктуры обязательны
|
||||
issue, терминальные трейлеры, соразмерные изменению зелёные гейты, хендофф с
|
||||
доказательствами и независимое код-ревью. Модель или имя агента процессом не
|
||||
предписываются.
|
||||
|
||||
Задача, задевающая класс A хотя бы одним файлом, инфраструктурной **не
|
||||
является** и идёт полным флоу. «В основном инфраструктурная» не бывает: иначе
|
||||
@@ -61,7 +75,8 @@ issue #118). Признак механический: **ни одного фай
|
||||
|
||||
## 2. Жизненный цикл
|
||||
|
||||
Восемь рабочих статусов и два служебных. Фазы тестирования в цикле сознательно
|
||||
Восемь рабочих статусов и два служебных. Полный маршрут ниже относится к
|
||||
продуктовым задачам. Фазы тестирования в цикле сознательно
|
||||
**нет**: найденные позже дефекты заводятся отдельными issue и проходят цикл
|
||||
заново. Issue закрывается после выпуска беты.
|
||||
|
||||
@@ -72,6 +87,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
служебные: blocked (поверх статуса) rejected (закрыт)
|
||||
⟲ — возврат на правки, не более 4 циклов (§4), на лёгком и коротком треке 2
|
||||
короткий трек (`trivial`, §5.1) идёт S2-analysis → S5-ready, минуя S3 и S4
|
||||
инфраструктурный трек (§1): без S → S7-code-review ⟲ S6-in-progress → S8-merged
|
||||
```
|
||||
|
||||
Переходы `S4-spec-review` и `S7-code-review` выполняются **автоматически**: метка
|
||||
@@ -122,9 +138,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
### 2.3 ТЗ в работе — написание ТЗ
|
||||
|
||||
- **Кто:** автор ТЗ, назначает себя. Статус означает «занято».
|
||||
- **Артефакт:** `docs/specs/<NN>-<slug>.md`, где `NN` — **номер issue**.
|
||||
Многоэтапная задача: `<NN>-<slug>-stage<N>.md`.
|
||||
- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5).
|
||||
- **Артефакт:** **тело issue**, раздел `## ТЗ` (решение владельца 2026-09-10,
|
||||
#517). Файл в `docs/specs/` не создаётся ни на одном треке: каталог — архив
|
||||
ТЗ до этой даты, и задачи, у которых файл уже есть, доживают по старой схеме.
|
||||
Доказуемость («вердикт вынесен на этом тексте») держит конвейер: в блок якорей
|
||||
документа ревью пишется `sha256` нормализованного тела, и правка ТЗ после
|
||||
зелёного ревью ТЗ приходит ревьюеру кода находкой, а не тишиной.
|
||||
- **Выход:** полная первая редакция по §7.
|
||||
|
||||
### 2.4 ТЗ на ревью
|
||||
@@ -155,8 +174,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`;
|
||||
- влияние на производительность и бюджеты названо (или явно «нет»);
|
||||
- влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие);
|
||||
- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN,
|
||||
документация, golden/скриншоты, performance/security);
|
||||
- release-артефакты названы: changelog RU+EN, документация, golden/скриншоты,
|
||||
performance/security — либо явное «нет»;
|
||||
- **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция);
|
||||
- открытых продуктовых вопросов нет; риски перечислены.
|
||||
|
||||
@@ -175,6 +194,16 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
|
||||
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
|
||||
тестирования, а не отсутствие тестов.
|
||||
- **Приёмка проверяет результат для человека, а не строки реализации.** Для
|
||||
изменённой поверхности автор выбирает обычный сценарий и самый рискованный
|
||||
применимый соседний случай; у каждого должен быть наблюдаемый oracle — что
|
||||
пользователь видит, может сделать или что система отказывается делать. При
|
||||
выборе случаев коротко пройти шесть классов риска: async (порядок, отмена,
|
||||
устаревший ответ); данные и права (пусто, нет связи, несколько источников,
|
||||
отказ); геометрия (границы, стыки, трансформации); визуал (промежуточный кадр,
|
||||
тема, zoom/DPR); объём данных и performance; host/input (HA, кэш, mouse/touch/
|
||||
keyboard). Неприменимое так и отмечается; проверка имени метода или строки
|
||||
исходника пользовательским oracle не считается.
|
||||
- **Скоуп не расширяется.** Найденное по пути становится новым issue в «Новое».
|
||||
Если находка блокирует — текущий issue уходит в «Заблокировано» со ссылкой.
|
||||
Попутных правок «раз уж я здесь» не бывает.
|
||||
@@ -189,10 +218,13 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
- **Артефакт:** `docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md` в действующем
|
||||
формате: скоуп, как проверялось (таблица гейтов с результатами), находки
|
||||
High/Medium/Low с воспроизведением, что проверено и корректно, чего не проверял.
|
||||
- **Ревьюер отвечает за AC.** Раз ручного тестирования в цикле нет, именно ревью
|
||||
кода отвечает на вопрос «оно вообще работает»: каждый AC либо доказан
|
||||
автотестом — и ревьюер убедился, что **тест умеет падать**, — либо разобран по
|
||||
коду с явной записью «проверено чтением, не исполнением».
|
||||
- **Ревьюер отвечает за полноту доказательств AC, а не заменяет их исполнение.**
|
||||
Каждый AC либо доказан автотестом — и ревьюер убедился, что **тест умеет
|
||||
падать**, — либо разобран по коду с явной записью «проверено чтением, не
|
||||
исполнением». Чтение кода выявляет риски, но не доказывает наблюдаемый
|
||||
пользовательский результат; если соразмерный исполнимый oracle возможен, его
|
||||
отсутствие — находка. Ревьюер отдельно сверяет применимые классы риска из
|
||||
§2.6 и не выдаёт запуск гейта за проверку сценария, которого в гейте нет.
|
||||
- **Защитный AC доказывается таблицей «чем краснеет» (#435).** Для каждого AC,
|
||||
заявляющего защиту — валидация, гард, лимит, отказ, инвариант, — в документе
|
||||
ревью обязательна строка из трёх столбцов: **AC · чем доказан** (точная
|
||||
@@ -428,19 +460,20 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
**Что упрощается:**
|
||||
|
||||
- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с
|
||||
доказательством · откат. Файл в `docs/specs/` не создаётся;
|
||||
- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат
|
||||
(в теле issue, как и на полном треке с 2026-09-10);
|
||||
- ревью ТЗ — комментарий второго агента, отдельный документ не нужен;
|
||||
- лимит ревью ТЗ — 2 цикла.
|
||||
|
||||
**Что не упрощается:** issue, оценка, статусы, трейлеры коммитов, changelog,
|
||||
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается
|
||||
никогда — именно оно в этом процессе заменяет тестирование. Единственное
|
||||
исключение — починка упавшего предрелизного гейта, §11.4.
|
||||
**код-ревью и его документ**, закрытие после беты. Код-ревью не пропускается:
|
||||
оно проверяет скоуп, риски и качество доказательств, но не заменяет исполнение
|
||||
тестов. Единственное исключение из повторного ревью — починка упавшего
|
||||
предрелизного гейта, §11.4.
|
||||
|
||||
Если по ходу выясняется, что критерий нарушен (появилась миграция, задело второй
|
||||
модуль) — метка `small` снимается, issue возвращается в `S3-spec` и получает
|
||||
нормальный файл ТЗ. Это не провал, это ранняя диагностика.
|
||||
полное ТЗ в теле issue по §7.1. Это не провал, это ранняя диагностика.
|
||||
|
||||
### 5.1 Короткий трек (метка `trivial`)
|
||||
|
||||
@@ -488,7 +521,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
| Роль | Делает | Не имеет права |
|
||||
|---|---|---|
|
||||
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
|
||||
| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ |
|
||||
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
|
||||
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
|
||||
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
|
||||
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
|
||||
@@ -498,19 +531,18 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
**Правило разделения:** ревьюер работает состязательно. Ему передаётся тег или
|
||||
диапазон коммитов и ТЗ — не рассказ автора о том, как всё хорошо.
|
||||
|
||||
**Роли закреплены за исполнителями** (решение владельца 2026-08-12):
|
||||
**Роли не закреплены за моделями или именами агентов** (решение владельца
|
||||
2026-09-13, issue #562). Codex, Claude или любой другой доступный агент может быть
|
||||
аналитиком, автором ТЗ, разработчиком, автором инфраструктурной задачи или
|
||||
релиз-инженером по прямой команде владельца.
|
||||
|
||||
| Исполнитель | Роли |
|
||||
|---|---|
|
||||
| **Codex** | аналитик, автор ТЗ, разработчик, релиз-инженер по команде владельца |
|
||||
| **Claude** | ревьюер ТЗ, ревьюер кода, вся инфраструктура и дистрибуция |
|
||||
| **Владелец** | приоритет, скоуп, арбитраж, закрытие issue, команда на выпуск |
|
||||
Разделение относится к артефакту: **автор и ревьюер — разные агенты/сессии**.
|
||||
Модель может совпадать, но ревьюер начинает без контекста реализации и не ставит
|
||||
вердикт собственной работе. Ревью ТЗ и код-ревью также идут в независимых
|
||||
сессиях: ревьюер кода не должен приходить с контекстом обсуждения ТЗ.
|
||||
|
||||
Автор и ревьюер — **разные модели**, и это сильнее требования «другая сессия»:
|
||||
одна модель, читая свой же артефакт заново, повторяет свои же слепые пятна.
|
||||
|
||||
Ревью ТЗ и код-ревью держатся в **разных сессиях** Claude: ревьюер кода не должен
|
||||
приходить с контекстом того, как обсуждали ТЗ.
|
||||
Владелец сохраняет исключительные решения о приоритете, ценности, продуктовом
|
||||
скоупе, отклонении, арбитраже, закрытии issue и команде на выпуск.
|
||||
|
||||
---
|
||||
|
||||
@@ -520,7 +552,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
|
||||
|
||||
```
|
||||
issue #NN
|
||||
↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`)
|
||||
↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью)
|
||||
↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`)
|
||||
↔ ветка issue/NN-slug
|
||||
↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no
|
||||
@@ -593,10 +625,9 @@ issue #NN
|
||||
|
||||
### 7.3 Расхождения с текущим состоянием, которые надо закрыть
|
||||
|
||||
1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку
|
||||
«Статус ТЗ» со своим словарём («черновик решения», «в реализации»,
|
||||
«реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить
|
||||
таблицу «issue ↔ ТЗ».
|
||||
1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт
|
||||
в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён
|
||||
вместе с самой таблицей. Статус задачи — только метка `S*`.
|
||||
2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и
|
||||
`SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их
|
||||
задним числом смысла нет: они описывают код, которого уже нет. Новые документы
|
||||
@@ -621,6 +652,9 @@ npm run golden:verify # если менялся визуал
|
||||
node scripts/check-docs.mjs # если менялся src/**
|
||||
node scripts/model-invariants.mjs --config <экспорт> # если правилась геометрия или ссылки
|
||||
python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:check), если менялся бэкенд
|
||||
npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
&& python tests_backend/junction_parity.py --build-dir=test-build/junction-parity
|
||||
# если менялось одно из зеркал junction limits
|
||||
```
|
||||
|
||||
**Новый код не добавляет `any`** (#342). В `src/**` уже 1034 вхождения явного
|
||||
@@ -653,6 +687,13 @@ python -m pytest tests_backend -q # py3.14 как в CI (npm run toolchain:c
|
||||
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит делает
|
||||
человек.
|
||||
|
||||
Когда правка `src/**` кадров не меняет — а это большинство правок — CI-цикл не
|
||||
нужен (#512): `npm run docs:accept -- --identical` снимает кадры локально,
|
||||
декодирует оба набора в Chromium и при нуле отличающихся пикселей во всех
|
||||
кадрах обновляет только отпечаток исходников в `screenshots.json`; байты
|
||||
закоммиченных PNG, их sha, браузер и упаковщик съёмки остаются прежними. Хотя бы
|
||||
один отличающийся пиксель — отказ с перечнем кадров и штатный путь через артефакт.
|
||||
|
||||
`check-docs` стоит в обязательной части не по важности, а по механике: отпечаток
|
||||
скриншотов документации считается по всему `src/**`, поэтому **любая** правка
|
||||
фронтенда делает его устаревшим. Выборка «по diff и AC» здесь не работает — diff
|
||||
@@ -694,7 +735,11 @@ demo/docs/capture.mjs`, коммит вместе с задачей.
|
||||
достаточен для продолжения релиза, повторное код-ревью не требуется — §11.4.
|
||||
|
||||
**Гейт стабильного релиза:** полный локальный прогон плюс Validate и Full
|
||||
Performance зелёные на точном SHA; статусов issue не касается.
|
||||
Performance зелёные на точном SHA, плюс зелёный E2E на реальном Home Assistant:
|
||||
`release.yml` сам запускает `e2e.yml` в `houseplan-e2e` на SHA кандидата и ждёт
|
||||
его зелёного (#514, #540); установочные ассеты публикуются только после всех
|
||||
гейтов и только этим workflow — релиз, опубликованный руками, возвращается в
|
||||
черновик до их прохождения (#540); статусов issue не касается.
|
||||
|
||||
---
|
||||
|
||||
@@ -728,18 +773,22 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
Тематические метки (`polish`, `infra`, `tests`, `docs`, `security`, `vacuum`)
|
||||
ортогональны процессу.
|
||||
|
||||
Инварианты: **ровно одна `S*`-метка** на открытом issue; закрытый issue статусных
|
||||
меток не несёт; `blocked` не заменяет статус, а дополняет его.
|
||||
Инварианты: продуктовая задача в процессе несёт **ровно одну `S*`-метку**.
|
||||
Инфраструктурная задача может не иметь `S*` во время первоначальной реализации;
|
||||
с первого `S7-code-review` на неё действует тот же инвариант ровно одной метки.
|
||||
Закрытый issue статусных меток не несёт; `blocked` не заменяет статус, а дополняет
|
||||
его.
|
||||
|
||||
**Чужой issue берётся в работу так же, как свой — после явного решения
|
||||
владельца** (решение владельца 2026-08-13, уточнено в тот же день). Репозиторий
|
||||
публичный, отчёты заводят и посторонние; проверка стоит **на входе**, а не на
|
||||
каждом шаге.
|
||||
|
||||
Входом служит присвоение первой статусной метки: пока меток нет, issue вне
|
||||
процесса и инварианты на него не распространяются. Как только метка стоит, задача
|
||||
в работе, и **кто её завёл, дальше не имеет значения** — статусы, ревью и лимиты
|
||||
работают одинаково.
|
||||
Для продуктовой задачи входом служит присвоение первой статусной метки. Для
|
||||
инфраструктурной — явное назначение владельцем; до готовности к первому
|
||||
код-ревью она может оставаться без `S*`. Как только продуктовая задача вошла в
|
||||
полный маршрут либо инфраструктурная получила `S7-code-review`, **кто её завёл,
|
||||
дальше не имеет значения** — статусы, ревью и лимиты работают одинаково.
|
||||
|
||||
Присвоение метки и есть то самое явное решение, причём проверенное платформой:
|
||||
метки может ставить только тот, у кого есть право записи в репозиторий. Прежняя
|
||||
@@ -811,9 +860,11 @@ Performance зелёные на точном SHA; статусов issue не к
|
||||
|
||||
1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько;
|
||||
2. имя ветки `issue/NN-slug` соответствует трейлерам;
|
||||
3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`.
|
||||
Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения
|
||||
меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ;
|
||||
3. у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1` в теле issue —
|
||||
либо архивный `docs/specs/NN-*.md` у задачи до 2026-09-10. Офлайн тела нет,
|
||||
и проверка молчит; с `--issues` — предупреждение (настоящий рубеж — ревью ТЗ).
|
||||
Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог
|
||||
заморожен (#517);
|
||||
4. `User-Visible: yes` → правки в обоих changelog в том же коммите;
|
||||
5. коммит только класса D невалиден без `Release: vX.Y.Z` либо
|
||||
`Baseline-Reviewed: <ссылка на прогон CI>`;
|
||||
@@ -849,10 +900,17 @@ post-beta коммит остаётся в проверке и по закрыт
|
||||
Не реализовано и остаётся долгом:
|
||||
|
||||
9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue
|
||||
зелёный вердикт код-ревью;
|
||||
10. закрытие issue и снятие статусных меток при публикации беты делаются руками —
|
||||
`node process-labels/apply.mjs cleanup --apply`, а не `publish-prerelease.yml`.
|
||||
Пропуск этого шага уже ломал инвариант «закрытый issue без статусной метки».
|
||||
зелёный вердикт код-ревью.
|
||||
|
||||
Закрытие вошедших issue автоматизировано (#120, #547). При старте беты текущая
|
||||
очередь S8 служит только списком кандидатов: `RELEASE-MEMBERSHIP.json` оставляет
|
||||
из неё лишь номера с доказанным `Issue: #NN` в Git-диапазоне зафиксированного
|
||||
SHA. Manifest публикуется и входит в `SHA256SUMS`; свежая очередь S8 после
|
||||
публикации не перечитывается. Общий для workflow и локальной команды bookkeeping
|
||||
идемпотентно добавляет один маркированный release-комментарий, снимает все
|
||||
статусные метки и закрывает issue. Поэтому повтор после сбоя продолжает manifest,
|
||||
даже если метка уже снята или release уже public; автор issue на membership не
|
||||
влияет.
|
||||
|
||||
### 10.3 Страховка и разбор
|
||||
|
||||
@@ -879,11 +937,13 @@ S4-spec-review → ревью ТЗ → S5-ready либо возврат в S3-
|
||||
S7-code-review → код-ревью → слияние в dev → S8-merged либо возврат в S6-in-progress
|
||||
```
|
||||
|
||||
Ревьюер — `anthropics/claude-code-action`. Он читает `docs/SCOPE.md`, `AGENTS.md`,
|
||||
этот документ и тело issue, публикует разбор комментарием, заводит issue на Medium-находки
|
||||
вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки задачи и возвращает вердикт
|
||||
структурированным JSON. **Метку переставляет отдельный детерминированный шаг по
|
||||
вердикту, а не модель.**
|
||||
Текущая техническая реализация независимого ревьюера —
|
||||
`anthropics/claude-code-action`; это деталь автоматизации, а не закрепление роли
|
||||
или вида задач за Claude. Ревьюер читает `docs/SCOPE.md`, `AGENTS.md`, этот
|
||||
документ и тело issue, публикует разбор комментарием, заводит issue на
|
||||
Medium-находки вне скоупа задачи (#202), кладёт документ в `docs/reviews/` ветки
|
||||
задачи и возвращает вердикт структурированным JSON. **Метку переставляет отдельная
|
||||
детерминированная стадия по вердикту, а не модель.**
|
||||
|
||||
Четыре вещи, без которых конвейер молча не работает:
|
||||
|
||||
@@ -897,14 +957,56 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым
|
||||
отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера.
|
||||
|
||||
**Ревью не начинается на красном коде** (#510). После фиксации материала конвейер
|
||||
запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`:
|
||||
`workflow_dispatch validate.yml -f mutants=true`) и ждёт его до 45 минут. Красный или
|
||||
пропавший прогон возвращает задачу в `S6-in-progress` с комментарием и ссылкой —
|
||||
код никто не читал, цикл ревью не израсходован. Мутанты по диффу вообще бегут
|
||||
только по запросу: на кандидате ревью, кандидате слияния (#492), кандидате беты,
|
||||
в ночном прогоне и на PR; обычный push обходится дешёвыми гейтами (~3 минуты).
|
||||
За 08–09.09 мутанты на каждом промежуточном пуше стоили 48 из 56 часов
|
||||
job-минут Validate и в основном отменялись следующим пушем.
|
||||
|
||||
Ожидание gates, работа модели и публикация/интеграция — три независимых jobs
|
||||
(#551) с отдельными бюджетами 55, 45 и 55 минут. Поэтому долгий Validate не
|
||||
съедает время модели, а ожидание кандидата после зелёного вердикта не обрывает
|
||||
готовый review. Между jobs передаётся запечатанный artifact: run/attempt, issue,
|
||||
этап, раунд, branch, SHA/tree материала, якоря ТЗ и результат Validate. Получатель
|
||||
сверяет полный набор файлов, SHA-256 и все поля с outputs предыдущей стадии;
|
||||
неполный, чужой или устаревший результат fail-closed не публикуется и не разрешает
|
||||
merge. Timeout/cancel/failure называет конкретную стадию и оставляет метку на месте;
|
||||
если модель не запускалась, цикл ревью не расходуется. Длительности всех трёх
|
||||
стадий печатаются отдельной таблицей в summary прогона.
|
||||
|
||||
Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель
|
||||
судится по **области своего якоря** — строкам патча плюс сорок строк с каждой
|
||||
стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по
|
||||
диффу, который читает ханки `git diff --unified=0`. Сторона гарда осталась
|
||||
файловой: у гарда якоря нет. Неоднозначный якорь и непрочитанные ханки дают
|
||||
прежний широкий ответ — незнание не доказательство. Приближение того же класса,
|
||||
что и сам отбор по диффу; нижняя граница — ночной полный гейт (#513). Шард
|
||||
считает свой план до установки окружения и при пустом плане не платит за
|
||||
npm ci, Python и Chromium, оставаясь исполненной job: доказательство гейта
|
||||
требует успешной job, а не пропущенной.
|
||||
|
||||
**Один хендофф — один пуш.** Перед пушем — локальный `node scripts/process-gate.mjs
|
||||
--issues` при доступном `gh` (хук без `gh` статус issue не проверяет и молчит);
|
||||
после `S7-code-review` в ветку не пушить, пока не пришёл вердикт или возврат: пуш
|
||||
поверх идущего ревью отменяет его и стоит 10–20 минут раннера, а после фиксации
|
||||
материала — ещё и слияние (#312). `S7` ставится один раз на заход, не после
|
||||
каждого фикса CI: красный Validate конвейер вернёт сам.
|
||||
|
||||
**Автор обязан дождаться вердикта, а не заканчивать сессию.** Ревью идёт от десяти
|
||||
минут до сорока пяти. Отчёт «передал на ревью» останавливает конвейер там, где он
|
||||
мог идти сам: вердикт придёт, а подхватить его будет некому. У агента нет часов —
|
||||
он существует только в момент своего хода, поэтому ожидание это опрос: раз в 90
|
||||
секунд, не более 30 попыток — `node scripts/wait-verdict.mjs --issue NN` делает его
|
||||
секунд, не более 110 попыток (запас на три независимых бюджета #551) —
|
||||
`node scripts/wait-verdict.mjs --issue NN` делает его
|
||||
детерминированно и говорит только при смене состояния (#496). Смотреть на метку, а
|
||||
не на комментарий: метка и есть состояние. При `blocked` не ждать — задача ждёт
|
||||
владельца.
|
||||
не на комментарий: метка и есть состояние. Комментарии конвейера до последнего
|
||||
применения `S4`/`S7` считаются историческим baseline, а уже опубликованный исход
|
||||
текущего раунда доставляется сразу при первом опросе (#546). При `blocked` не
|
||||
ждать — задача ждёт владельца.
|
||||
|
||||
**После прогона ревью метка меняется всегда.** Инвариант появился не сразу: первая
|
||||
редакция при конфликте слияния оставляла метку на месте, и это оказалось тупиком —
|
||||
@@ -945,8 +1047,9 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
|
||||
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
|
||||
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
|
||||
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, ожидание
|
||||
зелёного Validate **на этом SHA** и только затем push в `dev` с lease на ту
|
||||
к другому диффу не применим, §7.2), публикация кандидата в ветку задачи, запуск
|
||||
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
|
||||
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
|
||||
вершину, поверх которой кандидат собран. Отклонённый lease — `dev` двинулся снова
|
||||
— новая попытка; после третьей — `S6-in-progress` с комментарием;
|
||||
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
|
||||
@@ -966,6 +1069,25 @@ S7-code-review → код-ревью → слияние в dev → S8-merged л
|
||||
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
|
||||
сообщать владельцу, а не продолжать опрос.
|
||||
|
||||
**Очередь S4/S7 сверяется отдельным bounded controller (#555).** Workflow
|
||||
`process-reconcile.yml` раз в полчаса делает один снимок открытых задач и
|
||||
завершается — активного polling одинакового состояния и вызова модели на каждый
|
||||
тик нет. Он сопоставляет последнюю постановку `S4`/`S7` с run по номеру задачи и
|
||||
этапу; если стадия успела подготовить материал, дополнительно проверяет
|
||||
запечатанные run/attempt, SHA/tree, список блобов ТЗ и номер раунда. Текущий
|
||||
`blocked`, `review-4` или снятая review-метка всегда сильнее старого события.
|
||||
|
||||
Автоматически и не более одного раза повторно применяется только сама review-метка после доказанно
|
||||
потерянного события либо transient-исхода до получения запечатанного результата
|
||||
(`cancelled`, `timed_out`, `stale`, `startup_failure`, `skipped`). Это не применяет
|
||||
вердикт и не расходует цикл: обычный конвейер заново читает актуальные labels и
|
||||
материал. Здоровый running run не трогается. Failure guard, неизвестная связь,
|
||||
чужой material/stage/attempt, success без смены метки и сбой после появления
|
||||
`review-result`, а также потеря повторного события получают один дедуплицированный диагностический комментарий и
|
||||
эскалацию человеку — второй вызов модели или S8 по догадке запрещены. Перед любой
|
||||
записью controller перечитывает состояние; итог каждого прохода публикуется как
|
||||
`houseplan-process-reconcile/v1` artifact.
|
||||
|
||||
**Конвейер — идемпотентный контроллер, а событие лишь будит его** (#499). Guard
|
||||
читает метки issue текущими, а не из снимка события: прогон мог простоять в очереди,
|
||||
пока владелец снял метку — отозванный запрос не исполняется, и комментария об этом
|
||||
@@ -1083,9 +1205,9 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
|
||||
1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно
|
||||
одна `S*`-метка, инварианты чистые.
|
||||
2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3
|
||||
п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают
|
||||
код, которого уже нет.
|
||||
2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/`
|
||||
стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых
|
||||
документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет.
|
||||
3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`,
|
||||
issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а
|
||||
не через ТЗ и ревью, как предполагала прежняя редакция этого пункта.
|
||||
@@ -1119,9 +1241,15 @@ Golden, браузерные смоки, performance и полный HA-харн
|
||||
→ закрытие пачкой при выпуске беты. Оба ревью возвращают на правки не более 4
|
||||
циклов; пятый заход — разбор у владельца (разделить / отклонить / арбитраж).
|
||||
|
||||
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review` и идёт до
|
||||
45 минут. Поставив такую метку, автор не заканчивает работу, а ждёт смены метки
|
||||
опросом и продолжает по тому, чем она стала.
|
||||
Инфраструктурная задача (ни одного файла класса A) делается сразу любым агентом:
|
||||
без `S*` → `S7-code-review` ↔ `S6-in-progress` → `S8-merged`. ТЗ и ревью ТЗ нет,
|
||||
код-ревью обязательно.
|
||||
|
||||
Ревью запускается **само** от меток `S4-spec-review` и `S7-code-review`.
|
||||
Deterministic prerequisites, модель и интеграция имеют отдельные пределы 55/45/55
|
||||
минут; обычно стадии заканчиваются существенно раньше. Поставив такую метку, автор
|
||||
не заканчивает работу, а ждёт смены метки опросом и продолжает по тому, чем она
|
||||
стала.
|
||||
|
||||
- ветка `issue/<NN>-<slug>`, коммиты с трейлерами `Issue: #NN` и `User-Visible: yes|no`;
|
||||
- работаем прямыми коммитами в `dev`, без PR: блокирующий гейт — локальный
|
||||
|
||||
@@ -57,7 +57,7 @@ SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
ASSETS_DIR = "houseplan/assets"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.73.0"
|
||||
VERSION = "1.76.0-beta.3"
|
||||
DECOR_ASSETS_API_VERSION = 1
|
||||
SUMMARY_PANEL_API_VERSION = 1
|
||||
|
||||
|
||||
@@ -1,312 +1,295 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"fingerprint": "95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74",
|
||||
"fingerprint": "0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e",
|
||||
"entry": "houseplan-card.js",
|
||||
"panelEntry": "houseplan-panel.js",
|
||||
"initialViewFiles": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js",
|
||||
"houseplan-card.js"
|
||||
],
|
||||
"initialViewGzipBytes": 298891,
|
||||
"initialViewGzipBytes": 290379,
|
||||
"initialPanelFiles": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-card.js",
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js",
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelGzipBytes": 301193,
|
||||
"initialPanelGzipBytes": 291901,
|
||||
"initialPanelOnlyFiles": [
|
||||
"houseplan-panel.js"
|
||||
],
|
||||
"initialPanelOnlyGzipBytes": 2302,
|
||||
"initialPanelOnlyGzipBytes": 2325,
|
||||
"lazyFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/backdrop-pick-Caayxvt2.js",
|
||||
"houseplan-assets/de-JVEEGD9u.js",
|
||||
"houseplan-assets/editor-C5Zq-3aA.js",
|
||||
"houseplan-assets/fr-B-ete5Dh.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-D3IWAxB8.js",
|
||||
"houseplan-assets/iso-scene-render-ceJKJKhC.js",
|
||||
"houseplan-assets/live-interaction-runtime-CxW-k-p_.js",
|
||||
"houseplan-assets/pdf-export-BAtsOfAV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js",
|
||||
"houseplan-assets/zigbee-topology-DlU7AXvw.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js"
|
||||
],
|
||||
"lazyGzipBytes": 417329,
|
||||
"lazyGzipBytes": 418510,
|
||||
"lazyEditorFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/backdrop-pick-Caayxvt2.js",
|
||||
"houseplan-assets/editor-C5Zq-3aA.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
|
||||
"houseplan-assets/zigbee-topology-DlU7AXvw.js"
|
||||
],
|
||||
"lazyEditorGzipBytes": 205633,
|
||||
"lazyEditorGzipBytes": 204784,
|
||||
"lazyOnboardingFiles": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js"
|
||||
"houseplan-assets/backdrop-pick-Caayxvt2.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js"
|
||||
],
|
||||
"lazyOnboardingGzipBytes": 14148,
|
||||
"lazyOnboardingGzipBytes": 13914,
|
||||
"lazyLocaleFiles": [
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js"
|
||||
"houseplan-assets/de-JVEEGD9u.js",
|
||||
"houseplan-assets/fr-B-ete5Dh.js"
|
||||
],
|
||||
"lazyLocaleGzipBytes": 54843,
|
||||
"lazyLocaleGzipBytes": 54845,
|
||||
"lazyIsometricFiles": [
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js"
|
||||
"houseplan-assets/iso-scene-render-ceJKJKhC.js"
|
||||
],
|
||||
"lazyIsometricGzipBytes": 13452,
|
||||
"lazyIsometricGzipBytes": 13232,
|
||||
"lazyFurnitureArtFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js"
|
||||
],
|
||||
"lazyFurnitureArtGzipBytes": 10253,
|
||||
"lazyFurnitureArtGzipBytes": 10252,
|
||||
"lazyPdfFiles": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
|
||||
"houseplan-assets/pdf-export-BAtsOfAV.js"
|
||||
],
|
||||
"lazyPdfGzipBytes": 120367,
|
||||
"lazyPdfGzipBytes": 120125,
|
||||
"files": [
|
||||
{
|
||||
"path": "houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"sha256": "a4abe8807d8c404a7ce9f65b0a72ca3d8f8d4d5bf38fa8220915818f9238c0db",
|
||||
"rawBytes": 21134,
|
||||
"gzipBytes": 7144,
|
||||
"path": "houseplan-assets/backdrop-pick-Caayxvt2.js",
|
||||
"sha256": "3a8a8a4b522eaae770f7b0cdd5612aaa542fd81e2fef1adcb58d21d9b093b360",
|
||||
"rawBytes": 19956,
|
||||
"gzipBytes": 6881,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/de-IBWzSVOj.js",
|
||||
"sha256": "cf031fb380b4b056c116e14a8cb26729ae4d0b275991b07989917f884f8841ff",
|
||||
"rawBytes": 91441,
|
||||
"gzipBytes": 27705,
|
||||
"path": "houseplan-assets/de-JVEEGD9u.js",
|
||||
"sha256": "f300635f221656a36e591183395cc38c77c093e2347ce98ac0b0f324f7ec8306",
|
||||
"rawBytes": 91437,
|
||||
"gzipBytes": 27709,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/editor-DKd1NGbT.js",
|
||||
"sha256": "e62e388db3b39eaa5f0e8f9b9676b7a6e30c453f84d22d530740f9be093512d1",
|
||||
"rawBytes": 3837,
|
||||
"gzipBytes": 1585,
|
||||
"path": "houseplan-assets/editor-C5Zq-3aA.js",
|
||||
"sha256": "4878d5efac2222fdd3fda5e695588a8d5cfe463027397b2fc8499bd6e649c6a7",
|
||||
"rawBytes": 3831,
|
||||
"gzipBytes": 1583,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/fr-T10CV6-a.js",
|
||||
"sha256": "b602e1137a8395f963be744ed43d2054e472093502f687e40e6bd3e7a25a8794",
|
||||
"rawBytes": 93719,
|
||||
"gzipBytes": 27138,
|
||||
"path": "houseplan-assets/fr-B-ete5Dh.js",
|
||||
"sha256": "c4d9a2f7bf0fca73861727752c5cd9b41e8cda469685e32eb34dedc8ec0f790b",
|
||||
"rawBytes": 93701,
|
||||
"gzipBytes": 27136,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"sha256": "1e26cad8d04b459f4b1140e705a02a1d380b8c49a84f92b8ea2ce3e684544800",
|
||||
"path": "houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
|
||||
"sha256": "69da2271017aa401f35e87387fb06d384617ee78f0d534bbd28792f5c3cb33e8",
|
||||
"rawBytes": 33655,
|
||||
"gzipBytes": 10253,
|
||||
"gzipBytes": 10252,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/guard-7S6iRB0N.js",
|
||||
"sha256": "67f95421a21516a66157d8fdc0129c2d1280cee31a3621a81039c88835d1c0f8",
|
||||
"rawBytes": 518,
|
||||
"gzipBytes": 368,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"sha256": "dea85ab6dfeebcb263bd672270041e34473d40b9db24e69259e55d42e5ff44d4",
|
||||
"rawBytes": 1076387,
|
||||
"gzipBytes": 298088,
|
||||
"path": "houseplan-assets/houseplan-card-BCGpvbDT.js",
|
||||
"sha256": "83f27e261a8a7a32463b67762c859e431d1468a32af7249627ec326f16696772",
|
||||
"rawBytes": 1031731,
|
||||
"gzipBytes": 289576,
|
||||
"isEntry": false,
|
||||
"imports": [],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/de-IBWzSVOj.js",
|
||||
"houseplan-assets/editor-DKd1NGbT.js",
|
||||
"houseplan-assets/fr-T10CV6-a.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
|
||||
"houseplan-assets/de-JVEEGD9u.js",
|
||||
"houseplan-assets/editor-C5Zq-3aA.js",
|
||||
"houseplan-assets/fr-B-ete5Dh.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
|
||||
"houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
|
||||
"houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js",
|
||||
"houseplan-assets/hp-zigbee-topology-overlay-D3IWAxB8.js",
|
||||
"houseplan-assets/iso-scene-render-ceJKJKhC.js",
|
||||
"houseplan-assets/live-interaction-runtime-CxW-k-p_.js",
|
||||
"houseplan-assets/pdf-export-BAtsOfAV.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-DJMGYUqW.js",
|
||||
"sha256": "bc5163222924f68e6279b412b16a53f28a76b5b4d80d4a03e7fe28c4cce77a39",
|
||||
"rawBytes": 671972,
|
||||
"gzipBytes": 178859,
|
||||
"path": "houseplan-assets/houseplan-editor-runtime-BNMtEwiN.js",
|
||||
"sha256": "7a0814ddc88324ad5dcb87f43852bd1f92ed90e4bda658cab3a8cdfee3209c18",
|
||||
"rawBytes": 669271,
|
||||
"gzipBytes": 178643,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/backdrop-pick-Caayxvt2.js",
|
||||
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js",
|
||||
"houseplan-assets/zigbee-topology-DlU7AXvw.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-Cdd3N5hN.js",
|
||||
"sha256": "31ea22267928606938b34c47f1ac8ecc2041048bc8c045a411d7108d353de849",
|
||||
"rawBytes": 28237,
|
||||
"gzipBytes": 7004,
|
||||
"path": "houseplan-assets/houseplan-onboarding-runtime-D2XH4RQC.js",
|
||||
"sha256": "6484dea33a514dff985b752d3f3ff87086a87ab2e46e79d29773d382d9fe4695",
|
||||
"rawBytes": 28287,
|
||||
"gzipBytes": 7033,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/backdrop-pick-CXOE1u8Z.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/backdrop-pick-Caayxvt2.js",
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-HyXCDpzQ.js",
|
||||
"sha256": "0e18deb183cb473c2a96fb689c91c68a3729b8ad56fb8907b01c30810585dbec",
|
||||
"rawBytes": 11034,
|
||||
"gzipBytes": 3731,
|
||||
"path": "houseplan-assets/hp-zigbee-topology-overlay-D3IWAxB8.js",
|
||||
"sha256": "1dd14e9ee7bb06dd0ca154b471e16f410abc14c0beb5e310266f71514e770f9b",
|
||||
"rawBytes": 10756,
|
||||
"gzipBytes": 3685,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js",
|
||||
"houseplan-assets/zigbee-topology-DlU7AXvw.js",
|
||||
"houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/iso-scene-render-miMf0jvt.js",
|
||||
"sha256": "00fd101b4e5469f6d289b77eab3056bedf98082520af9139a1afff5823fcb025",
|
||||
"rawBytes": 38562,
|
||||
"gzipBytes": 13084,
|
||||
"path": "houseplan-assets/iso-scene-render-ceJKJKhC.js",
|
||||
"sha256": "e7032d13f4479fa28d8b378c900a339577e74f5c377da9b080697e20e02f5903",
|
||||
"rawBytes": 39206,
|
||||
"gzipBytes": 13232,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/guard-7S6iRB0N.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/live-interaction-runtime-Dahsj-4y.js",
|
||||
"sha256": "631b277aae71d9977fabb3f330c4a9cdf38e47ec51259584540b747d9c9444df",
|
||||
"rawBytes": 5196,
|
||||
"gzipBytes": 2124,
|
||||
"path": "houseplan-assets/live-interaction-runtime-CxW-k-p_.js",
|
||||
"sha256": "3b0f3ae94e957cb662f63abeb5b7fb95012da6654a0ad5a857cf7b1b6a4cb6ac",
|
||||
"rawBytes": 8216,
|
||||
"gzipBytes": 3209,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/pdf-export-DpHlViMV.js",
|
||||
"sha256": "48b07b9ecfbbf2c1a8de53edce8bc32b1217e2c2f20eb5ceb24af75999870d8a",
|
||||
"rawBytes": 237254,
|
||||
"gzipBytes": 110114,
|
||||
"path": "houseplan-assets/pdf-export-BAtsOfAV.js",
|
||||
"sha256": "2e57254cc4a637a6ed689ee1de9af9f4dc60595489683b238ead4026f7e4d60b",
|
||||
"rawBytes": 236244,
|
||||
"gzipBytes": 109873,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/furniture-plan-art.generated-C-yj8ZTi.js",
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/furniture-plan-art.generated-MsQg9zvn.js",
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-editor-DOpNDtK_.js",
|
||||
"sha256": "4ed28cc81b7f0ccac46e0fb6ac37856ddbdf7813040585447089bf4a0a61805f",
|
||||
"rawBytes": 14811,
|
||||
"gzipBytes": 3390,
|
||||
"path": "houseplan-assets/summary-panel-editor-BAGJ8QwD.js",
|
||||
"sha256": "72d3ad8f8d61228dc255eed21ff157ad9c96ee8a35bd7b7b999f4090adb1925c",
|
||||
"rawBytes": 14824,
|
||||
"gzipBytes": 3401,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js",
|
||||
"houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-metrics-DXF83oAy.js",
|
||||
"sha256": "51953d004b76561a842f67b9455985cceac2663c52499d1e1acc3a3cdfdaded9",
|
||||
"rawBytes": 2195,
|
||||
"gzipBytes": 1121,
|
||||
"path": "houseplan-assets/summary-panel-metrics-CXnAEe7_.js",
|
||||
"sha256": "c73f5de8717a8a7d5e7e0d0861fb9d007876b50c4c62f97b3c7f1739cd5bb38f",
|
||||
"rawBytes": 2610,
|
||||
"gzipBytes": 1304,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-2X-IAkWu.js",
|
||||
"sha256": "7598bcc5d26d8d2f9a15cc154c32a0b836ba0ac53b7b5f9b65f3c2c2bc0287d1",
|
||||
"rawBytes": 69530,
|
||||
"gzipBytes": 19034,
|
||||
"path": "houseplan-assets/summary-panel-runtime-loaded-BcKBuVQ7.js",
|
||||
"sha256": "8609218f07a5ec5399a2ba61707a2854dfaa65e80b11dc628ddb3ece70d77f72",
|
||||
"rawBytes": 73366,
|
||||
"gzipBytes": 20089,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": [
|
||||
"houseplan-assets/summary-panel-editor-DOpNDtK_.js",
|
||||
"houseplan-assets/summary-panel-metrics-DXF83oAy.js"
|
||||
"houseplan-assets/summary-panel-editor-BAGJ8QwD.js",
|
||||
"houseplan-assets/summary-panel-metrics-CXnAEe7_.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-DOkBU6gZ.js",
|
||||
"sha256": "0176688590ae76a8766ee735de892e37f29211a042c27f5a2992eda0cc8ef8d9",
|
||||
"path": "houseplan-assets/zigbee-topology-DlU7AXvw.js",
|
||||
"sha256": "f7f21945cb650b57fa6469a7205aa687415b208f9649e2e8bae9dd27aa6cf4e6",
|
||||
"rawBytes": 18961,
|
||||
"gzipBytes": 7424,
|
||||
"gzipBytes": 7425,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CiDfY5zA.js",
|
||||
"sha256": "6b43a567d3686249519d43ef6f04bb9596529d99d04f745944c0e561c0e3034e",
|
||||
"path": "houseplan-assets/zigbee-topology-runtime-CWcX1vcd.js",
|
||||
"sha256": "7ae2a9b1aac6ea4ee128341cce087684b546efd2788b97fa6941138ddb702f9f",
|
||||
"rawBytes": 3941,
|
||||
"gzipBytes": 1762,
|
||||
"gzipBytes": 1760,
|
||||
"isEntry": false,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js",
|
||||
"houseplan-assets/zigbee-topology-DOkBU6gZ.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js",
|
||||
"houseplan-assets/zigbee-topology-DlU7AXvw.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-card.js",
|
||||
"sha256": "b8095427f52e7413e84d3202953d15765fbd43d4536226beff01ba7ff692ae2e",
|
||||
"sha256": "642174a115a5be423b55f552e351f4445e58f744c22c4eb1378fc376ef16d65b",
|
||||
"rawBytes": 1205,
|
||||
"gzipBytes": 803,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-assets/houseplan-card-Bj6mVEeM.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
},
|
||||
{
|
||||
"path": "houseplan-panel.js",
|
||||
"sha256": "f0a1709a58213029c08e73da2ed81b217827c8f5cdbf96e082385cfb608eabcc",
|
||||
"rawBytes": 6124,
|
||||
"gzipBytes": 2302,
|
||||
"sha256": "8383bd59c693f7f9cc4b2aee4886b6a9fb90a715d91630e6473ce3aeae09216b",
|
||||
"rawBytes": 6165,
|
||||
"gzipBytes": 2325,
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"houseplan-card.js"
|
||||
"houseplan-assets/houseplan-card-BCGpvbDT.js"
|
||||
],
|
||||
"dynamicImports": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{q as e,l as o,v as t,e3 as s,A as a,e4 as i,e5 as l,e6 as n,E as r,c}from"./houseplan-card-BCGpvbDT.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1,14 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{t as e,l as o,x as t,e4 as s,A as a,e5 as i,e6 as l,e7 as n,dV as r,b as c}from"./houseplan-card-Bj6mVEeM.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._formData}
|
||||
.schema=${e}
|
||||
.computeLabel=${e=>h[e.name]||e.name}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>`;return c`
|
||||
${d(f.slice(0,3))}
|
||||
${_?c`<div class="default-floor-error" role="alert"
|
||||
style="color:var(--error-color,#db4437);margin:-4px 0 12px;overflow-wrap:anywhere">
|
||||
${t(s,"editor.default_floor_missing",{id:_})}
|
||||
</div>`:a}
|
||||
${d(f.slice(3))}
|
||||
`}_valueChanged(e){const o={...this._config,...e.detail.value};""===o.floor?delete o.floor:o.floor===this._floorToken&&(o.floor=this._config?.floor);const t=new Event("config-changed",{bubbles:!0,composed:!0});t.detail={config:o},this.dispatchEvent(t)}}h.properties={hass:{attribute:!1},_config:{state:!0},_spaces:{state:!0}},customElements.get("houseplan-card-editor")||customElements.define("houseplan-card-editor",h);
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{dT as r,dU as e,dV as t}from"./houseplan-card-Bj6mVEeM.js";const s={},a=r(class extends e{constructor(){super(...arguments),this.ot=s}render(r,e){return e()}update(r,[e,s]){if(Array.isArray(e)){if(Array.isArray(this.ot)&&this.ot.length===e.length&&e.every((r,e)=>r===this.ot[e]))return t}else if(this.ot===e)return t;return this.ot=Array.isArray(e)?Array.from(e):e,this.render(e,s)}});export{a as i};
|
||||
@@ -1,4 +1,4 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{b as a,A as e}from"./houseplan-card-Bj6mVEeM.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-2X-IAkWu.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{c as a,A as e}from"./houseplan-card-BCGpvbDT.js";import{s,n as t,a as l,m as r,b as o,c as i,d as u}from"./summary-panel-runtime-loaded-BcKBuVQ7.js";const m=m=>{const{host:c,dialog:d,problems:n,t:b}=m,y=n.find(a=>"error"===a.kind),$=d.localShow!==d.baseLocalShow||!d.localOnly&&!s(t(d.draft),d.base),p=$&&!d.busy&&!y,v=a=>n.find(e=>e.path===a),h=a=>a?b(`summary.problem.${a.code}`):"",k=()=>m.close(),g=a`<label class="summary-switch" for="summary-local-show">
|
||||
<span class="summary-switch-caption"><strong>${b("summary.show_local")}</strong>
|
||||
<small id="summary-local-show-hint">${b("summary.show_local_hint")}</small></span>
|
||||
<input id="summary-local-show" data-summary-local-show type="checkbox"
|
||||
@@ -6,7 +6,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c46
|
||||
?disabled=${d.busy} aria-label=${b("summary.show_local")}
|
||||
aria-describedby="summary-local-show-hint"
|
||||
@change=${a=>m.setDialog({...d,localShow:a.target.checked})} /></label>`;return a`<hp-dialog .hass=${c.hass} data-kind="summary"
|
||||
.title=${b("summary.settings")} wide
|
||||
.title=${b("summary.settings")} wide flex-content
|
||||
dismiss-on-scrim aria-busy=${String(d.busy)}
|
||||
@hp-close=${k}>
|
||||
<div class="body summary-editor" @click=${()=>m.closeSource()}>
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{e9 as e,P as t,a5 as n,M as i,aq as r,N as o,br as a,bq as s,a4 as c,a3 as l,aA as u,ea as d,eb as f,bt as m,ec as g}from"./houseplan-card-BCGpvbDT.js";function v(e){if(!e.registry.authoritative)return null;const t=new Set,n=u(e.markers);for(const i of Object.values(e.registry.devices||{}))i?.id&&"service"!==i.entry_type&&!n.devices.has(i.id)&&i.area_id&&e.areaToSpace[i.area_id]&&t.add(i.id);for(const i of e.markers||[]){if(i.removed||"virtual"===i.binding)continue;const r=String(i.binding||"").indexOf(":");if(r<1)continue;const o=i.binding.slice(0,r),a=i.binding.slice(r+1),s="device"===o?e.registry.devices?.[a]:null,c="entity"===o?e.registry.entities?.[a]:null,l="device"===o?a:c?.device_id,u="device"===o?s?.area_id:c?.area_id||l&&e.registry.devices?.[l]?.area_id,d="string"==typeof i.room_id&&i.room_id.length>0&&null===i.area?"":i.area||u||"",f=d&&e.areaToSpace[d]||i.space||e.firstSpaceId;l&&e.registry.devices?.[l]&&e.spaceIds.has(f)&&(!n.devices.has(l)||"entity"===o&&n.liveEntities.has(a))&&t.add(l)}return t}function y(e,t){return e?.length?t?.length?g(e,t):e:t}function _(e,t){const n=c(e.rooms,t.walls,t.openCuts,[],i,t.cellCm,r,o);return{roomGeom:"ok"===n.status||"degraded-extra"===n.status?n.roomGeom:void 0,multiWallNodes:l(e.rooms,t.walls,t.openCuts,i,t.cellCm,r,o)}}function*h(c,l,u=_){try{let d=0;for(const f of l){const l=c.spaces.find(e=>String(e?.id)===f.id);if(!l)continue;const m=e(l,f),g=u(f,m);let v=null;for(const e of f.rooms){if(!e.id)continue;const s=t(e);if(!s)continue;const c=n(f.rooms,e.id,m.walls,m.openCuts,i,m.cellCm,r,o,g.roomGeom,g.multiWallNodes)||s;v=y(v,a(c,m.physicalBodies)),yield}const _=m.cellCm/r;d+=s(v)*_*_/1e4}return d}catch{return null}}function p(e,t,n=_){const i=h(e,t,n);let r=i.next();for(;!r.done;)r=i.next();return r.value}function b(e,t,n,i){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:m(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(i||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function S(e,t){const n=e?.states?.[t];if(!n)return null;const i=d(e,t);return i?f(i,String(n.attributes?.unit_of_measurement||"")):null}export{h as cleanFloorAreaSteps,v as representedHaDeviceIds,_ as spaceWallGeometry,S as summaryEntityValue,b as summarySystemValue,p as totalCleanFloorAreaM2};
|
||||
@@ -1 +0,0 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{az as e,ea as t,eb as n,bs as i,ec as r,P as a,a5 as s,M as o,aq as c,N as l,bq as d,bp as u,ed as f}from"./houseplan-card-Bj6mVEeM.js";function g(t){if(!t.registry.authoritative)return null;const n=new Set,i=e(t.markers);for(const e of Object.values(t.registry.devices||{}))e?.id&&"service"!==e.entry_type&&!i.devices.has(e.id)&&e.area_id&&t.areaToSpace[e.area_id]&&n.add(e.id);for(const e of t.markers||[]){if(e.removed||"virtual"===e.binding)continue;const r=String(e.binding||"").indexOf(":");if(r<1)continue;const a=e.binding.slice(0,r),s=e.binding.slice(r+1),o="device"===a?t.registry.devices?.[s]:null,c="entity"===a?t.registry.entities?.[s]:null,l="device"===a?s:c?.device_id,d="device"===a?o?.area_id:c?.area_id||l&&t.registry.devices?.[l]?.area_id,u="string"==typeof e.room_id&&e.room_id.length>0&&null===e.area?"":e.area||d||"",f=u&&t.areaToSpace[u]||e.space||t.firstSpaceId;l&&t.registry.devices?.[l]&&t.spaceIds.has(f)&&(!i.devices.has(l)||"entity"===a&&i.liveEntities.has(s))&&n.add(l)}return n}function m(e,t){return e?.length?t?.length?f(e,t):e:t}function v(e,t){try{let n=0;for(const i of t){const t=e.spaces.find(e=>String(e?.id)===i.id);if(!t)continue;const f=r(t,i);let g=null;for(const e of i.rooms){if(!e.id)continue;const t=a(e);if(!t)continue;const n=s(i.rooms,e.id,f.walls,f.openCuts,o,f.cellCm,c,l)||t;g=m(g,d(n,f.physicalBodies))}const v=f.cellCm/c;n+=u(g)*v*v/1e4}return n}catch{return null}}function y(e,t,n,r){if("system"!==e.type)return null;if("device_count"===e.key)return null===t.deviceCount?null:String(t.deviceCount);if("total_area"===e.key)return null===t.areaM2?null:i(t.areaM2,"mi"===n?.config?.unit_system?.length);try{return new Intl.DateTimeFormat(r||void 0,{dateStyle:"short",timeStyle:"short",timeZone:n?.config?.time_zone||void 0}).format(t.now)}catch{return t.now.toLocaleString()}}function _(e,i){const r=e?.states?.[i];if(!r)return null;const a=t(e,i);return a?n(a,String(r.attributes?.unit_of_measurement||"")):null}export{g as representedHaDeviceIds,_ as summaryEntityValue,y as summarySystemValue,v as totalCleanFloorAreaM2};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";import{n as t,b as n}from"./zigbee-topology-DOkBU6gZ.js";import{F as e}from"./houseplan-card-Bj6mVEeM.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function p(t){if(!0!==t?.user?.is_admin)throw a("permission")}function f(n){const e=i(n);return e?c(e,"zha",async()=>{if(p(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function g(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function m(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(p(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,f=null,m=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{f=t,m=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const p=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&f?.(n):c&&m?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof p&&b.push(p),await g(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await g(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{f as readZhaTopology,m as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";import{n as t,b as n}from"./zigbee-topology-DlU7AXvw.js";import{F as e}from"./houseplan-card-BCGpvbDT.js";const o=new WeakMap;function i(t){const n=function(t){const n=t?.connection||t;return!n||"object"!=typeof n&&"function"!=typeof n?null:n}(t);if(!n)return null;let e=o.get(n);return e||(e={revision:0,topologies:[],states:{},listeners:new Set,inflight:new Map},o.set(n,e)),e}function r(t,n,e){t.states={...t.states,[n]:e},function(t){t.revision++;for(const n of t.listeners)n()}(t)}function s(t){const n=t?.code;if("permission"===n||"unsupported"===n||"timeout"===n||"invalid_topic"===n||"invalid_payload"===n)return n;const e=String(t?.message||"").toLowerCase();return e.includes("unauthor")||e.includes("permission")?"permission":e.includes("unknown_command")||e.includes("not found")?"unsupported":"provider"}function a(t){return Object.assign(new Error(t),{code:t})}function c(t,n,e){const o=t.inflight.get(n);if(o)return o;r(t,n,{phase:"loading"});const i=e().then(n=>{if(!n.nodes.length&&n.warnings.some(t=>"invalid_payload"===t.code))throw a("invalid_payload");!function(t,n){t.topologies=[...t.topologies.filter(t=>!(t.provider===n.provider&&t.instanceId===n.instanceId)),n],r(t,"zha"===n.provider?"zha":`z2m:${n.instanceId}`,{phase:"ready",obtainedAt:n.obtainedAt,partial:n.warnings.length>0})}(t,n)}).catch(e=>{r(t,n,{phase:"error",error:s(e)})}).finally(()=>t.inflight.delete(n));return t.inflight.set(n,i),i}function l(t){const n=i(t);return n?{revision:n.revision,topologies:n.topologies,states:n.states}:{revision:0,topologies:[],states:{}}}function u(t,n){const e=i(t);return e?(e.listeners.add(n),()=>e.listeners.delete(n)):()=>{}}function f(t){if(!0!==t?.user?.is_admin)throw a("permission")}function p(n){const e=i(n);return e?c(e,"zha",async()=>{if(f(n),"function"!=typeof n?.callWS)throw a("unsupported");return t(await n.callWS({type:"zha/devices"}))}):Promise.resolve()}function d(t){return null!==t&&"object"==typeof t?t:null}function h(t){const n=d(t),e=n?.payload??t;if("string"!=typeof e)return e;try{return JSON.parse(e)}catch{return null}}async function m(t,n){let e;try{return await Promise.race([t,new Promise((t,o)=>{e=globalThis.setTimeout(()=>o(a("timeout")),Math.max(1,n))})])}finally{void 0!==e&&globalThis.clearTimeout(e)}}function g(t,o,r=15e4){const s=i(t);if(!s)return Promise.resolve();const l=e(o);return c(s,`z2m:${l||String(o)}`,async()=>{if(f(t),!l)throw a("invalid_topic");const e=t.connection,o=e?.subscribeMessage;if("function"!=typeof o||"function"!=typeof t?.callService)throw a("unsupported");const i=function(){const t=globalThis.crypto;return"function"==typeof t?.randomUUID?`houseplan-${t.randomUUID()}`:`houseplan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}(),s=Date.now()+Math.max(1,r);let c=!1,u=null,p=null,g=null;const y=new Promise(t=>{u=t}),w=new Promise((t,n)=>{p=t,g=n}),b=[];try{const r=await o.call(e,t=>{!0===d(t)?.retain&&h(t)&&u?.()},{type:"mqtt/subscribe",topic:`${l}/bridge/info`});"function"==typeof r&&b.push(r);const f=await o.call(e,t=>{if(!0===d(t)?.retain)return;const n=h(t);null!==n?n&&function(t){const n=d(t),e=n?.transaction??d(n?.data)?.transaction;return"string"==typeof e||"number"==typeof e?String(e):null}(n)===i&&p?.(n):c&&g?.(a("invalid_payload"))},{type:"mqtt/subscribe",topic:`${l}/bridge/response/networkmap`});"function"==typeof f&&b.push(f),await m(y,Math.min(4e3,Math.max(1,s-Date.now()))),c=!0,await t.callService("mqtt","publish",{topic:`${l}/bridge/request/networkmap`,payload:JSON.stringify({type:"raw",routes:!1,transaction:i}),qos:0,retain:!1});const v=await m(w,s-Date.now()),_=d(v)?.status;if(_&&"ok"!==_)throw a("provider");return n(v,l)}finally{for(const t of b)try{t()}catch{}}})}export{p as readZhaTopology,g as refreshZ2mTopology,u as subscribeZigbeeTopology,l as zigbeeTopologyRuntimeSnapshot};
|
||||
@@ -1 +1 @@
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="95d32c120d1ca37feb5a930e4a20e5ec2c460f82a52d88a41fe0d143f697ae74";try{await import("./houseplan-assets/houseplan-card-Bj6mVEeM.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="0aa07f15a35e57da89774909bc60ec4f962331aec1df3963cc08462a0c3abf5e";try{await import("./houseplan-assets/houseplan-card-BCGpvbDT.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale houseplan-card.js: the implementation chunk is unavailable",e)}
|
||||
|
||||
@@ -451,6 +451,7 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
partial(
|
||||
check_quota, files_root, tmp_path.stat().st_size,
|
||||
MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,
|
||||
additional_disk_bytes=0,
|
||||
),
|
||||
)
|
||||
except QuotaError as err:
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.73.0"
|
||||
"version": "1.76.0-beta.3"
|
||||
}
|
||||
|
||||
@@ -222,9 +222,20 @@ def dir_usage(path: Path, *, exclude: Path | None = None) -> tuple[int, int]:
|
||||
|
||||
|
||||
def check_quota(
|
||||
path: Path, incoming: int, max_bytes: int, max_files: int, *, exclude: Path | None = None,
|
||||
path: Path,
|
||||
incoming: int,
|
||||
max_bytes: int,
|
||||
max_files: int,
|
||||
*,
|
||||
exclude: Path | None = None,
|
||||
additional_disk_bytes: int | None = None,
|
||||
) -> None:
|
||||
"""Raise QuotaError unless `incoming` more bytes fit (`exclude`: see dir_usage).
|
||||
"""Raise QuotaError unless `incoming` fits the store and disk.
|
||||
|
||||
`exclude` is omitted from current store usage but still charged as
|
||||
`incoming`. `additional_disk_bytes` is the part not physically written yet;
|
||||
by default all incoming bytes still need disk space. A staged file already
|
||||
under `path` passes zero because promotion only renames it (#554).
|
||||
|
||||
Deliberately not an age rule. Files are never removed for getting old — that
|
||||
cost real plans twice — so the limit sits where a decision is being made
|
||||
@@ -245,7 +256,8 @@ def check_quota(
|
||||
free = shutil.disk_usage(str(path if path.is_dir() else path.parent)).free
|
||||
except OSError:
|
||||
return
|
||||
if free - incoming < MIN_FREE_BYTES:
|
||||
disk_incoming = incoming if additional_disk_bytes is None else additional_disk_bytes
|
||||
if free - disk_incoming < MIN_FREE_BYTES:
|
||||
raise QuotaError("low_disk_space", f"only {free // 1024 // 1024} MB free on the disk")
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,17 @@ RADAR_PROFILES = frozenset({
|
||||
"esphome_ld2450_v1", "cartesian_v1", "polar_v1", "range_v1",
|
||||
"zones_v1", "presence_v1",
|
||||
})
|
||||
_COMMON_SOURCE_ROLES = (
|
||||
"occupancy_entity", "count_entity", "availability_entity",
|
||||
)
|
||||
_PROFILE_SOURCE_ROLES: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"esphome_ld2450_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
|
||||
"cartesian_v1": ("slots", ("x_entity", "y_entity", "presence_entity")),
|
||||
"polar_v1": ("slots", ("distance_entity", "angle_entity", "presence_entity")),
|
||||
"range_v1": ("ranges", ("entity_id", "presence_entity")),
|
||||
"zones_v1": ("zones", ("entity_id",)),
|
||||
"presence_v1": ("", ()),
|
||||
}
|
||||
LENGTH_UNITS = frozenset({"mm", "cm", "m", "in", "ft"})
|
||||
ANGLE_UNITS = frozenset({"degrees", "radians"})
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
@@ -90,18 +101,23 @@ def _point(value: Any, name: str) -> tuple[float, float]:
|
||||
)
|
||||
|
||||
|
||||
def _source_ids(sources: dict[str, Any]) -> set[str]:
|
||||
def _source_ids(profile: Any, sources: dict[str, Any]) -> set[str]:
|
||||
profile_roles = _PROFILE_SOURCE_ROLES.get(profile)
|
||||
if profile_roles is None:
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for key in ("occupancy_entity", "count_entity", "availability_entity"):
|
||||
for key in _COMMON_SOURCE_ROLES:
|
||||
value = sources.get(key)
|
||||
if isinstance(value, str):
|
||||
out.add(value)
|
||||
for group in ("slots", "ranges", "zones"):
|
||||
group, roles = profile_roles
|
||||
if group:
|
||||
for item in sources.get(group) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for key, value in item.items():
|
||||
if key.endswith("_entity") and isinstance(value, str):
|
||||
for key in roles:
|
||||
value = item.get(key)
|
||||
if isinstance(value, str):
|
||||
out.add(value)
|
||||
return out
|
||||
|
||||
@@ -111,7 +127,8 @@ def radar_source_entity_ids(radar: Any) -> set[str]:
|
||||
if not isinstance(radar, dict):
|
||||
return set()
|
||||
sources = radar.get("sources")
|
||||
out = _source_ids(sources) if isinstance(sources, dict) else set()
|
||||
out = _source_ids(radar.get("profile"), sources) \
|
||||
if isinstance(sources, dict) else set()
|
||||
return out
|
||||
|
||||
|
||||
@@ -154,7 +171,7 @@ def _validate_verified_adapter(
|
||||
if profile != "esphome_ld2450_v1" or registry is None:
|
||||
return
|
||||
entities = registry.get("entities") or {}
|
||||
source_ids = _source_ids(sources)
|
||||
source_ids = _source_ids(profile, sources)
|
||||
rows = [entities.get(entity_id) for entity_id in source_ids]
|
||||
device_ids = {
|
||||
str(row.get("device_id"))
|
||||
|
||||
@@ -180,7 +180,11 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
|
||||
except (TypeError, ValueError):
|
||||
return # CONFIG_SCHEMA owns malformed values.
|
||||
previous = previous or {}
|
||||
if old_model >= 10 and new_model < 10 and any(
|
||||
stored_drafts = any(
|
||||
"room_drafts" in space for space in previous.get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
)
|
||||
if old_model >= 10 and not stored_drafts and any(
|
||||
"room_drafts" in space for space in config.get("spaces") or []
|
||||
if isinstance(space, dict)
|
||||
):
|
||||
@@ -188,6 +192,15 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None:
|
||||
# projection at all. The old catalogue guard therefore cannot see the
|
||||
# destructive downgrade, but v10 must never persist the removed
|
||||
# carrier again (#478 AC2).
|
||||
#
|
||||
# #529: the submitted model number is not the tell. A stale card
|
||||
# echoes back the `model_version` it was given — the current one — so
|
||||
# the old `new_model < 10` condition let exactly the outdated client
|
||||
# through and the user met an unrelated "conflicting wall identifiers"
|
||||
# instead of "update the card and reload the page". What identifies the
|
||||
# stale writer is the carrier itself appearing over a stored plan that
|
||||
# does not have it; legacy left in storage is not a stale writer and is
|
||||
# healed by the migration instead (#529 K4).
|
||||
raise WallModelClientOutdatedError(
|
||||
f"stored model={old_model}; submitted legacy room_drafts model={new_model}"
|
||||
)
|
||||
@@ -1903,7 +1916,11 @@ def _config_wall_segment_invariants(value: dict) -> dict:
|
||||
if model >= 9:
|
||||
if "open_spans" in space or any("open_to" in room for room in space.get("rooms", [])):
|
||||
raise vol.Invalid("v9 config must not contain legacy open boundaries")
|
||||
if model >= 10 and "room_drafts" in space:
|
||||
# #529: пустой ключ данных не несёт и записи не стоит. Непустой
|
||||
# остаётся отказом — это последняя линия против устаревшего клиента,
|
||||
# и к ней человек приходит уже с понятным сообщением от сторожа
|
||||
# переходов.
|
||||
if model >= 10 and space.get("room_drafts"):
|
||||
raise vol.Invalid("v10 config must not contain room_drafts")
|
||||
segments = space.get("wall_segments")
|
||||
if segments is None:
|
||||
|
||||
@@ -644,14 +644,21 @@ def _host_openings(
|
||||
opening.pop("host", None)
|
||||
|
||||
|
||||
def _migrate_room_drafts_to_partitions(
|
||||
space: dict[str, Any], initial_migration: bool,
|
||||
) -> tuple[int, int]:
|
||||
def _migrate_room_drafts_to_partitions(space: dict[str, Any]) -> tuple[int, int]:
|
||||
if "room_drafts" not in space:
|
||||
return 0, 0
|
||||
if not initial_migration:
|
||||
raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")
|
||||
drafts = space.get("room_drafts") or []
|
||||
# #529: наследие снимается, а не запирает план. Отказ здесь стоил
|
||||
# пользователю всего: карточка считает ту же миграцию своим зеркалом, так
|
||||
# что структурная правка отбивалась ещё до отправки, «Optimize plans» на
|
||||
# эту ветку не влияет, а экспорт зовёт эту же функцию — забрать бэкап и
|
||||
# починить файл руками тоже было нельзя. Защита от устаревшего клиента
|
||||
# (#478) переехала на свой слой: `validate_wall_model_transition` ловит
|
||||
# заявку с черновиками поверх чистого сохранённого конфига и отвечает
|
||||
# человеку «обновите карточку», а не загадкой про идентификаторы.
|
||||
if not drafts:
|
||||
space.pop("room_drafts", None)
|
||||
return 0, 0
|
||||
used = {
|
||||
str(item["id"])
|
||||
for name in ("rooms", "openings", "decor", "partitions",
|
||||
@@ -709,9 +716,7 @@ def _migrate_room_drafts_to_partitions(
|
||||
def _migrate_space(
|
||||
space: dict[str, Any], initial_migration: bool,
|
||||
) -> tuple[int, int, int]:
|
||||
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(
|
||||
space, initial_migration,
|
||||
)
|
||||
migrated_drafts, migrated_draft_segments = _migrate_room_drafts_to_partitions(space)
|
||||
old: dict[str, dict[str, Any]] = {}
|
||||
for segment in space.get("wall_segments") or []:
|
||||
segment_id = str(segment.get("id", ""))
|
||||
|
||||
@@ -142,7 +142,7 @@ try {
|
||||
longTasks: await longTasks.stop(),
|
||||
};
|
||||
};
|
||||
// Current candidates split Stage 3 out of the initial graph. Comparison
|
||||
// Current candidates split the experimental Iso runtime out of the initial graph. Comparison
|
||||
// bundles remain valid: their older monolithic renderer has no preload
|
||||
// hook and is already available synchronously.
|
||||
const ensureIsoRuntime = async (card) => {
|
||||
@@ -249,11 +249,11 @@ try {
|
||||
const missingKinds = expectedKinds.filter((kind) => !snapshot.overlayKinds.includes(kind));
|
||||
const failures = [];
|
||||
if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');
|
||||
if (snapshot.isoStageRevision !== '3') failures.push('Stage 3 revision marker is absent');
|
||||
if (snapshot.isoStageRevision !== '4') failures.push('Stage 4 revision marker is absent');
|
||||
if (snapshot.structuralBuilds == null) failures.push('structural build counter is absent');
|
||||
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
|
||||
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
|
||||
failures.push('one or more Stage 3 overlay roots remained on the floor plane');
|
||||
failures.push('one or more Stage 4 overlay roots remained outside the low visual plane');
|
||||
if (snapshot.nudgedOverlayCount < 1) failures.push('dense fixture produced no bounded nudge');
|
||||
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
|
||||
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
|
||||
@@ -262,11 +262,11 @@ try {
|
||||
const stage3 = snapshot.stage3RootsByKind[kind];
|
||||
if (total < 1) failures.push(`no rendered ${kind} roots`);
|
||||
else if (stage3 !== total)
|
||||
failures.push(`${kind} Stage 3 roots ${stage3}/${total}`);
|
||||
failures.push(`${kind} Stage 4 roots ${stage3}/${total}`);
|
||||
}
|
||||
for (const kind of fixture.stage3Dense.expectedOpeningKinds) {
|
||||
if (!(snapshot.openingSurfaceCounts[kind] > 0))
|
||||
failures.push(`no Stage 3 ${kind} opening surfaces`);
|
||||
failures.push(`no Stage 4 ${kind} opening surfaces`);
|
||||
}
|
||||
for (const [facet, count] of Object.entries(snapshot.facetCounts)) {
|
||||
if (!(count > 0)) failures.push(`no observable ${facet} facet${facet === 'pulse'
|
||||
@@ -280,7 +280,7 @@ try {
|
||||
if (!(snapshot.definitionCounts.patterns > 0)) failures.push('no shared texture pattern');
|
||||
if (!(snapshot.definitionCounts.filters > 0)) failures.push('no shared shadow filter');
|
||||
if (failures.length)
|
||||
throw new Error(`Stage 3 ${label} contract failed: ${failures.join('; ')}`);
|
||||
throw new Error(`Stage 4 ${label} contract failed: ${failures.join('; ')}`);
|
||||
};
|
||||
|
||||
window.__card?.remove?.();
|
||||
@@ -294,6 +294,41 @@ try {
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
// #520 diagnostics: where the extra half second of model readiness goes.
|
||||
// Counts Lit update cycles and model builds; printed, never budgeted.
|
||||
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
|
||||
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
|
||||
// what rebuilds the model a second time (#520).
|
||||
let diagEpoch = 0;
|
||||
Object.defineProperty(card, '_cfgEpoch', {
|
||||
configurable: true,
|
||||
get: () => diagEpoch,
|
||||
set: (next) => {
|
||||
if (next !== diagEpoch) {
|
||||
const frames = (new Error().stack || '').split('\n').slice(1, 4)
|
||||
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
|
||||
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
|
||||
}
|
||||
diagEpoch = next;
|
||||
},
|
||||
});
|
||||
const diagPerform = card.performUpdate.bind(card);
|
||||
card.performUpdate = function () {
|
||||
const started = performance.now();
|
||||
const result = diagPerform();
|
||||
card.__diag.updates += 1;
|
||||
card.__diag.updateMs += performance.now() - started;
|
||||
return result;
|
||||
};
|
||||
const diagBuild = card._buildModel.bind(card);
|
||||
card._buildModel = function () { card.__diag.models += 1; return diagBuild(); };
|
||||
if (typeof card._adoptStructuralResponses === 'function') {
|
||||
const legacy = card._adoptStructuralResponses.bind(card);
|
||||
card._adoptStructuralResponses = function (...args) { card.__diag.adopts += 1; return legacy(...args); };
|
||||
} else if (typeof card._adoptAuthoritative === 'function') {
|
||||
const gated = card._adoptAuthoritative.bind(card);
|
||||
card._adoptAuthoritative = function (...args) { card.__diag.adopts += 1; return gated(...args); };
|
||||
}
|
||||
card.setConfig({
|
||||
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
|
||||
});
|
||||
@@ -363,6 +398,14 @@ try {
|
||||
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const bootDiag = {
|
||||
updates: card.__diag.updates,
|
||||
updateMs: Number(card.__diag.updateMs.toFixed(1)),
|
||||
models: card.__diag.models,
|
||||
adopts: card.__diag.adopts,
|
||||
cfgEpoch: card._cfgEpoch,
|
||||
epochs: card.__diag.epochs.slice(0, 8),
|
||||
};
|
||||
const initialProjection = typeof card._effectiveProjection === 'function'
|
||||
? card._effectiveProjection() : null;
|
||||
if (requiresIsometric && initialProjection !== 'iso')
|
||||
@@ -451,7 +494,7 @@ try {
|
||||
throw new Error(`${profile} performed a structural rebuild for an HA-only state update`);
|
||||
}
|
||||
if (requireStage3 && afterStateStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an HA-only state update`);
|
||||
throw new Error(`${profile} rebuilt Stage 4 structure for an HA-only state update`);
|
||||
|
||||
let stage3States = nextStates;
|
||||
const openingUpdate = stage3Dense ? await duration(async () => {
|
||||
@@ -469,7 +512,7 @@ try {
|
||||
if (stage3Dense && requiresIsometric && afterOpeningStage3?.effectiveProjection !== 'iso')
|
||||
throw new Error(`${profile} entered Flat fallback during an opening update`);
|
||||
if (requireStage3 && afterOpeningStage3.structuralBuilds !== steadyStage3.structuralBuilds)
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for an opening update`);
|
||||
throw new Error(`${profile} rebuilt Stage 4 structure for an opening update`);
|
||||
|
||||
const overlayInteraction = stage3Dense ? await duration(async () => {
|
||||
const overlay = card.renderRoot.querySelector(requireStage3
|
||||
@@ -495,7 +538,7 @@ try {
|
||||
throw new Error(`${profile} entered Flat fallback during a hover/focus update`);
|
||||
if (requireStage3
|
||||
&& afterInteractionStage3.structuralBuilds !== steadyStage3.structuralBuilds) {
|
||||
throw new Error(`${profile} rebuilt Stage 3 structure for a hover/focus update`);
|
||||
throw new Error(`${profile} rebuilt Stage 4 structure for a hover/focus update`);
|
||||
}
|
||||
|
||||
let interactionDiagnostics = null;
|
||||
@@ -1096,6 +1139,7 @@ try {
|
||||
|
||||
const result = {
|
||||
sample,
|
||||
bootDiag,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
@@ -1165,7 +1209,16 @@ try {
|
||||
interaction, requiresInteraction, stage3Dense, requireStage3,
|
||||
requiresIsoStructuralBuildCounter, profile,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
// #520: диагностика печатается в лог прогона и в запись не попадает.
|
||||
const { bootDiag, ...measured } = row;
|
||||
if (bootDiag) {
|
||||
console.log(`#520 diag sample ${row.sample}: updates=${bootDiag.updates}`
|
||||
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
|
||||
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
|
||||
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
|
||||
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
|
||||
}
|
||||
if (measuredSample >= 0) rows.push(measured);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -43,6 +43,17 @@ radial spokes visible instead of hiding them under a translucent room fill.
|
||||
CI turns green"; the second stops an empty or clipped frame from becoming the
|
||||
contract unseen (#350).
|
||||
|
||||
## Displayed version (#512)
|
||||
|
||||
Frames never show the real `CARD_VERSION`. The harness sets the test seam
|
||||
`window.__HP_VERSION_OVERRIDE__ = '0.0.0-golden'` before any card is created and
|
||||
feeds the same constant wherever it plays the backend (`integration_version`,
|
||||
support facts), so the about dialog, the version-recovery banner and the support
|
||||
and backup previews print `0.0.0-golden` in every baseline. A beta bump therefore
|
||||
changes no golden frame; the version-mismatch scenarios keep their own
|
||||
`0.0.0-golden-backend` and still exercise the frontend≠backend relation. The
|
||||
product never sets the seam (`src/card-version.ts`).
|
||||
|
||||
## Workflow
|
||||
|
||||
Build and copy the exact current source first:
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 61,
|
||||
"acceptedAt": "2026-09-09T07:34:37.356Z",
|
||||
"sourceFingerprint": "4c289ff2fbf0a2df9cd9ca8f6f3c286d9d2c67019a1ced4be717a7d101800575",
|
||||
"acceptedAt": "2026-09-14T14:31:43.727Z",
|
||||
"sourceFingerprint": "3bc41a18634ab9cd312dbbd6b4376ac85c55b70b5ad6928e5f7e07643e67aabf",
|
||||
"chromium": "151.0.7922.34",
|
||||
"platform": "linux",
|
||||
"platform": "win32",
|
||||
"witnesses": {
|
||||
"count": 112,
|
||||
"count": 101,
|
||||
"floor": 10
|
||||
},
|
||||
"scenarios": {
|
||||
"panel-wide-view-light-en": "2c93497871bc40f6401f6cb7e65e73bf98d0064bd2755ca2026135975bd2d9f3",
|
||||
"panel-wide-plan-editor-dark-en": "9404d7e3aa84b4b40a8cfeed27eea8831e55e9e6236abbb7cb092c709ca0dfcd",
|
||||
"panel-narrow-read-only-empty-light-en": "99b8f1af82b1ad13f237125aab8f7e7da0563c138e9da2ddf06e54c2d56138c4",
|
||||
"version-mismatch-desktop-dark-en": "8a693b1b676276ceef8c30dbd8dbccb375a4ce856690e1f2beb182242503743c",
|
||||
"version-mismatch-touch-light-ru": "6aa0d115da2ec6ba9081a57eb739b7531f860a4ad7a9716ff270bce7e82e803b",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "848b05c62360b532e3105212ea76730bd02320672d341e2cef90875bac7d87fd",
|
||||
"version-mismatch-desktop-dark-en": "b8059b768284208f605329618799d13491655e488e9d739a40b85cdc0eba5888",
|
||||
"version-mismatch-touch-light-ru": "df42d0158025371b82eabd522e0dd7d0b9121a9ba685970db3d7839d97fce50f",
|
||||
"version-mismatch-kiosk-attempted-dark-de": "2e3eaf2fb13d1cca77abe97938374b3e25235fc7db87bb6f7a2e72f1cafe4a7e",
|
||||
"hidden-wall-diagnostics-plan-light": "4113474796ccb1b7b8c876fcb463e97eb77bb1ec96129195a0dafdcc7ccca334",
|
||||
"hidden-wall-diagnostics-plan-dark": "40c48868f0251aec3b1a14c0eef77a78cc7965420fb9b41f46f8190d1fdfd11a",
|
||||
"coincident-partition-before-dark": "32d6d7e32db1bb579b33c783833ce986d10a81589e0783c5e7fae4b8c1e2755e",
|
||||
@@ -27,22 +27,22 @@
|
||||
"split-corner-wall-thin-dark": "0a8da611ecb6061852cc26ee53c00d12433cf8539681bd91dac3adad1d7e9e67",
|
||||
"split-corner-wall-thick-dark": "2efc65ddaa8777ebffe63205e2a961fa7053c91d3cd8c0af59810bef01ecd498",
|
||||
"split-zero-divider-taper-dark": "f7f9ee435da00dcae043c693127561a5e050675ca4d6f6f0d916ac5dbe24a594",
|
||||
"isometric-geometry-view-dark": "b061d186ffa614b97e9b0a2ba5f4b5378df54d75e168e5734d94c90289b4cfb0",
|
||||
"isometric-geometry-view-light": "585fffbb49dab8de8ca2c2af7d4d290c5355b9060029ea9bf38c72af594f2d5f",
|
||||
"isometric-live-layers-dark": "bf5dbe438ba74fada375966757620a6f8fc4641901f69000862145ca806085ed",
|
||||
"isometric-no-borders-dark": "792c221f9412825b80c56f5a195f896e3459298caf1623caebb3ad448463fe8d",
|
||||
"isometric-touch-kiosk-dark": "e609a2abba29e5441d57452b4f9d16a582f8f466f1a6f08cf81ef9f2a5391203",
|
||||
"isometric-large-warm-remount-dark": "fadfe8985736d3fdec62c971caceb34a91f9ba5698e27380e014dcd93f54a29b",
|
||||
"isometric-stage3-overlays-light": "9461cb2030234125b73beecc222a6119fdbd7f9d22b8dcec2bad6712210982bd",
|
||||
"isometric-stage3-overlays-dark": "ef589656803ebbb6436228b068df1ed99fc46c4313f15dc75940835506714f4f",
|
||||
"isometric-stage3-openings-dark": "7816adef15f9ea83a70572274baa6083875851fdeb17d9a8a77a4fe4feb92faa",
|
||||
"isometric-stage3-forced-colors-dark": "dd52ebc54962541c934b94dd7f436183ed7a65445bc90f201a2f9d2eefa93fa1",
|
||||
"isometric-stage3-no-filter-dark": "b2eeda001fa1d72c74aa0a06a996df205a7398dfc9f729406939a49a4c009a21",
|
||||
"isometric-geometry-view-dark": "2be45cda07281a2d7cce8afd00096e29ef9e424a6c4eff9bf01ac51d9ce1edc7",
|
||||
"isometric-geometry-view-light": "9ba4fc763db9e5c89163faf8c4c18fb22bff61107689d4bf9bb8cfd518a3b9a6",
|
||||
"isometric-live-layers-dark": "32ab62d8f04729732cae539b304f389aa1665db2d4a6c017f040ff68d8c61092",
|
||||
"isometric-no-borders-dark": "ec8952f4bbd4011aed80cf66c9fa8345fe4f91fecfe0c4d9f33c5ae3e210805d",
|
||||
"isometric-touch-kiosk-dark": "ec61f74130cf5c1359e87a5bd26cf311e5c97d0898f85ca9b08e54386f961dc1",
|
||||
"isometric-large-warm-remount-dark": "24250bbbbfb9b9487c4e4522f230951da32502aa4c0489cc9e5765b65d31c1b0",
|
||||
"isometric-stage3-overlays-light": "753511b47a41c41d92504eac6b658b02c3af43a2bd0be438deb7315d1eac6546",
|
||||
"isometric-stage3-overlays-dark": "d9692ba94647ee4782277e198b75a6e2e7a6a0bfc33e3999d3c8101534327e83",
|
||||
"isometric-stage3-openings-dark": "6c0b2dd0c95d7a1e2a0bf98d33da06da59557fc249a41e6c35b305562c6a1d67",
|
||||
"isometric-stage3-forced-colors-dark": "20e1704292a9c25729e7d042926e44c7def8855232b3426dd17e994c1fd92167",
|
||||
"isometric-stage3-no-filter-dark": "f927ce752808ecc1175ab98eae0d8a734d42746340e06ed31f597a04829dce6e",
|
||||
"geometry-view-dark-fit": "454da1248f1fe13b8f60c589ae9ebee5e852995e9bc5175d3c5692c445f7cb94",
|
||||
"geometry-view-light-fit": "de5f6a41552447ca909d20471ed6665f558fb6e111c1f333eeba55866517d5a5",
|
||||
"pdf-export-geometry-light": "2e7d0d027f4fc63e8005412e59b08da0114217cbd441bd3195f4ae909d19ff86",
|
||||
"pdf-export-polish-light": "a8f9698513de9730d58fed90cd4c7092fdc8abc49378ee3ad1e58ce0fd607c7b",
|
||||
"pdf-export-stepped-dimensions-light": "d4dffc0b2bab76405a5c4d504e18b86392abaf50ba63940666d165497947b21d",
|
||||
"pdf-export-geometry-light": "c403a141cefb73e4653f2ffb9c346b4cfaa3de339b6c39c227ef1552ebde0048",
|
||||
"pdf-export-polish-light": "ed6685900355e20a4618e931d42d2e45a39ad44e4a2ab044fa5ee64ff56f12f4",
|
||||
"pdf-export-stepped-dimensions-light": "b9732b8b9c38153c564d0c93fbfbf44a8dd348c8667d2f8c2d14492e08e04f3e",
|
||||
"vacuum-trail-smoothing-dark": "f8eecc726a3e8bdaba414584ad6afd48908ee9d96ea969a1e37758f4e76843e2",
|
||||
"room-label-parity-view-dark": "a188d4db84533d605cb6fe822d20c34d164e3bfc8458a55beaad564854eef169",
|
||||
"room-label-parity-plan-dark": "07e896d4f6dad384bd52dc60f786f042e1cfcd26ddb1f7b12b72d40bb4824324",
|
||||
@@ -50,10 +50,10 @@
|
||||
"room-label-parity-plan-light": "91b7aed42c3a677e387b26c405a04dcc327957f9368625c7c3ad3cca758f1fb1",
|
||||
"washer-active-cycle-dark": "0d683619c27747b3dd6a1e6c7cdc5e15dd886f4f2db4b85b7ca5a5c529e9ec52",
|
||||
"washer-idle-cycle-dark": "0138902daa8dabb0588904507ef5a44f5257f7859b4f1198191bfca3dd4130b7",
|
||||
"day-cycle-dawn-dark": "6adecb5c2b2c14122140b87f41eb7222c9db4b185eee71a6008209fa8fc42ebc",
|
||||
"day-cycle-day-dark": "6319379d476f56c91a28e44981fe97883dedfe9e73dd942107328855845e3009",
|
||||
"day-cycle-dusk-dark": "7039204aa1d66fe8cc30f698e44ad8da7a8550d15aa849720c500b4d4810ac00",
|
||||
"day-cycle-night-dark": "dc7514e39b37903c9704be833b553d713295b63fc6530fb975a86481f32be6a2",
|
||||
"day-cycle-dawn-dark": "96bd5ef84345976bb83d35d418e7d247503629eceb732b6e926f2217f3b9faa4",
|
||||
"day-cycle-day-dark": "605849bbada8d3c3a02a2c88c7b21c81c675a7a89b72c4c560df635d63005e67",
|
||||
"day-cycle-dusk-dark": "d19cc97b75014d9f0661705df50d5f30a3a574661957c04c5dab96e26c5e3364",
|
||||
"day-cycle-night-dark": "39a6a959676295eb4ead02703533641f57901ad0ce1359933e1d47f48867196a",
|
||||
"geometry-plan-editor-dark": "6fa29c51ac37449e53ff76afe17760a86325e160f53040c737619aae1095599d",
|
||||
"safe-resize-handles-clamp-light": "98db89eb1ec29aa6297ac0bc10653c8efce16175833091d0e76dd033ad9f4a8e",
|
||||
"safe-resize-handles-clamp-dark": "581f85ca93197ba796e72daa67402eebb7c64f13d4722b5d33943577837c8980",
|
||||
@@ -72,14 +72,14 @@
|
||||
"orthogonal-strip-cell-5-view-dark": "267a3eab1f4ad7d8d484eb2a58b3e38031b0516ca8c46c93e272bfaf9ac409b8",
|
||||
"orthogonal-strip-cell-1-view-dark": "7c9bab0789f9ec9019edf3c49176415af83e8a325e833f98a0e6324e7f06fe4c",
|
||||
"wall-key-roundtrip-view-dark": "74fd46cffefd32d805296d844c162dbe6f15b13e86c4d6c7f6974293b253e1b4",
|
||||
"isometric-wall-junctions-dark": "2249ee0effc2c96c90add934071ccab302f9233920b43496a48c8d8ecad298d1",
|
||||
"isometric-wall-junctions-dark": "fa84bbdb917d6d2fa9376fa01f50e2050994b9f7086a9ce58f8a239bc9afee37",
|
||||
"opening-placement-door-thick-wall-dark": "9d587eff0c2561ef5f84e9280cd812fa6e9af7cf6ff00c922d275796774ba513",
|
||||
"opening-placement-passage-thick-wall-dark": "6ed48b39c0e11fbf4b0df7d3e4dad8078e5b17694f1a780936a864bebd0c1c57",
|
||||
"opening-placement-passage-thick-wall-light": "3d4e3a5d9b67f2450fab9ccc0f5dc91f4485080582377de904f16ea1d62fbafa",
|
||||
"opening-symbol-room-wall-light": "dc3f6513a08b809a77df6822f59fdb30c912f320e55d47d749963678fbb8f417",
|
||||
"opening-symbol-diagonal-partition-dark": "fe264aa0a5926d904ea0a2d15e5f0567a536c9251a72bf96a287d7bf9c480aa1",
|
||||
"opening-symbol-flip-pairs-light": "693fe97bae8375c6a16244fbb4ddaa1ed447732382a45d4629e1711e0aa0d45a",
|
||||
"isometric-opening-symbol-parity-dark": "4f745055f3de80c01d7144cdc1610be73c78a98f69506c8d6e62bc3fef509471",
|
||||
"isometric-opening-symbol-parity-dark": "7a64ac0c10e8d76fd902a015e2e3a629414f93878e72ed8f32510f157775cc7a",
|
||||
"geometry-devices-editor-dark": "e273c4fe49619a5856a9212ff6261803640e5afa79b841edfccf5a4056e0264d",
|
||||
"device-inbox-desktop-en-light": "2556b2ca3916c016bec58767acc3eab445e32ef29ae59e8ef3580f27cdee9f21",
|
||||
"device-inbox-desktop-ru-dark": "f6da7aca353c904e37f2aae9bda9ef79b0c102926f9b9dbab5f6d9b7a14bbfb3",
|
||||
@@ -162,7 +162,7 @@
|
||||
"general-color-popover-desktop-en": "8dd1aac5beb37d942325c566dd1fedd6303a74d97882202cca92516fc4bd7982",
|
||||
"settings-help-zoom-200-en-light": "d4c0ca1d0a6807707bc5f3366f4da8d8bba13297d09339d36917fd77f9fbe923",
|
||||
"settings-help-zoom-200-ru-dark": "5128090ce7ed3196b3cf81e95634c11c9a42160959cac682792920353f8405c7",
|
||||
"support-desktop-empty-light-en": "a16ebd9246343bdfe363eb1546fab0dfee220cdb746575afb920ffad946648d1",
|
||||
"support-desktop-empty-light-en": "ec60104cb98b646db22089c93f70f78c3a13cdb7001782b2981ce19800ce3cee",
|
||||
"support-desktop-preview-dark-en": "37707b251c117eb381f7ddd6ad13a235be82c139a4d92dc07789a76cc425e946",
|
||||
"support-phone-validation-light-ru": "1699dde634aa583ce6aaa30252f6cc01c9d822ff2718d0d77b179c7c38216ea2",
|
||||
"support-phone-success-dark-en": "21146b3a221010cdc45e1a83c299395772a9f214550a296b78c5ecb72ddab332",
|
||||
|
||||
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 50 KiB |
@@ -32,9 +32,13 @@ const spanOverDoorFixture = JSON.parse(readFileSync(
|
||||
const wallUnionIsolationFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/278-wall-union-isolation.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const cardVersion = JSON.parse(readFileSync(
|
||||
new URL('../../package.json', import.meta.url), 'utf8',
|
||||
)).version;
|
||||
// The version the frames show (#512). The card's own CARD_VERSION changes with
|
||||
// every beta and used to invalidate every frame that prints it (about, the
|
||||
// version-recovery banner, support and backup previews) although nothing but
|
||||
// the digits changed. The harness pins the displayed version through the
|
||||
// `__HP_VERSION_OVERRIDE__` seam and feeds the same constant wherever it plays
|
||||
// the backend, so a version bump is invisible to golden by construction.
|
||||
const cardVersion = '0.0.0-golden';
|
||||
const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1';
|
||||
|
||||
const fixtureFor = (scenario) => scenario.fixture === 'large'
|
||||
@@ -608,7 +612,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
const space = requireSpace();
|
||||
if (contract.roomMetrics) {
|
||||
if (space.id !== 'golden-lighting' || space.rooms.length !== 2)
|
||||
throw new Error(`golden Stage 3 metrics require lighting fixture: ${space.id}`);
|
||||
throw new Error(`golden Stage 4 metrics require lighting fixture: ${space.id}`);
|
||||
space.settings = {
|
||||
...(space.settings || {}), show_names: true,
|
||||
label_temp: true, label_hum: true, label_lqi: true, label_light: true,
|
||||
@@ -621,7 +625,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
}
|
||||
if (contract.newDevice) {
|
||||
if (!fixture.devices?.[contract.newDevice])
|
||||
throw new Error(`golden Stage 3 new marker is missing: ${contract.newDevice}`);
|
||||
throw new Error(`golden Stage 4 new marker is missing: ${contract.newDevice}`);
|
||||
fixture.config.settings = {
|
||||
...(fixture.config.settings || {}), new_device_ids: [contract.newDevice],
|
||||
};
|
||||
@@ -629,14 +633,14 @@ export function prepareGoldenFixture(scenario) {
|
||||
if (contract.lockOpening) {
|
||||
const opening = (space.openings || []).find((item) => item.id === contract.lockOpening);
|
||||
if (!opening || !['door', 'gate'].includes(opening.type))
|
||||
throw new Error(`golden Stage 3 lock opening is missing: ${contract.lockOpening}`);
|
||||
throw new Error(`golden Stage 4 lock opening is missing: ${contract.lockOpening}`);
|
||||
if (!['locked', 'unlocked'].includes(contract.lockState))
|
||||
throw new Error(`golden Stage 3 lock state is invalid: ${contract.lockState}`);
|
||||
throw new Error(`golden Stage 4 lock state is invalid: ${contract.lockState}`);
|
||||
const entityId = `lock.golden_stage3_${contract.lockOpening.replaceAll('-', '_')}`;
|
||||
opening.lock = entityId;
|
||||
fixture.states[entityId] = {
|
||||
entity_id: entityId, state: contract.lockState,
|
||||
attributes: { friendly_name: `Golden Stage 3 ${contract.lockOpening} lock` },
|
||||
attributes: { friendly_name: `Golden Stage 4 ${contract.lockOpening} lock` },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -857,6 +861,8 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
const result = await page.evaluate(async ({ fixture, scenario, cardVersion, attemptKey }) => {
|
||||
// #512: the displayed version is pinned before any card is created.
|
||||
window.__HP_VERSION_OVERRIDE__ = cardVersion;
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
@@ -1258,9 +1264,9 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const structuralBuilds = Number(stage?.getAttribute('data-hp-iso-structural-builds'));
|
||||
if (!scenario.stage3Golden?.noBorders
|
||||
&& (stage?.getAttribute('data-hp-iso-stage') !== '3'
|
||||
&& (stage?.getAttribute('data-hp-iso-stage') !== '4'
|
||||
|| !Number.isFinite(structuralBuilds) || structuralBuilds < 1)) {
|
||||
throw new Error(`isometric golden did not render the Stage 3 contract: ${scenario.id}`);
|
||||
throw new Error(`isometric golden did not render the Stage 4 contract: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (scenario.stage3Golden) {
|
||||
@@ -1269,7 +1275,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const renderedStage = card.renderRoot.querySelector('.stage');
|
||||
if (card.hass?.themes?.darkMode !== expectedDark
|
||||
|| !renderedStage?.classList.contains(expectedDark ? 'theme-dark' : 'theme-light')) {
|
||||
throw new Error(`Stage 3 golden did not use HA darkMode: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 golden did not use HA darkMode: ${scenario.id}`);
|
||||
}
|
||||
const raisedRoots = [...card.renderRoot.querySelectorAll(
|
||||
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
|
||||
@@ -1280,42 +1286,42 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (contract.noBorders) {
|
||||
if (raisedRoots.length || raisedSvg
|
||||
|| card.renderRoot.querySelector('[data-hp-iso-raised="true"]')) {
|
||||
throw new Error(`Stage 3 no-borders golden retained raised overlays: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 no-borders golden retained low overlays: ${scenario.id}`);
|
||||
}
|
||||
} else {
|
||||
const missing = (contract.requiredKinds || []).filter((kind) => !raisedKinds.has(kind));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 golden is missing raised kinds ${missing.join(',')}: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 golden is missing low-overlay kinds ${missing.join(',')}: ${scenario.id}`);
|
||||
}
|
||||
if (!raisedRoots.length
|
||||
|| raisedRoots.some((root) => root.getAttribute('data-hp-iso-raised') !== 'true')) {
|
||||
throw new Error(`Stage 3 golden left an interactive overlay on the floor: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 golden lost the corrected low visual plane: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireNudged
|
||||
&& !raisedRoots.some((root) => root.getAttribute('data-hp-iso-nudged') === 'true')) {
|
||||
throw new Error(`Stage 3 golden fixture produced no bounded nudge: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 golden fixture produced no bounded nudge: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
const materialDefs = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]').length;
|
||||
if (contract.requireMaterialDefs
|
||||
&& (!(materialDefs > 0) || materialDefs > contract.maxMaterialDefs)) {
|
||||
throw new Error(`Stage 3 golden material definitions ${materialDefs} are outside 1..${contract.maxMaterialDefs}: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 golden material definitions ${materialDefs} are outside 1..${contract.maxMaterialDefs}: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireNoMaterialDefs && materialDefs !== 0) {
|
||||
throw new Error(`Stage 3 solid fallback retained material definitions: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 solid fallback retained material definitions: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireTetherCues) {
|
||||
const tethers = [...card.renderRoot.querySelectorAll('.iso-overlay-tether')];
|
||||
if (contract.requireNoTetherCues) {
|
||||
const tether = card.renderRoot.querySelector('.iso-overlay-tether');
|
||||
const paintedFootprint = card.renderRoot.querySelector(
|
||||
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
|
||||
);
|
||||
if (paintedFootprint || !tethers.length) {
|
||||
throw new Error(`Stage 3 golden lost invisible-footprint/tether contract: ${scenario.id}`);
|
||||
if (paintedFootprint || tether) {
|
||||
throw new Error(`Stage 4 golden retained a debug footprint/tether cue: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.requireGrounding
|
||||
&& card.renderRoot.querySelectorAll('.iso-overlay-ground').length < raisedRoots.length) {
|
||||
throw new Error(`Stage 3 golden lost persistent grounding cues: ${scenario.id}`);
|
||||
if (contract.requireNoGrounding
|
||||
&& card.renderRoot.querySelector('.iso-overlay-ground, #hp-iso-overlay-ground')) {
|
||||
throw new Error(`Stage 4 golden retained a per-marker ground cue: ${scenario.id}`);
|
||||
}
|
||||
if (contract.requireDenseFacets) {
|
||||
const facets = {
|
||||
@@ -1327,7 +1333,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
};
|
||||
const missing = Object.entries(facets).filter(([, count]) => !(count > 0));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 dense golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 dense golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.openingKinds) {
|
||||
@@ -1336,7 +1342,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
)].map((node) => node.getAttribute('data-kind')).filter(Boolean));
|
||||
const missing = contract.openingKinds.filter((kind) => !renderedKinds.has(kind));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 opening golden is missing ${missing.join(',')}: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 opening golden is missing ${missing.join(',')}: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.requirePassage) {
|
||||
@@ -1344,7 +1350,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
|| card.renderRoot.querySelector(
|
||||
'[data-hp="iso-openings"] .iso-opening-panel[data-kind="passage"]',
|
||||
)) {
|
||||
throw new Error(`Stage 3 passage golden violated negative-volume contract: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 passage golden violated negative-volume contract: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
if (contract.requireVacuumFloor) {
|
||||
@@ -1353,7 +1359,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
if (!vacuumRoots.length || vacuumRoots.some((root) =>
|
||||
root.getAttribute('data-hp-iso-raised') === 'true'
|
||||
|| root.hasAttribute('data-hp-iso-overlay-kind'))) {
|
||||
throw new Error(`Stage 3 golden raised a floor-bound vacuum: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 golden raised a floor-bound vacuum: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2112,7 +2118,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
};
|
||||
const missing = Object.entries(live).filter(([, count]) => !(count > 0));
|
||||
if (missing.length) {
|
||||
throw new Error(`Stage 3 combined golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
|
||||
throw new Error(`Stage 4 combined golden lacks ${missing.map(([name]) => name).join(',')}: ${scenario.id}`);
|
||||
}
|
||||
}
|
||||
let panelHost = null;
|
||||
|
||||
@@ -296,8 +296,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
requireMaterialDefs: true, maxMaterialDefs: 12,
|
||||
requireVacuumFloor: theme === 'dark',
|
||||
requireDenseFacets: true,
|
||||
requireTetherCues: true,
|
||||
requireGrounding: true,
|
||||
requireNoTetherCues: true,
|
||||
requireNoGrounding: true,
|
||||
requireLiveLayers: theme === 'dark',
|
||||
},
|
||||
markerOverrides: stage3DenseMarkers,
|
||||
@@ -350,7 +350,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
stage3Fixture: { roomMetrics: true, lockOpening: 'light-door', lockState: 'locked' },
|
||||
stage3Golden: {
|
||||
requiredKinds: stage3RequiredOverlays, requireNudged: true,
|
||||
requireNoMaterialDefs: true, requireTetherCues: true,
|
||||
requireNoMaterialDefs: true, requireNoTetherCues: true, requireNoGrounding: true,
|
||||
},
|
||||
layoutOverrides: stage3DenseLayout,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -360,7 +360,7 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
stage3Fixture: { roomMetrics: true, lockOpening: 'light-door', lockState: 'locked' },
|
||||
stage3Golden: {
|
||||
requiredKinds: stage3RequiredOverlays, requireNudged: true,
|
||||
requireNoMaterialDefs: true, requireTetherCues: true,
|
||||
requireNoMaterialDefs: true, requireNoTetherCues: true, requireNoGrounding: true,
|
||||
},
|
||||
layoutOverrides: stage3DenseLayout,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
|
||||
@@ -121,7 +121,9 @@ one runner. Its raw reports and comparison are uploaded as
|
||||
`full-performance-<profile>`, and the table is written to that GitHub job
|
||||
summary. Stable release assets require both exact-SHA
|
||||
`Validate` and exact-SHA `Full Performance`; prereleases require only
|
||||
`Validate`.
|
||||
`Validate`. The gate (`scripts/release-gate.mjs`, #511) judges by the latest
|
||||
non-cancelled run on the SHA: a re-run or a manual comparison against another
|
||||
baseline refreshes the verdict, and a cancelled twin is invisible.
|
||||
|
||||
This base-vs-candidate design intentionally does not compare timings captured
|
||||
on different machines or different Chromium builds. A runtime/profile mismatch
|
||||
@@ -218,6 +220,17 @@ rationale in the change. Do not loosen a threshold merely to make a single red
|
||||
run pass. A new fixture profile gets a new profile id instead of silently
|
||||
changing the meaning of `large-house-v1`.
|
||||
|
||||
`large-house-isometric-v1` and its Stage 3 dense twin carry
|
||||
`countNoiseAllowance: 5` (the other profiles keep 3). Owner decision 2026-09-09, #507: since v1.73.0-beta.1 the
|
||||
isometric renderer lives in the lazy `iso-scene-render` chunk (#160 Stage 3),
|
||||
so the single v1.72.0 boot task is split into two around that import. The
|
||||
load-phase work is unchanged — timings, `longTask.totalP95Ms` and
|
||||
`longTask.maxSingleMs` keep their unchanged ratios and still catch real
|
||||
growth — but `longTask.countP95` counts the split as +2 tasks and, with
|
||||
runner jitter, sat at 16 → 20 against a 19.2 limit on the v1.73.0 stable
|
||||
comparison. The allowance widens the count check alone to 16 → 21 for that
|
||||
baseline; it is not a licence for more work per task.
|
||||
|
||||
The `cleanFloor` entry ceiling is 100: the reviewed fixture warms exactly 100
|
||||
deterministic room/physical-body entries. An extra 20 means that one complete
|
||||
floor was invalidated and rebuilt, so fixture extensions must recalibrate this
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.2,
|
||||
"countNoiseAllowance": 3,
|
||||
"countNoiseAllowance": 5,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.2,
|
||||
"noiseAllowanceMs": 150
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.2,
|
||||
"countNoiseAllowance": 3,
|
||||
"countNoiseAllowance": 5,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.2,
|
||||
"noiseAllowanceMs": 150
|
||||
|
||||
@@ -18,6 +18,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
methods: Object.freeze([
|
||||
'_baseVb',
|
||||
'_bindingStatus',
|
||||
'_buildModel',
|
||||
'_cancelDecorGesture',
|
||||
'_checkSpacePhysicalGeometry',
|
||||
'_decorBoxOf',
|
||||
@@ -40,6 +41,7 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_booting',
|
||||
'_bootSoft',
|
||||
'_cameraTransition',
|
||||
'_cfgEpoch',
|
||||
...CACHE_FIELDS,
|
||||
'_continuity',
|
||||
'_cursorPt',
|
||||
@@ -72,13 +74,20 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
// bundles moved it into ResizeController. A comparison target must expose
|
||||
// one of the two explicit shapes; the current member retains its object
|
||||
// type check and is also verified against current production source.
|
||||
// #500 replaced the private structural adoption with a gated public entry
|
||||
// point. The boot diagnostics (#520) count adoptions through whichever of
|
||||
// the two the measured bundle owns; an undeclared rename would silently
|
||||
// report zero adoptions instead of failing.
|
||||
fieldAlternatives: Object.freeze([
|
||||
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
|
||||
Object.freeze({ current: '_adoptAuthoritative', legacy: '_adoptStructuralResponses' }),
|
||||
]),
|
||||
fieldTypes: Object.freeze({
|
||||
_adoptAuthoritative: 'function',
|
||||
_booting: 'boolean',
|
||||
_bootSoft: 'boolean',
|
||||
_cameraTransition: 'object',
|
||||
_cfgEpoch: 'number',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_continuity: 'object',
|
||||
|
||||
@@ -62,10 +62,12 @@ const requireReport = (report, budgets, label, expectedSha = null) => {
|
||||
throw new Error(`${label}: Iso structural build count is missing or changed on HA-only update`);
|
||||
}
|
||||
if (label === 'candidate' && report.profile === 'isometric-stage3-dense-v1') {
|
||||
if (report.stage3Required !== true) throw new Error(`${label}: Stage 3 was not required`);
|
||||
if (!sameJson(report.isoStageRevision, ['3'])
|
||||
|| report.rows.some((row) => row.isoStageRevision !== '3')) {
|
||||
throw new Error(`${label}: Stage 3 revision metadata is incomplete`);
|
||||
// The profile/schema name is intentionally stable for baseline continuity;
|
||||
// the candidate DOM revision tracks the current experimental Stage 4.
|
||||
if (report.stage3Required !== true) throw new Error(`${label}: Stage 4 was not required`);
|
||||
if (!sameJson(report.isoStageRevision, ['4'])
|
||||
|| report.rows.some((row) => row.isoStageRevision !== '4')) {
|
||||
throw new Error(`${label}: Stage 4 revision metadata is incomplete`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
const { page, browser } = await launch(
|
||||
{ width: 820, height: 760 }, 1, [], { hasTouch: true },
|
||||
);
|
||||
const session = await page.context().newCDPSession(page);
|
||||
const frames = [];
|
||||
let stopped = false;
|
||||
|
||||
session.on('Page.screencastFrame', (event) => {
|
||||
if (frames.length < 120) frames.push(event.data);
|
||||
if (frames.length < 180) frames.push(event.data);
|
||||
void session.send('Page.screencastFrameAck', { sessionId: event.sessionId });
|
||||
});
|
||||
|
||||
@@ -36,6 +38,46 @@ try {
|
||||
format: 'png', everyNthFrame: 1, maxWidth: 820, maxHeight: 760,
|
||||
});
|
||||
await wait(120);
|
||||
|
||||
// #579: exercise a real multi-frame pinch while CDP records compositor-
|
||||
// presented frames. DOM screenshots cannot witness the transient white or
|
||||
// transparent WebView frame reported by users.
|
||||
const pinchFrameStart = frames.length;
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const stage = (card.shadowRoot || card.renderRoot).querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const centerX = rect.x + rect.width / 2;
|
||||
const centerY = rect.y + rect.height / 2;
|
||||
stage.setPointerCapture = () => {};
|
||||
stage.releasePointerCapture = () => {};
|
||||
const send = (type, pointerId, x, buttons = 1) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
pointerId,
|
||||
pointerType: 'touch',
|
||||
isPrimary: pointerId === 731,
|
||||
button: type === 'pointerdown' ? 0 : -1,
|
||||
buttons,
|
||||
clientX: x,
|
||||
clientY: centerY,
|
||||
}));
|
||||
send('pointerdown', 731, centerX - 72);
|
||||
send('pointerdown', 732, centerX + 72);
|
||||
const distances = [80, 91, 70, 98, 66, 104, 73, 96, 68, 101, 75, 94];
|
||||
for (const distance of distances) {
|
||||
send('pointermove', 731, centerX - distance);
|
||||
send('pointermove', 732, centerX + distance);
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
send('pointerup', 731, centerX - distances.at(-1), 0);
|
||||
send('pointerup', 732, centerX + distances.at(-1), 0);
|
||||
await card.updateComplete;
|
||||
await new Promise(requestAnimationFrame);
|
||||
});
|
||||
await wait(180);
|
||||
const pinchPresentedFrames = frames.length - pinchFrameStart;
|
||||
|
||||
await page.evaluate(() => window.__card._pageVisibility({
|
||||
kind: 'visible', token: 73, at: Date.now(), hiddenFor: 20_000, long: true,
|
||||
}));
|
||||
@@ -110,11 +152,12 @@ try {
|
||||
Buffer.from(data, 'base64'),
|
||||
));
|
||||
writeFileSync('artifacts/continuity-screencast/metrics.json', JSON.stringify({
|
||||
stage, frames: metrics, forbidden: forbidden.length,
|
||||
stage, frames: metrics, forbidden: forbidden.length, pinchPresentedFrames,
|
||||
}, null, 2));
|
||||
|
||||
const result = {
|
||||
capturedPresentedFrames: frames.length >= 2,
|
||||
capturedPinchPresentedFrames: pinchPresentedFrames >= 4,
|
||||
baselineContainsPlanDetail: baseline.variance >= 12 && baseline.mean >= 4,
|
||||
noEmptyOrBlackPresentedFrame: forbidden.length === 0,
|
||||
};
|
||||
|
||||
@@ -1,80 +1,327 @@
|
||||
// Направляющие выравнивания — свидетель на НАСТОЯЩЕМ жесте (#521).
|
||||
//
|
||||
// Прежняя редакция этого смока была зелёной на сломанном коде и потому сама
|
||||
// оказалась частью дефекта: она присваивала `_deviceDrag`/`_decorDraft`
|
||||
// целиком и звала `requestUpdate()`. Присвоение с `oldValue == null`
|
||||
// маршрутизацию живого пути не включает, поэтому проверялась обычная осевая
|
||||
// отрисовка — состояние, которого при настоящем жесте не бывает. С #451 всё,
|
||||
// что двигается пальцем, идёт в живого художника, а слой направляющих жил
|
||||
// только в осевшей сцене; на пользователе это выглядело как «направляющих
|
||||
// больше нет» в трёх редакторах сразу.
|
||||
//
|
||||
// Отсюда протокол каждого сценария:
|
||||
// 1. дождаться тишины — сразу после входа в режим `_hdrH` сходится
|
||||
// ступеньками ~200 мс, и каждая ступенька даёт осевой кадр «в подарок»,
|
||||
// на котором сломанный код тоже показывает направляющую;
|
||||
// 2. вести жест настоящими `PointerEvent`/`click` по элементу;
|
||||
// 3. считать осевые циклы за движения — их обязано быть НОЛЬ, иначе смок
|
||||
// снова проверяет не тот путь;
|
||||
// 4. требовать ровно одну группу `.alignguides`: осевая копия слоя на время
|
||||
// жеста подавлена, две линии (одна из них устаревшая) — дефект.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const g = c._gridPitch;
|
||||
const guides = () => sr().querySelectorAll('.alignline').length;
|
||||
// 1) рисование контура: курсор на одном X с вершиной комнаты → гид
|
||||
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
|
||||
const r1 = c._spaceModel().rooms.find((r) => r.id === 'r1');
|
||||
const poly = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
|
||||
const v = poly[0];
|
||||
c._path = [[v[0] + g * 10, v[1] + g * 10]];
|
||||
c._cursorPt = [v[0], v[1] + g * 20]; // тот же X, что у вершины
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.drawGuide = guides() >= 1;
|
||||
out.dot = sr().querySelectorAll('.aligndot').length >= 1;
|
||||
// нет выравнивания → нет гидов
|
||||
c._cursorPt = [v[0] + g * 7 + 1.7, v[1] + g * 13 + 2.3]; c.requestUpdate(); await c.updateComplete;
|
||||
out.noGuideOffAxis = guides() === 0;
|
||||
// 2) бейдж угла: 45° красит
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 5]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml = sr().querySelector('.measurelabel');
|
||||
out.badge45 = !!ml && ml.classList.contains('on45') && ml.textContent.includes('45');
|
||||
c._cursorPt = [c._path[0][0] + g * 5, c._path[0][1] + g * 2]; c.requestUpdate(); await c.updateComplete;
|
||||
const ml2 = sr().querySelector('.measurelabel');
|
||||
out.badgeOff45 = !!ml2 && !ml2.classList.contains('on45') && ml2.textContent.includes('°');
|
||||
c._path = []; c._cursorPt = null;
|
||||
// 3) редактор устройств: drag значка в линию с другим значком
|
||||
c._setMode('devices'); await c.updateComplete;
|
||||
const devs = c._devices.filter((d) => d.space === c._space);
|
||||
const a = devs[0], b = devs[1];
|
||||
const pa = c._pos(a);
|
||||
// поставим b на тот же Y, начнём drag
|
||||
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
|
||||
const pb = c._pos(b);
|
||||
c._deviceDrag = {
|
||||
id: b.id, spaceId: b.space, displayName: b.name, pointerId: 74,
|
||||
source: null, sx: 0, sy: 0, ox: pb.x, oy: pb.y, moved: true,
|
||||
before: { s: b.space, x: pb.x / 1000, y: pb.y / 1000 },
|
||||
start: c._devicePlacementForCanvas(b, pb.x, pb.y),
|
||||
// Считается ВИДИМОЕ. Осевая копия слоя на время жеста гасится прозрачностью
|
||||
// и остаётся в DOM — если считать узлы, две направляющие подряд (живая и
|
||||
// осевая, отставшая на шаг) выглядят как одна.
|
||||
const visible = (selector) => [...sr().querySelectorAll(selector)].filter((node) => {
|
||||
const layer = node.closest('.hp-editor-only-layer');
|
||||
return !layer || layer.style.opacity !== '0';
|
||||
});
|
||||
const lines = () => visible('.alignline').length;
|
||||
const dots = () => visible('.aligndot').length;
|
||||
const groups = () => visible('.alignguides').length;
|
||||
/** Кто сейчас рисует видимую направляющую: живой художник или осевшая сцена. */
|
||||
const guideOwner = () => {
|
||||
const group = visible('.alignguides')[0];
|
||||
if (!group) return 'none';
|
||||
return group.closest('[data-hp-live-editor]') ? 'live' : 'settled';
|
||||
};
|
||||
const keptDrag = c._deviceDrag;
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuide = guides() >= 1;
|
||||
// #400: направляющая обязана идти ОТ ДРУГОГО значка, а не от самого себя.
|
||||
// Прежняя проверка `guides() >= 1` этого не различала: точка всегда
|
||||
// совпадает сама с собой в пределах допуска, поэтому исключение
|
||||
// перетаскиваемого маркера могло не работать вовсе — и не работало,
|
||||
// потому что читало `_drag`, который в этом режиме всегда null.
|
||||
out.devGuideComesFromAnotherMarker = (() => {
|
||||
const key = (list) => list.map((point) => point.join(','));
|
||||
const withDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = null;
|
||||
const withoutDrag = key(c._editorRuntime._alignCandidates());
|
||||
c._deviceDrag = keptDrag;
|
||||
// Исключение снимает РОВНО один кандидат — перетаскиваемый маркер.
|
||||
// Сравнение по разнице списков, а не по заранее посчитанной точке:
|
||||
// позиция значка живёт вместе с перетаскиванием и к моменту проверки
|
||||
// уже другая.
|
||||
const removed = withoutDrag.filter((point) => !withDrag.includes(point));
|
||||
return withDrag.length === withoutDrag.length - 1 && removed.length === 1;
|
||||
const guideLineX = () => {
|
||||
const line = visible('.alignline')[0];
|
||||
return line ? Number(line.getAttribute('x1')) : NaN;
|
||||
};
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
/** Осевые кадры оседания редактора должны закончиться ДО жеста. */
|
||||
const quiet = async () => {
|
||||
for (let i = 0; i < 8; i++) await frame();
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
};
|
||||
const pe = (type, id, extra) => new PointerEvent(type, {
|
||||
pointerId: id, isPrimary: true, bubbles: true, composed: true, cancelable: true, ...extra,
|
||||
});
|
||||
let settled = 0;
|
||||
const settledWillUpdate = c.willUpdate.bind(c);
|
||||
c.willUpdate = function (changed) { settled += 1; return settledWillUpdate(changed); };
|
||||
const g = c._gridPitch;
|
||||
const stage = () => c._stageEl || sr().querySelector('.stage');
|
||||
const toClient = (x, y) => {
|
||||
const rect = stage().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return {
|
||||
x: rect.left + ((x - v.x) / v.w) * rect.width,
|
||||
y: rect.top + ((y - v.y) / v.h) * rect.height,
|
||||
};
|
||||
};
|
||||
const pxPerUnit = () => {
|
||||
const rect = stage().getBoundingClientRect();
|
||||
return rect.width / c._viewOr(c._baseVb()).w;
|
||||
};
|
||||
|
||||
// ---- 1) редактор устройств: настоящее перетаскивание значка -------------
|
||||
c._setMode('devices');
|
||||
await c.updateComplete;
|
||||
const devs = c._devices.filter((d) => d.space === c._space && !d.virtual
|
||||
&& d.bindingStatus?.kind !== 'ha_disabled');
|
||||
const anchor = devs[0];
|
||||
const dragged = devs[1];
|
||||
// Обе позиции — на узлах сетки, иначе выравнивание жестом недостижимо.
|
||||
// Цель ведёт значок и по X (на ось якоря), и по Y — тогда конец
|
||||
// направляющей отличает живую точку от замороженного снимка.
|
||||
c._layout = {
|
||||
...c._layout,
|
||||
[anchor.id]: { s: c._space, x: (53 * g) / 1000, y: (53 * g) / 1000 },
|
||||
[dragged.id]: { s: c._space, x: (61 * g) / 1000, y: (69 * g) / 1000 },
|
||||
};
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const anchorPos = c._pos(anchor);
|
||||
const marker = [...sr().querySelectorAll('[data-hp="device"]')]
|
||||
.find((element) => element.dataset.id === dragged.id);
|
||||
out.markerIsOnTheStage = !!marker;
|
||||
const live = () => c._livePos(c._devices.find((d) => d.id === dragged.id));
|
||||
await quiet();
|
||||
const box = marker.getBoundingClientRect();
|
||||
const sx = box.x + box.width / 2;
|
||||
const sy = box.y + box.height / 2;
|
||||
marker.dispatchEvent(pe('pointerdown', 77, { clientX: sx, clientY: sy }));
|
||||
await c.updateComplete;
|
||||
out.pointerDownStartsTheDrag = !!c._deviceDrag && c._deviceDrag.moved === false;
|
||||
const settledAtDown = settled;
|
||||
let clientX = sx;
|
||||
let clientY = sy;
|
||||
let oneLayerEveryStep = true;
|
||||
const targetY = anchorPos.y + g * 8;
|
||||
for (let step = 0; step < 10; step++) {
|
||||
const at = live();
|
||||
const dx = anchorPos.x - at.x;
|
||||
const dy = targetY - at.y;
|
||||
if (Math.abs(dx) < g * 0.01 && Math.abs(dy) < g * 0.01) break;
|
||||
const share = step < 4 ? 1 / (4 - step) : 1;
|
||||
clientX += dx * share * pxPerUnit();
|
||||
clientY += dy * share * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
await frame();
|
||||
await frame();
|
||||
if (groups() > 1) oneLayerEveryStep = false;
|
||||
}
|
||||
const atGuide = live();
|
||||
out.dragReachedTheAnchorAxis = Math.abs(atGuide.x - anchorPos.x) < 1e-6;
|
||||
out.devGuideFollowsARealDrag = lines() >= 1 && dots() >= 1;
|
||||
out.devSingleGuideLayer = groups() === 1 && oneLayerEveryStep;
|
||||
out.devZeroSettledRendersDuringDrag = settled - settledAtDown === 0;
|
||||
// Конец направляющей считается от точки жеста: `pt[1] ± 1.5 шага`. Живая
|
||||
// точка и замороженный снимок здесь заведомо разные — это и есть проверка,
|
||||
// что направляющую нарисовали от маркера, а не от его прошлого места.
|
||||
const frozen = c._pos(c._devices.find((d) => d.id === dragged.id));
|
||||
const over = g * 1.5;
|
||||
const endFrom = (point) => point.y + Math.sign(point.y - anchorPos.y) * over;
|
||||
const guideLine = sr().querySelector('.alignline');
|
||||
const y2 = guideLine ? Number(guideLine.getAttribute('y2')) : NaN;
|
||||
out.snapshotIsStaleDuringTheDrag = Math.abs(endFrom(frozen) - endFrom(atGuide)) > 1;
|
||||
out.devGuideIsMeasuredFromTheLivePosition = Math.abs(y2 - endFrom(atGuide)) < 0.01;
|
||||
out.alignPointIsTheLivePosition = Math.abs(c._alignPoint[0] - atGuide.x) < 1e-6
|
||||
&& Math.abs(c._alignPoint[1] - atGuide.y) < 1e-6;
|
||||
out.guideIsPaintedByTheLivePainter = guideOwner() === 'live';
|
||||
// #400: направляющая обязана идти ОТ ДРУГОГО значка. Проверяется без
|
||||
// подмены состояния жеста: перетаскиваемого маркера нет среди кандидатов.
|
||||
const candidates = c._editorRuntime._alignCandidates();
|
||||
const key = (x, y) => `${Math.round(x * 1000)}:${Math.round(y * 1000)}`;
|
||||
const candidateKeys = candidates.map((point) => key(point[0], point[1]));
|
||||
out.devCandidatesExcludeTheDraggedMarker = !candidateKeys.includes(key(atGuide.x, atGuide.y))
|
||||
&& candidates.length === devs.length - 1;
|
||||
out.devGuideAnchorIsTheOtherMarker = candidateKeys.includes(key(anchorPos.x, anchorPos.y));
|
||||
// AC6: серия движений внутри одного кадра — один расчёт кандидатов.
|
||||
const runtime = c._editorRuntime;
|
||||
const realCandidates = runtime._alignCandidates.bind(runtime);
|
||||
let candidateCalls = 0;
|
||||
runtime._alignCandidates = function () { candidateCalls += 1; return realCandidates(); };
|
||||
const paintsBefore = c._liveEditorPaintCount;
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX: clientX + i, clientY }));
|
||||
}
|
||||
await frame();
|
||||
await frame();
|
||||
const paints = c._liveEditorPaintCount - paintsBefore;
|
||||
out.candidatesAreComputedOncePerFrame = paints >= 1 && candidateCalls >= 1
|
||||
&& candidateCalls <= paints;
|
||||
runtime._alignCandidates = realCandidates;
|
||||
|
||||
// Передача слоя посреди жеста. Осевой кадр приходит не от жеста — у
|
||||
// владельца это делает наблюдатель высоты шапки (`_hdrH` сходится
|
||||
// ступеньками при любом изменении раскладки), и он же маскировал дефект на
|
||||
// замерах S2. Каждый осевой рендер заканчивается `_commitLiveEditor()`,
|
||||
// который гасит живой слой и возвращает владение сцене: двух групп не
|
||||
// бывает, но точка обязана остаться живой — иначе осевая копия нарисует
|
||||
// направляющую там, где маркер стоял до жеста.
|
||||
// Серия AC6 увела маркер с оси на пару шагов — возвращаем его, иначе
|
||||
// проверять передачу слоя не на чем: без совпадения направляющей нет.
|
||||
for (let step = 0; step < 8; step++) {
|
||||
const at = live();
|
||||
if (Math.abs(at.x - anchorPos.x) < g * 0.01) break;
|
||||
clientX += (anchorPos.x - at.x) * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
await frame();
|
||||
await frame();
|
||||
}
|
||||
const atHandover = live();
|
||||
out.markerIsBackOnTheAxis = Math.abs(atHandover.x - anchorPos.x) < 1e-6 && lines() === 1;
|
||||
const settledBeforeHandover = settled;
|
||||
c._hdrH = (c._hdrH || 0) + 0.01;
|
||||
await c.updateComplete;
|
||||
await frame();
|
||||
out.handoverHappened = settled - settledBeforeHandover >= 1;
|
||||
out.handoverGivesTheLayerBackToTheSettledScene = guideOwner() === 'settled'
|
||||
&& sr().querySelector('[data-hp-live-editor]').childElementCount === 0;
|
||||
out.handoverKeepsExactlyOneGuide = groups() === 1 && lines() === 1;
|
||||
out.handoverGuideStaysOnTheLivePoint = Math.abs(guideLineX() - atHandover.x) < 1e-6;
|
||||
// Настоящее движение — и слой снова у живого художника. Шаг вниз по той же
|
||||
// оси: выравнивание сохраняется, а позиция меняется, иначе живой отрисовки
|
||||
// не будет вовсе — менять нечего, и это правильно.
|
||||
clientY += g * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
for (let i = 0; i < 10 && guideOwner() !== 'live'; i++) await frame();
|
||||
out.nextMoveTakesTheLayerBack = guideOwner() === 'live' && groups() === 1 && lines() === 1;
|
||||
|
||||
// Съехали с оси — направляющей нет. Смещение выбирается так, чтобы не
|
||||
// попасть на ось ни одного из кандидатов: их девять, и «на глаз» выбранный
|
||||
// отступ однажды уже сел ровно на чужую ось.
|
||||
const away = (() => {
|
||||
const points = c._editorRuntime._alignCandidates();
|
||||
const here = live();
|
||||
for (let step = 3; step <= 24; step++) {
|
||||
const x = here.x + g * step;
|
||||
const y = here.y + g * step;
|
||||
const clear = points.every((point) => Math.abs(point[0] - x) > g * 0.5
|
||||
&& Math.abs(point[1] - y) > g * 0.5);
|
||||
if (clear) return step;
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
c._deviceDrag = null; c.requestUpdate(); await c.updateComplete;
|
||||
out.devGuideGone = guides() === 0;
|
||||
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
|
||||
c._setMode('decor'); await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
|
||||
const W = 1000, H = 1000; // square canvas
|
||||
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
out.decorGuide = guides() >= 1;
|
||||
c._decorDraft = null; c._curSpaceCfg.decor = []; c.requestUpdate(); await c.updateComplete;
|
||||
// 5) в Просмотре гидов не бывает
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
out.noneInView = guides() === 0;
|
||||
out.foundAPlaceOffEveryAxis = away > 0;
|
||||
clientX += g * away * pxPerUnit();
|
||||
clientY += g * away * pxPerUnit();
|
||||
marker.dispatchEvent(pe('pointermove', 77, { clientX, clientY }));
|
||||
await frame();
|
||||
await frame();
|
||||
out.devNoGuideOffAxis = lines() === 0;
|
||||
marker.dispatchEvent(pe('pointerup', 77, { clientX, clientY }));
|
||||
await c.updateComplete;
|
||||
out.devGuideGoneAfterTheDrop = lines() === 0 && groups() === 0;
|
||||
|
||||
// ---- 2) подложка: настоящее рисование прямоугольника -------------------
|
||||
c._setMode('decor');
|
||||
await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [{
|
||||
id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3,
|
||||
}];
|
||||
c._decorTool = 'rect';
|
||||
c._decorStyle = { color: '#00ff00', width: 3, fill: false };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await quiet();
|
||||
{
|
||||
const start = toClient(500, 500);
|
||||
const element = stage();
|
||||
const settledAtStart = settled;
|
||||
element.dispatchEvent(pe('pointerdown', 91, { clientX: start.x, clientY: start.y }));
|
||||
await c.updateComplete;
|
||||
out.decorGestureStartsADraft = !!c._decorDraft;
|
||||
let x = start.x;
|
||||
const y = toClient(0, 600).y;
|
||||
let decorOneLayer = true;
|
||||
for (let step = 0; step < 10; step++) {
|
||||
const b = c._decorDraft?.b;
|
||||
if (!b) break;
|
||||
const remaining = 200 - b[0]; // левый край d1
|
||||
if (Math.abs(remaining) < 0.05) break;
|
||||
x += remaining * pxPerUnit() * (step < 3 ? 0.5 : 1);
|
||||
element.dispatchEvent(pe('pointermove', 91, { clientX: x, clientY: y }));
|
||||
await frame();
|
||||
await frame();
|
||||
if (groups() > 1) decorOneLayer = false;
|
||||
}
|
||||
out.decorDraftReachedTheShapeCorner = Math.abs((c._decorDraft?.b?.[0] ?? -1) - 200) < 0.05;
|
||||
out.decorGuideFollowsARealGesture = lines() >= 1 && dots() >= 1;
|
||||
out.decorSingleGuideLayer = groups() === 1 && decorOneLayer;
|
||||
out.decorZeroSettledRendersDuringGesture = settled - settledAtStart <= 1;
|
||||
element.dispatchEvent(pe('pointerup', 91, { clientX: x, clientY: y }));
|
||||
await c.updateComplete;
|
||||
c._curSpaceCfg.decor = [];
|
||||
c._decorTool = 'select';
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// ---- 3) план: направляющая идёт за курсором БЕЗ клика ------------------
|
||||
c._setMode('plan');
|
||||
c._tool = 'draw';
|
||||
await c.updateComplete;
|
||||
await quiet();
|
||||
{
|
||||
const room = c._spaceModel().rooms[0];
|
||||
const poly = room.poly || [[room.x, room.y], [room.x + room.w, room.y],
|
||||
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h]];
|
||||
const vertex = poly[0];
|
||||
const element = stage();
|
||||
const first = toClient(vertex[0] + g * 10, vertex[1] + g * 10);
|
||||
element.dispatchEvent(new MouseEvent('click', {
|
||||
clientX: first.x, clientY: first.y, bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
await c.updateComplete;
|
||||
out.planClickAddsTheFirstVertex = c._path.length === 1;
|
||||
await quiet();
|
||||
const settledAtHover = settled;
|
||||
let planOneLayer = true;
|
||||
for (let step = 1; step <= 4; step++) {
|
||||
const x = vertex[0] + g * 10 - (g * 10 * step) / 4;
|
||||
const point = toClient(x, vertex[1] + g * 20);
|
||||
element.dispatchEvent(pe('pointermove', 93, { clientX: point.x, clientY: point.y }));
|
||||
await frame();
|
||||
await frame();
|
||||
if (groups() > 1) planOneLayer = false;
|
||||
}
|
||||
out.planHoverReachedTheVertexAxis = Math.abs((c._cursorPt?.[0] ?? -1) - vertex[0]) < 1e-6;
|
||||
out.planGuideFollowsTheCursorWithoutAClick = lines() >= 1 && dots() >= 1;
|
||||
out.planSingleGuideLayer = groups() === 1 && planOneLayer;
|
||||
out.planZeroSettledRendersDuringHover = settled - settledAtHover === 0;
|
||||
// Бейдж угла живёт тем же жестом: 45° красит, иначе — обычный градус.
|
||||
const at45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 5);
|
||||
element.dispatchEvent(pe('pointermove', 93, { clientX: at45.x, clientY: at45.y }));
|
||||
await frame();
|
||||
await frame();
|
||||
const label45 = sr().querySelector('.measurelabel');
|
||||
out.badge45 = !!label45 && label45.classList.contains('on45') && label45.textContent.includes('45');
|
||||
const off45 = toClient(c._path[0][0] + g * 5, c._path[0][1] + g * 2);
|
||||
element.dispatchEvent(pe('pointermove', 93, { clientX: off45.x, clientY: off45.y }));
|
||||
await frame();
|
||||
await frame();
|
||||
const labelOff = sr().querySelector('.measurelabel');
|
||||
out.badgeOff45 = !!labelOff && !labelOff.classList.contains('on45')
|
||||
&& labelOff.textContent.includes('°');
|
||||
c._path = [];
|
||||
c._cursorPt = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}
|
||||
|
||||
// ---- 4) в Просмотре направляющих не бывает ----------------------------
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
out.noneInView = lines() === 0 && groups() === 0;
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// #543: authoritative config reloads may finish in either network/asset order.
|
||||
// Exercise the production bundle and the real `_reloadConfigOnly` host path;
|
||||
// only transport replies and image latency are controlled by the harness.
|
||||
import { launchColdView, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launchColdView();
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const clone = (value) => structuredClone(value);
|
||||
const cache = () => JSON.parse(localStorage.getItem('houseplan_card_cfg_v1') || 'null');
|
||||
const firstTitle = (config) => config?.spaces?.[0]?.title;
|
||||
const config = (title, planUrl) => {
|
||||
const next = clone(card._serverCfg);
|
||||
next.spaces[0].title = title;
|
||||
next.spaces[0].plan_url = planUrl;
|
||||
return next;
|
||||
};
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const original = {
|
||||
get: card._getAuthoritativeConfig,
|
||||
prepareImage: card._signer.prepareImage,
|
||||
cacheSnapshot: card._cacheSnapshot,
|
||||
rebuild: card._maybeRebuildDevices,
|
||||
continuity: card._beginContinuityCandidate,
|
||||
toast: card._showToast,
|
||||
retry: card._scheduleLoadRetry,
|
||||
requestUpdate: card.requestUpdate,
|
||||
};
|
||||
const tails = {
|
||||
cache: [], rebuild: 0, continuity: 0, toast: 0, retry: 0, renders: 0,
|
||||
};
|
||||
card._cacheSnapshot = function (...args) {
|
||||
tails.cache.push({ title: firstTitle(this._serverCfg), rev: this._cfgRev });
|
||||
return original.cacheSnapshot.apply(this, args);
|
||||
};
|
||||
card._maybeRebuildDevices = function (...args) {
|
||||
tails.rebuild += 1;
|
||||
return original.rebuild.apply(this, args);
|
||||
};
|
||||
card._beginContinuityCandidate = function (...args) {
|
||||
tails.continuity += 1;
|
||||
return original.continuity.apply(this, args);
|
||||
};
|
||||
card._showToast = function (...args) {
|
||||
tails.toast += 1;
|
||||
return original.toast.apply(this, args);
|
||||
};
|
||||
card._scheduleLoadRetry = function (...args) {
|
||||
tails.retry += 1;
|
||||
return original.retry.apply(this, args);
|
||||
};
|
||||
card.requestUpdate = function (...args) {
|
||||
tails.renders += 1;
|
||||
return original.requestUpdate.apply(this, args);
|
||||
};
|
||||
|
||||
const reset = async (title) => {
|
||||
const base = config(title, `/assets/${title}.svg`);
|
||||
card._getAuthoritativeConfig = async () => ({ config: base, rev: 2, can_write: true });
|
||||
card._signer.prepareImage = async () => true;
|
||||
await card._reloadConfigOnly(true);
|
||||
await card.updateComplete;
|
||||
tails.cache.length = 0;
|
||||
tails.rebuild = 0;
|
||||
tails.continuity = 0;
|
||||
tails.toast = 0;
|
||||
tails.retry = 0;
|
||||
tails.renders = 0;
|
||||
};
|
||||
|
||||
const tailSnapshot = () => ({
|
||||
cache: tails.cache.length,
|
||||
rebuild: tails.rebuild,
|
||||
continuity: tails.continuity,
|
||||
toast: tails.toast,
|
||||
retry: tails.retry,
|
||||
renders: tails.renders,
|
||||
epoch: card._cfgEpoch,
|
||||
});
|
||||
|
||||
// Order 1: A has its response first but remains inside prepareImage while B
|
||||
// completes. Releasing A must not produce any second adoption tail.
|
||||
await reset('race-baseline-asset');
|
||||
const olderAsset = config('race-older-asset', '/assets/race-older-asset.svg');
|
||||
const newerAsset = config('race-newer-asset', '/assets/race-newer-asset.svg');
|
||||
const assetGate = deferred();
|
||||
const assetEntered = deferred();
|
||||
let requestIndex = 0;
|
||||
card._getAuthoritativeConfig = async () => (++requestIndex === 1
|
||||
? { config: olderAsset, rev: 3, can_write: true }
|
||||
: { config: newerAsset, rev: 4, can_write: true });
|
||||
card._signer.prepareImage = async (_hass, url) => {
|
||||
if (String(url).includes('race-older-asset')) {
|
||||
assetEntered.resolve();
|
||||
return assetGate.promise;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const olderAssetReload = card._reloadConfigOnly(false, 3);
|
||||
await assetEntered.promise;
|
||||
await card._reloadConfigOnly(false, 4);
|
||||
await card.updateComplete;
|
||||
const assetWinner = {
|
||||
title: firstTitle(card._serverCfg),
|
||||
rev: card._cfgRev,
|
||||
cache: clone(cache()),
|
||||
cacheTails: tails.cache.length,
|
||||
rebuilds: tails.rebuild,
|
||||
continuity: tails.continuity,
|
||||
renders: tails.renders,
|
||||
epoch: card._cfgEpoch,
|
||||
text: card.renderRoot.textContent,
|
||||
};
|
||||
assetGate.resolve(true);
|
||||
await olderAssetReload;
|
||||
await card.updateComplete;
|
||||
const assetFinal = {
|
||||
title: firstTitle(card._serverCfg),
|
||||
rev: card._cfgRev,
|
||||
cache: clone(cache()),
|
||||
cacheTails: tails.cache.length,
|
||||
rebuilds: tails.rebuild,
|
||||
continuity: tails.continuity,
|
||||
renders: tails.renders,
|
||||
epoch: card._cfgEpoch,
|
||||
text: card.renderRoot.textContent,
|
||||
};
|
||||
|
||||
// Order 2: A waits in transport itself. B completes before A even reaches
|
||||
// the common gate; the pre-adoption host check must drop A.
|
||||
await reset('race-baseline-network');
|
||||
const olderNetwork = config('race-older-network', '/assets/race-older-network.svg');
|
||||
const newerNetwork = config('race-newer-network', '/assets/race-newer-network.svg');
|
||||
const networkGate = deferred();
|
||||
requestIndex = 0;
|
||||
card._getAuthoritativeConfig = async () => (++requestIndex === 1
|
||||
? networkGate.promise
|
||||
: { config: newerNetwork, rev: 4, can_write: true });
|
||||
card._signer.prepareImage = async () => true;
|
||||
const olderNetworkReload = card._reloadConfigOnly(false, 3);
|
||||
await Promise.resolve();
|
||||
await card._reloadConfigOnly(false, 4);
|
||||
await card.updateComplete;
|
||||
const networkWinner = {
|
||||
title: firstTitle(card._serverCfg),
|
||||
rev: card._cfgRev,
|
||||
cacheTails: tails.cache.length,
|
||||
rebuilds: tails.rebuild,
|
||||
continuity: tails.continuity,
|
||||
renders: tails.renders,
|
||||
epoch: card._cfgEpoch,
|
||||
};
|
||||
networkGate.resolve({ config: olderNetwork, rev: 3, can_write: true });
|
||||
await olderNetworkReload;
|
||||
await card.updateComplete;
|
||||
const networkFinal = {
|
||||
title: firstTitle(card._serverCfg),
|
||||
rev: card._cfgRev,
|
||||
cacheTails: tails.cache.length,
|
||||
rebuilds: tails.rebuild,
|
||||
continuity: tails.continuity,
|
||||
renders: tails.renders,
|
||||
epoch: card._cfgEpoch,
|
||||
};
|
||||
|
||||
// A local write accepted while a read is pending establishes a different
|
||||
// baseline. Even a response with the same numeric revision cannot replace
|
||||
// that body or append another cache/render tail.
|
||||
await reset('race-baseline-write');
|
||||
const olderWrite = config('race-older-write', '/assets/race-older-write.svg');
|
||||
const localWinner = config('race-local-winner', '/assets/race-local-winner.svg');
|
||||
const writeGate = deferred();
|
||||
card._getAuthoritativeConfig = async () => writeGate.promise;
|
||||
const staleAfterWrite = card._reloadConfigOnly(false, 3);
|
||||
await Promise.resolve();
|
||||
card._adoption.stageConfigCandidate(localWinner);
|
||||
card._adoption.acceptConfigWrite(localWinner, { rev: 3 });
|
||||
card._cacheSnapshot();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
const writeWinner = {
|
||||
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
|
||||
};
|
||||
const writeTail = tailSnapshot();
|
||||
writeGate.resolve({ config: olderWrite, rev: 3, can_write: true });
|
||||
await staleAfterWrite;
|
||||
await card.updateComplete;
|
||||
const writeFinal = {
|
||||
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
|
||||
tail: tailSnapshot(),
|
||||
};
|
||||
|
||||
// Every lifecycle boundary must invalidate permanently, even when the user,
|
||||
// connection or route later appears identical again. Snapshot counters only
|
||||
// after the boundary so its own legitimate work is not attributed to A.
|
||||
const originalHass = card.hass;
|
||||
const lifecycleCase = async (name, invalidate) => {
|
||||
card.hass = originalHass;
|
||||
await card.updateComplete;
|
||||
await reset(`race-baseline-${name}`);
|
||||
const stale = config(`race-stale-${name}`, `/assets/race-stale-${name}.svg`);
|
||||
const gate = deferred();
|
||||
card._getAuthoritativeConfig = async () => gate.promise;
|
||||
const pending = card._reloadConfigOnly(false, 3);
|
||||
await Promise.resolve();
|
||||
await invalidate();
|
||||
const before = {
|
||||
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
|
||||
tail: tailSnapshot(),
|
||||
};
|
||||
gate.resolve({ config: stale, rev: 3, can_write: true });
|
||||
await pending;
|
||||
await card.updateComplete;
|
||||
const after = {
|
||||
title: firstTitle(card._serverCfg), rev: card._cfgRev, cache: clone(cache()),
|
||||
tail: tailSnapshot(),
|
||||
};
|
||||
return JSON.stringify(before) === JSON.stringify(after);
|
||||
};
|
||||
const routeInvalidated = await lifecycleCase('route', async () => {
|
||||
card._leaveCardRoute();
|
||||
});
|
||||
const userInvalidated = await lifecycleCase('user', async () => {
|
||||
card.hass = { ...originalHass, user: { ...originalHass.user, id: 'other-user' } };
|
||||
await card.updateComplete;
|
||||
card.hass = originalHass;
|
||||
await card.updateComplete;
|
||||
});
|
||||
const connectionInvalidated = await lifecycleCase('connection', async () => {
|
||||
card.hass = { ...originalHass, connection: { ...originalHass.connection } };
|
||||
await card.updateComplete;
|
||||
card.hass = originalHass;
|
||||
await card.updateComplete;
|
||||
});
|
||||
const reconnectInvalidated = await lifecycleCase('reconnect', async () => {
|
||||
const parent = card.parentNode;
|
||||
card.remove();
|
||||
await Promise.resolve();
|
||||
parent.appendChild(card);
|
||||
await card.updateComplete;
|
||||
});
|
||||
|
||||
// The same element is usable in the new lifecycle after stale work was
|
||||
// discarded; lifecycle invalidation is not a permanent numeric-rev ban.
|
||||
const lifecycleWinner = config('race-lifecycle-winner', '/assets/race-lifecycle-winner.svg');
|
||||
card._getAuthoritativeConfig = async () => ({ config: lifecycleWinner, rev: 4, can_write: true });
|
||||
card._signer.prepareImage = async () => true;
|
||||
await card._reloadConfigOnly(false, 4);
|
||||
await card.updateComplete;
|
||||
const newLifecycleCanReload = firstTitle(card._serverCfg) === 'race-lifecycle-winner'
|
||||
&& card._cfgRev === 4;
|
||||
|
||||
// Force/restore owns a fresh generation rather than a process-lifetime
|
||||
// monotonic clock. A lower authoritative revision is valid there; ordinary
|
||||
// events then establish a new high-water from that restored baseline. The
|
||||
// pending event timer below belongs to the old generation and must expire.
|
||||
const restored = config('race-force-restored', '/assets/race-force-restored.svg');
|
||||
let deferredEventRetry = null;
|
||||
const nativeSetTimeout = window.setTimeout;
|
||||
window.setTimeout = (callback) => {
|
||||
deferredEventRetry = callback;
|
||||
return 543;
|
||||
};
|
||||
card._writesPending += 1;
|
||||
try {
|
||||
await card._reloadConfigOnly(false, 5);
|
||||
} finally {
|
||||
window.setTimeout = nativeSetTimeout;
|
||||
}
|
||||
card._getAuthoritativeConfig = async () => ({ config: restored, rev: 1, can_write: true });
|
||||
try {
|
||||
await card._reloadConfigOnly(true);
|
||||
} finally {
|
||||
card._writesPending -= 1;
|
||||
}
|
||||
await card.updateComplete;
|
||||
const forceDuringWriteAcceptedLowerRevision = firstTitle(card._serverCfg) === 'race-force-restored'
|
||||
&& card._cfgRev === 1;
|
||||
let expiredRetryReads = 0;
|
||||
card._getAuthoritativeConfig = async () => {
|
||||
expiredRetryReads += 1;
|
||||
return { config: restored, rev: 1, can_write: true };
|
||||
};
|
||||
await deferredEventRetry?.();
|
||||
await Promise.resolve();
|
||||
const oldDeferredEventExpired = expiredRetryReads === 0;
|
||||
let ordinaryReads = 0;
|
||||
card._getAuthoritativeConfig = async () => {
|
||||
ordinaryReads += 1;
|
||||
return {
|
||||
config: config('race-after-restore', '/assets/race-after-restore.svg'),
|
||||
rev: 2,
|
||||
can_write: true,
|
||||
};
|
||||
};
|
||||
await card._reloadConfigOnly(false, 1);
|
||||
const restoredEchoSkipped = ordinaryReads === 0;
|
||||
await card._reloadConfigOnly(false, 2);
|
||||
await card.updateComplete;
|
||||
const restoredHigherEventAccepted = ordinaryReads === 1
|
||||
&& firstTitle(card._serverCfg) === 'race-after-restore'
|
||||
&& card._cfgRev === 2;
|
||||
|
||||
card._getAuthoritativeConfig = original.get;
|
||||
card._signer.prepareImage = original.prepareImage;
|
||||
card._cacheSnapshot = original.cacheSnapshot;
|
||||
card._maybeRebuildDevices = original.rebuild;
|
||||
card._beginContinuityCandidate = original.continuity;
|
||||
card._showToast = original.toast;
|
||||
card._scheduleLoadRetry = original.retry;
|
||||
card.requestUpdate = original.requestUpdate;
|
||||
|
||||
return {
|
||||
assetWinnerAdopted: assetWinner.title === 'race-newer-asset' && assetWinner.rev === 4,
|
||||
assetWinnerVisible: assetWinner.text.includes('race-newer-asset'),
|
||||
assetWinnerCached: firstTitle(assetWinner.cache?.config) === 'race-newer-asset'
|
||||
&& assetWinner.cache?.rev === 4,
|
||||
lateAssetCannotRegress: assetFinal.title === assetWinner.title
|
||||
&& assetFinal.rev === assetWinner.rev,
|
||||
lateAssetHasNoTail: assetFinal.cacheTails === assetWinner.cacheTails
|
||||
&& assetFinal.rebuilds === assetWinner.rebuilds
|
||||
&& assetFinal.continuity === assetWinner.continuity
|
||||
&& assetFinal.renders === assetWinner.renders
|
||||
&& assetFinal.epoch === assetWinner.epoch,
|
||||
lateAssetCannotRegressCache: JSON.stringify(assetFinal.cache) === JSON.stringify(assetWinner.cache),
|
||||
lateAssetNotVisible: !assetFinal.text.includes('race-older-asset'),
|
||||
networkWinnerAdopted: networkWinner.title === 'race-newer-network'
|
||||
&& networkWinner.rev === 4,
|
||||
lateNetworkCannotRegress: networkFinal.title === networkWinner.title
|
||||
&& networkFinal.rev === networkWinner.rev,
|
||||
lateNetworkHasNoTail: networkFinal.cacheTails === networkWinner.cacheTails
|
||||
&& networkFinal.rebuilds === networkWinner.rebuilds
|
||||
&& networkFinal.continuity === networkWinner.continuity
|
||||
&& networkFinal.renders === networkWinner.renders
|
||||
&& networkFinal.epoch === networkWinner.epoch,
|
||||
localWriteTitle: writeWinner.title,
|
||||
localWriteRev: writeWinner.rev,
|
||||
localWriteCacheTitle: firstTitle(writeWinner.cache?.config),
|
||||
localWriteCacheRev: writeWinner.cache?.rev,
|
||||
staleReadCannotReplaceLocalWrite: writeFinal.title === writeWinner.title
|
||||
&& writeFinal.rev === writeWinner.rev
|
||||
&& JSON.stringify(writeFinal.cache) === JSON.stringify(writeWinner.cache)
|
||||
&& JSON.stringify(writeFinal.tail) === JSON.stringify(writeTail),
|
||||
routeInvalidated,
|
||||
userInvalidated,
|
||||
connectionInvalidated,
|
||||
reconnectInvalidated,
|
||||
newLifecycleCanReload,
|
||||
forceDuringWriteAcceptedLowerRevision,
|
||||
oldDeferredEventExpired,
|
||||
restoredEchoSkipped,
|
||||
restoredHigherEventAccepted,
|
||||
noStaleToastOrRetry: tails.toast === 0 && tails.retry === 0,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(out, {
|
||||
localWriteTitle: 'race-local-winner',
|
||||
localWriteRev: 3,
|
||||
localWriteCacheTitle: 'race-local-winner',
|
||||
localWriteCacheRev: 3,
|
||||
});
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,110 @@
|
||||
// #526: включённая минификация не меняет смысл таблиц стилей.
|
||||
//
|
||||
// Минификатор молчал с самого начала (его отсев искал тег вплотную к бэктику,
|
||||
// а плагин видит вывод TypeScript с пробелом), поэтому 23 КБ CSS проходят
|
||||
// сжатие впервые. Риск задачи не в двух строках отсева, а в том, что где-то
|
||||
// съеден значащий пробел — потомковый комбинатор, аргумент calc(), кавычка.
|
||||
//
|
||||
// Судим не глазами и не байтами, а браузером: исходный и минифицированный
|
||||
// текст кладутся в отдельные CSSStyleSheet, и сериализованные правила обязаны
|
||||
// совпасть посимвольно. Браузер сам приводит запись к канонической форме,
|
||||
// поэтому совпадение cssText означает совпадение смысла.
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { minifyCssText } from '../scripts/css-template-minifier.mjs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
/** Тело статического css-шаблона модуля (интерполяций в них нет, см. ТЗ). */
|
||||
const templateBody = (source) => {
|
||||
const match = /(^|[^\w$.])css\s*`/.exec(source);
|
||||
if (!match) return null;
|
||||
const start = match.index + match[0].length;
|
||||
const end = source.indexOf('`', start);
|
||||
if (end < 0) return null;
|
||||
const body = source.slice(start, end);
|
||||
return body.includes('\\') || body.includes('${') ? null : body;
|
||||
};
|
||||
|
||||
const files = [
|
||||
...readdirSync(new URL('../src/styles/', import.meta.url))
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.map((name) => [`src/styles/${name}`, new URL(`../src/styles/${name}`, import.meta.url)]),
|
||||
['src/editor-secondary.styles.ts', new URL('../src/editor-secondary.styles.ts', import.meta.url)],
|
||||
];
|
||||
|
||||
const pairs = [];
|
||||
for (const [label, url] of files) {
|
||||
const body = templateBody(readFileSync(url, 'utf8'));
|
||||
if (!body) continue;
|
||||
pairs.push({ label, source: body, minified: minifyCssText(body, label) });
|
||||
}
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = await page.evaluate(async (input) => {
|
||||
const result = {};
|
||||
// Минификатор по контракту схлопывает пробельные пробеги и убирает пробел
|
||||
// после запятой. В обычных значениях браузер делает то же самое сам, но
|
||||
// значение СВОЙСТВА-ПЕРЕМЕННОЙ он хранит текстом как есть, поэтому такие
|
||||
// правила отличались бы записью при полном совпадении смысла. Сравнение
|
||||
// нормализует ровно объявленную политику пробелов минификатора — пробельные
|
||||
// пробеги и пробел рядом с `,`, `:`, `(`, `)` — и ничего больше: пропавший
|
||||
// потомковый
|
||||
// комбинатор (`.a .b` → `.a.b`) или съеденный пробел в `calc(100% - 2px)`
|
||||
// остаются расхождением и валят свидетеля.
|
||||
const normalize = (text) => text
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/([,:(])\s/g, '$1')
|
||||
.replace(/\s\)/g, ')');
|
||||
const rulesOf = (sheet) => {
|
||||
const list = [];
|
||||
const walk = (rules) => {
|
||||
for (const rule of rules) {
|
||||
list.push(normalize(rule.cssText));
|
||||
if (rule.cssRules) walk(rule.cssRules);
|
||||
}
|
||||
};
|
||||
walk(sheet.cssRules);
|
||||
return list;
|
||||
};
|
||||
const mismatches = [];
|
||||
let comparedRules = 0;
|
||||
for (const pair of input) {
|
||||
const before = new CSSStyleSheet();
|
||||
const after = new CSSStyleSheet();
|
||||
before.replaceSync(pair.source);
|
||||
after.replaceSync(pair.minified);
|
||||
const left = rulesOf(before);
|
||||
const right = rulesOf(after);
|
||||
comparedRules += left.length;
|
||||
if (left.length !== right.length) {
|
||||
mismatches.push(`${pair.label}: правил ${left.length} против ${right.length}`);
|
||||
continue;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) {
|
||||
mismatches.push(`${pair.label}: правило ${index}\n было: ${left[index].slice(0, 160)}\n стало: ${right[index].slice(0, 160)}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.filesCompared = input.length;
|
||||
result.enoughFiles = input.length >= 5;
|
||||
result.rulesCompared = comparedRules;
|
||||
result.enoughRules = comparedRules >= 500;
|
||||
result.everyRuleIdentical = mismatches.length === 0;
|
||||
result.mismatches = mismatches.slice(0, 3);
|
||||
// Минификация обязана быть не просто безопасной, но и полезной.
|
||||
const saved = input.reduce((sum, pair) => sum + pair.source.length - pair.minified.length, 0);
|
||||
result.savedBytes = saved;
|
||||
result.savesRealBytes = saved > 20000;
|
||||
return result;
|
||||
}, pairs);
|
||||
|
||||
console.log(JSON.stringify({ ...out, mismatches: out.mismatches }, null, 1));
|
||||
checkAll(out, {
|
||||
filesCompared: out.filesCompared,
|
||||
rulesCompared: out.rulesCompared,
|
||||
savedBytes: out.savedBytes,
|
||||
mismatches: [],
|
||||
});
|
||||
await browser.close();
|
||||
finish();
|
||||
@@ -196,7 +196,8 @@ const out = await page.evaluate(async () => {
|
||||
card._saveConfigDebounced.cancel();
|
||||
card._persistLayout.cancel();
|
||||
card._writeChain = Promise.resolve();
|
||||
card._adoptStructuralResponses = () => ({ configChanged: false, layoutChanged: false });
|
||||
// #500: the delete path re-reads and adopts through the one gated entry; keep it inert here.
|
||||
card._adoptAuthoritative = async () => ({ status: 'adopted', spaceChanged: false });
|
||||
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
|
||||
card._space = 'another-space';
|
||||
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Стоимость растеризации дневного цикла — свидетель #532.
|
||||
//
|
||||
// Владелец: «в Firefox план тормозит; со статичным цветом фона тормоза
|
||||
// пропадают». Профиль подтвердил: наш JS спал 89 % времени, а в GPU-процессе
|
||||
// на кадр уезжало 23 МБ текстур. Виновником оказалась не сцена плана, а
|
||||
// тройной drop-shadow внешнего контура: группа .hp-paperg несёт одни бумажные
|
||||
// силуэты и при ховере и панораме не меняется, но фильтр жил в общем слое
|
||||
// плана — и каждая перерисовка плана заново прогоняла три прохода размытия по
|
||||
// габариту всего листа. Лечится одной подсказкой will-change: filter, которая
|
||||
// уводит отфильтрованную бумагу в свой композиционный слой.
|
||||
//
|
||||
// Смок проверяет две вещи, и вторая — не время, а ОТНОШЕНИЕ: абсолютные
|
||||
// миллисекунды зависят от машины и раннера, отношение «дневной цикл к
|
||||
// статичному фону» — нет. Оба замера идут в одном процессе, на одной странице,
|
||||
// подряд, поэтому делят между собой и прогрев, и загрузку машины.
|
||||
//
|
||||
// Замер аналитики на панораме демо-стенда: 7.9 без подсказки, 0.26 с ней.
|
||||
// Порог 2.0 стоит примерно посередине по логарифму и даёт запас в обе стороны.
|
||||
//
|
||||
// Движок здесь Chromium — он же движок CI и golden. Приговор по Gecko даёт
|
||||
// профиль Firefox с машины владельца: подсказка это совет движку, и Gecko
|
||||
// вправе отклонить её по бюджету памяти (layout.css.will-change.budget).
|
||||
// Смок нужен не вместо того профиля, а чтобы правку нельзя было молча снять.
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const PAN_STEPS = 60;
|
||||
const RATIO_CEILING = 2.0;
|
||||
|
||||
const { page, browser } = await launch({ width: 1400, height: 900 }, 1);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
|
||||
const setBackground = async (mode) => {
|
||||
await page.evaluate(async (mode) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg.settings = { ...(card._serverCfg.settings || {}), bg_mode: mode };
|
||||
// Пространство со своим bg_mode переопределяет глобальный — снимаем, иначе
|
||||
// смок мерил бы не то, что переключил.
|
||||
for (const space of card._serverCfg.spaces) {
|
||||
space.settings = { ...(space.settings || {}) };
|
||||
delete space.settings.bg_mode;
|
||||
}
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 40, rising: false },
|
||||
} } };
|
||||
card._cfgRev = (card._cfgRev || 0) + 1;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}, mode);
|
||||
// Переход фильтра длится 1100 мс: замер до его конца мерил бы анимацию.
|
||||
await page.waitForTimeout(1400);
|
||||
};
|
||||
|
||||
const outlineStyle = () => page.evaluate(() => {
|
||||
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const stage = root.querySelector('.stage');
|
||||
const group = root.querySelector('.hp-paperg');
|
||||
if (!group) return null;
|
||||
const style = getComputedStyle(group);
|
||||
return {
|
||||
daycycle: stage.classList.contains('daycycle'),
|
||||
filter: style.filter,
|
||||
willChange: style.willChange,
|
||||
};
|
||||
});
|
||||
|
||||
const stageBox = () => page.evaluate(() => {
|
||||
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
||||
const rect = root.querySelector('.stage').getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
||||
});
|
||||
|
||||
/** Одна панорама с зажатой кнопкой; возвращает суммарный RasterTask в мс. */
|
||||
const measurePan = async () => {
|
||||
const box = await stageBox();
|
||||
const events = [];
|
||||
const collect = ({ value }) => events.push(...value);
|
||||
cdp.on('Tracing.dataCollected', collect);
|
||||
await cdp.send('Tracing.start', {
|
||||
traceConfig: { includedCategories: ['disabled-by-default-devtools.timeline'] },
|
||||
transferMode: 'ReportEvents',
|
||||
});
|
||||
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.mouse.down();
|
||||
for (let step = 0; step < PAN_STEPS; step += 1) {
|
||||
await page.mouse.move(cx + Math.sin(step / 6) * 140, cy + Math.cos(step / 9) * 90);
|
||||
// Два кадра на шаг: первый ставит кадр в очередь, второй ждёт, пока он
|
||||
// действительно отрисуется. Иначе замер считает не работу, а очередь.
|
||||
await page.evaluate(() => new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}));
|
||||
}
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const complete = new Promise((resolve) => cdp.once('Tracing.tracingComplete', resolve));
|
||||
await cdp.send('Tracing.end');
|
||||
await complete;
|
||||
cdp.off('Tracing.dataCollected', collect);
|
||||
let micros = 0, tasks = 0;
|
||||
for (const event of events) {
|
||||
if (event.name !== 'RasterTask' || event.ph !== 'X' || typeof event.dur !== 'number') continue;
|
||||
micros += event.dur;
|
||||
tasks += 1;
|
||||
}
|
||||
return { ms: micros / 1000, tasks };
|
||||
};
|
||||
|
||||
const out = {};
|
||||
|
||||
// 1. Подсказка стоит там и только там, где стоит фильтр.
|
||||
await setBackground('daynight');
|
||||
const dayStyle = await outlineStyle();
|
||||
out.dayCycleClassOn = dayStyle?.daycycle === true;
|
||||
out.dayCycleOutlineFiltered = /drop-shadow/.test(dayStyle?.filter || '');
|
||||
out.dayCycleOutlinePromoted = /filter/.test(dayStyle?.willChange || '');
|
||||
|
||||
await setBackground('static');
|
||||
const staticStyle = await outlineStyle();
|
||||
out.staticClassOff = staticStyle?.daycycle === false;
|
||||
out.staticOutlineUnfiltered = (staticStyle?.filter || 'none') === 'none';
|
||||
// Статичный фон не платит за чужую подсказку ни слоем, ни памятью.
|
||||
out.staticOutlineNotPromoted = !/filter/.test(staticStyle?.willChange || '');
|
||||
|
||||
// 2. Отношение растеризации на настоящей панораме.
|
||||
const staticPan = await measurePan();
|
||||
await setBackground('daynight');
|
||||
const dayPan = await measurePan();
|
||||
|
||||
out.rasterTasksObserved = staticPan.tasks > 0 && dayPan.tasks > 0;
|
||||
const ratio = staticPan.ms > 0 ? dayPan.ms / staticPan.ms : Infinity;
|
||||
out.rasterRatioWithinBudget = ratio <= RATIO_CEILING;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
staticPanMs: Number(staticPan.ms.toFixed(1)), staticPanTasks: staticPan.tasks,
|
||||
dayCyclePanMs: Number(dayPan.ms.toFixed(1)), dayCyclePanTasks: dayPan.tasks,
|
||||
ratio: Number(ratio.toFixed(2)), ceiling: RATIO_CEILING,
|
||||
}, null, 1));
|
||||
|
||||
for (const [name, value] of Object.entries(out)) check(name, value);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,252 @@
|
||||
// #564 AC2: real rendered Icon/Text/Double/legacy faces must own every
|
||||
// painted cardinal point even when the invisible 44 px floor of a neighbouring
|
||||
// marker overlaps it. Synthetic rectangles cannot prove the CSS/DOM geometry.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 780, height: 720 });
|
||||
|
||||
const setup = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const marker = (id, patch) => ({
|
||||
...(card._serverCfg.markers || []).find((item) => item.id === id),
|
||||
id, binding: `device:${id}`, tap_action: 'info', ...patch,
|
||||
});
|
||||
const replacements = new Map([
|
||||
['d_light1', marker('d_light1', { display: 'static_icon' })],
|
||||
['d_tv', marker('d_tv', { display: 'value' })],
|
||||
['d_temp', marker('d_temp', {
|
||||
display: 'badge',
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: 'sensor.living_temp' },
|
||||
position: 'right',
|
||||
},
|
||||
})],
|
||||
// No explicit value_badge: temperature plus humidity must take the legacy
|
||||
// two-reading route and produce .with-legacy in the real renderer.
|
||||
['d_window', marker('d_window', { display: 'badge' })],
|
||||
]);
|
||||
card._serverCfg.markers = [
|
||||
...(card._serverCfg.markers || []).filter((item) => !replacements.has(item.id)
|
||||
&& item.id !== 'hit-neighbour'),
|
||||
...replacements.values(),
|
||||
{
|
||||
id: 'hit-neighbour', name: 'Neighbour floor', binding: 'virtual',
|
||||
display: 'static_icon', tap_action: 'info', space: card._space,
|
||||
},
|
||||
];
|
||||
card._layout = {
|
||||
...card._layout,
|
||||
'hit-neighbour': { s: card._space, x: 0.5, y: 0.5 },
|
||||
};
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
entities: {
|
||||
...card.hass.entities,
|
||||
'sensor.window_temperature': {
|
||||
entity_id: 'sensor.window_temperature', device_id: 'd_window',
|
||||
platform: 'demo', disabled_by: null,
|
||||
},
|
||||
'sensor.window_humidity': {
|
||||
entity_id: 'sensor.window_humidity', device_id: 'd_window',
|
||||
platform: 'demo', disabled_by: null,
|
||||
},
|
||||
},
|
||||
states: {
|
||||
...card.hass.states,
|
||||
'binary_sensor.window': {
|
||||
entity_id: 'binary_sensor.window', state: '47',
|
||||
attributes: {
|
||||
friendly_name: 'Window humidity', device_class: 'humidity',
|
||||
unit_of_measurement: '%',
|
||||
},
|
||||
},
|
||||
'sensor.window_temperature': {
|
||||
entity_id: 'sensor.window_temperature', state: '21.5',
|
||||
attributes: {
|
||||
friendly_name: 'Window temperature', device_class: 'temperature',
|
||||
unit_of_measurement: '°C',
|
||||
},
|
||||
},
|
||||
'sensor.window_humidity': {
|
||||
entity_id: 'sensor.window_humidity', state: '47',
|
||||
attributes: {
|
||||
friendly_name: 'Window humidity', device_class: 'humidity',
|
||||
unit_of_measurement: '%',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
card._regSignature = '';
|
||||
card._cfgEpoch++;
|
||||
card._maybeRebuildDevices();
|
||||
// Keep the browser fixture inside the already-authoritative demo registry:
|
||||
// project two existing active entities into one production-shaped legacy
|
||||
// DevItem, with humidity as primary and the thermometer icon. That is the
|
||||
// exact old two-reading condition consumed by renderDeviceFace().
|
||||
const legacyDevice = card._devices.find((device) => device.id === 'd_window');
|
||||
if (legacyDevice) {
|
||||
legacyDevice.primary = 'binary_sensor.window';
|
||||
legacyDevice.entities = ['binary_sensor.window', 'sensor.living_temp'];
|
||||
legacyDevice.icon = 'mdi:thermometer';
|
||||
}
|
||||
card._visibleDeviceSnapshot = null;
|
||||
card._candidateDeviceSnapshot = null;
|
||||
card._renderLife.invalidate();
|
||||
card._planHassMemo = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
const shell = (id) => card.renderRoot
|
||||
.querySelector(`[data-hp="device"][data-id="${id}"] .device-shell`);
|
||||
return {
|
||||
icon: shell('d_light1')?.className || '',
|
||||
text: shell('d_tv')?.className || '',
|
||||
double: shell('d_temp')?.className || '',
|
||||
legacy: shell('d_window')?.className || '',
|
||||
};
|
||||
});
|
||||
|
||||
const ids = {
|
||||
icon: 'd_light1',
|
||||
text: 'd_tv',
|
||||
double: 'd_temp',
|
||||
legacy: 'd_window',
|
||||
};
|
||||
const directions = ['right', 'bottom', 'left', 'top'];
|
||||
const cases = [];
|
||||
|
||||
for (const [type, targetId] of Object.entries(ids)) {
|
||||
for (const direction of directions) {
|
||||
const prepared = await page.evaluate(async ({ type, targetId, direction }) => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const target = root.querySelector(`[data-hp="device"][data-id="${targetId}"]`);
|
||||
const neighbour = root.querySelector('[data-hp="device"][data-id="hit-neighbour"]');
|
||||
const shell = target?.querySelector('.device-shell');
|
||||
const badge = target?.querySelector('.value-badge');
|
||||
const stage = root.querySelector('.stage');
|
||||
const cardinalDirections = ['right', 'bottom', 'left', 'top'];
|
||||
if (!target || !neighbour || !shell || !stage) return { ready: false };
|
||||
|
||||
// Put the invisible competitor first in DOM/index order. A resolver
|
||||
// regressed to "first candidate wins" must therefore fail this smoke.
|
||||
neighbour.parentElement.prepend(neighbour);
|
||||
for (const node of root.querySelectorAll('[data-hp="device"]')) {
|
||||
node.style.display = node === target || node === neighbour ? '' : 'none';
|
||||
}
|
||||
for (const name of cardinalDirections) {
|
||||
shell.classList.remove(`pos-${name}`);
|
||||
badge?.classList.remove(`pos-${name}`);
|
||||
}
|
||||
// Icon/Text have no directional section, so the direction selects the
|
||||
// tested side of their painted core. Double/legacy additionally rotate
|
||||
// their real flex capsule through every production CSS direction.
|
||||
if (shell.classList.contains('with-values')) {
|
||||
shell.classList.add(`pos-${direction}`);
|
||||
badge?.classList.add(`pos-${direction}`);
|
||||
}
|
||||
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
target.style.left = `${stageRect.width / 2}px`;
|
||||
target.style.top = `${stageRect.height / 2}px`;
|
||||
neighbour.style.left = `${stageRect.width / 2}px`;
|
||||
neighbour.style.top = `${stageRect.height / 2}px`;
|
||||
card._deviceHits.invalidate();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
const frame = target.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const unit = {
|
||||
right: [1, 0], bottom: [0, 1], left: [-1, 0], top: [0, -1],
|
||||
}[direction];
|
||||
const inset = 3;
|
||||
const point = {
|
||||
x: unit[0] > 0 ? frame.right - inset
|
||||
: unit[0] < 0 ? frame.left + inset : frame.left + frame.width / 2,
|
||||
y: unit[1] > 0 ? frame.bottom - inset
|
||||
: unit[1] < 0 ? frame.top + inset : frame.top + frame.height / 2,
|
||||
};
|
||||
|
||||
const neighbourFrame = neighbour.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const floorStyle = getComputedStyle(neighbour, '::before');
|
||||
const floorRadius = Math.max(parseFloat(floorStyle.width), parseFloat(floorStyle.height)) / 2;
|
||||
const paintedRadius = Math.max(neighbourFrame.width, neighbourFrame.height) / 2;
|
||||
const distance = Math.min(floorRadius - 2, paintedRadius + 3);
|
||||
const desired = {
|
||||
x: point.x + unit[0] * distance,
|
||||
y: point.y + unit[1] * distance,
|
||||
};
|
||||
neighbour.style.left = `${desired.x - stageRect.left}px`;
|
||||
neighbour.style.top = `${desired.y - stageRect.top}px`;
|
||||
card._deviceHits.invalidate();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
const finalNeighbourFrame = neighbour.querySelector('.device-shell-frame').getBoundingClientRect();
|
||||
const neighbourCore = neighbour.querySelector('.device-core').getBoundingClientRect();
|
||||
const centre = {
|
||||
x: neighbourCore.left + neighbourCore.width / 2,
|
||||
y: neighbourCore.top + neighbourCore.height / 2,
|
||||
};
|
||||
const overlapDistance = Math.hypot(point.x - centre.x, point.y - centre.y);
|
||||
const deepElementFromPoint = (x, y) => {
|
||||
let node = document.elementFromPoint(x, y);
|
||||
while (node?.shadowRoot?.elementFromPoint) {
|
||||
const deeper = node.shadowRoot.elementFromPoint(x, y);
|
||||
if (!deeper || deeper === node) break;
|
||||
node = deeper;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const native = deepElementFromPoint(point.x, point.y)
|
||||
?.closest?.('[data-hp="device"]')?.dataset?.id || null;
|
||||
return {
|
||||
ready: true, type, direction, targetId, point,
|
||||
semantic: card._deviceHitOwnerAt(point.x, point.y)?.id || null,
|
||||
native,
|
||||
neighbourBeforeTarget: !!(neighbour.compareDocumentPosition(target)
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
targetPainted: point.x >= frame.left && point.x <= frame.right
|
||||
&& point.y >= frame.top && point.y <= frame.bottom,
|
||||
neighbourFloor: overlapDistance <= floorRadius,
|
||||
neighbourPainted: point.x >= finalNeighbourFrame.left
|
||||
&& point.x <= finalNeighbourFrame.right
|
||||
&& point.y >= finalNeighbourFrame.top
|
||||
&& point.y <= finalNeighbourFrame.bottom,
|
||||
};
|
||||
}, { type, targetId, direction });
|
||||
|
||||
if (prepared.ready) {
|
||||
await page.mouse.click(prepared.point.x, prepared.point.y);
|
||||
prepared.clicked = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
await card.updateComplete;
|
||||
const id = card._infoCard?.id || null;
|
||||
card._infoCard = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return id;
|
||||
});
|
||||
}
|
||||
cases.push(prepared);
|
||||
}
|
||||
}
|
||||
|
||||
const out = {
|
||||
realFaceKinds: setup.icon.includes('device-shell')
|
||||
&& !setup.icon.includes('with-values') && !setup.icon.includes('text-shell')
|
||||
&& setup.text.includes('text-shell') && !setup.text.includes('with-values')
|
||||
&& setup.double.includes('with-values') && !setup.double.includes('with-legacy')
|
||||
&& setup.legacy.includes('with-values') && setup.legacy.includes('with-legacy'),
|
||||
allSixteenCasesRan: cases.length === 16 && cases.every((entry) => entry.ready),
|
||||
everyPaintedPointOwnsNativeAndSemanticHit: cases.every((entry) =>
|
||||
entry.neighbourBeforeTarget && entry.targetPainted
|
||||
&& entry.neighbourFloor && !entry.neighbourPainted
|
||||
&& entry.native === entry.targetId && entry.semantic === entry.targetId),
|
||||
everyPaintedPointOwnsClick: cases.every((entry) => entry.clicked === entry.targetId),
|
||||
};
|
||||
|
||||
if (Object.values(out).some((value) => value !== true)) {
|
||||
console.error(JSON.stringify({ setup, cases }, null, 2));
|
||||
}
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -37,7 +37,7 @@ const res = await page.evaluate(async () => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
// #397 AC3: the reconnect path reads BOTH answers, and a config that
|
||||
// differs clears the history for its own reason (`configChanged` in
|
||||
// `_adoptStructuralResponses`). To isolate the layout question the
|
||||
// `adoptStructuralResponses`, #500). To isolate the layout question the
|
||||
// fake server echoes the config the card already holds.
|
||||
return { config: structuredClone(c._serverCfg), rev: c._cfgRev };
|
||||
}
|
||||
@@ -294,7 +294,7 @@ const res = await page.evaluate(async () => {
|
||||
placement: structuredClone(rawProbe) },
|
||||
});
|
||||
// #397 AC3: the reconnect path (`_loadFromServer` →
|
||||
// `_adoptStructuralResponses`) is the OTHER reader of the same value: it
|
||||
// `adoptAuthoritativeGated`, #500) is the OTHER reader of the same value: it
|
||||
// compares the stored `_layoutContentFingerprint` with the server answer
|
||||
// instead of recomputing it. It must be exercised right after a write,
|
||||
// before any reload has had a chance to align the two sides — otherwise
|
||||
|
||||
@@ -26,44 +26,182 @@ out.pinchZoomsInPlanEditor = await page.evaluate(() => {
|
||||
return zoomed && c._path.length === 0;
|
||||
});
|
||||
|
||||
// A child control may stop pointer events before the stage sees the first
|
||||
// finger. The card-level capture guard must still classify the sequence as a
|
||||
// pinch and swallow WebKit's synthetic click before it reaches that control.
|
||||
out.pinchCannotMisclickInteractiveChild = await page.evaluate(async () => {
|
||||
// A real device is the safety boundary: neither order of a pinch may arm the
|
||||
// marker's long press, and both click and contextmenu compatibility events stay
|
||||
// owned by that gesture. Exercise every terminal path because touch browsers
|
||||
// disagree about pointerup/cancel/capture ordering.
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('view');
|
||||
await c.updateComplete;
|
||||
const stage = c._stageEl;
|
||||
const probe = document.createElement('button');
|
||||
let clicks = 0;
|
||||
probe.addEventListener('pointerdown', (ev) => ev.stopPropagation());
|
||||
probe.addEventListener('pointermove', (ev) => ev.stopPropagation());
|
||||
probe.addEventListener('pointerup', (ev) => ev.stopPropagation());
|
||||
probe.addEventListener('click', () => clicks++);
|
||||
stage.appendChild(probe);
|
||||
const pointer = (type, id, target, x = id === 51 ? 300 : 400) => target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
|
||||
clientX: x, clientY: 300,
|
||||
const marker = c.renderRoot.querySelector('.dev[data-entity]')
|
||||
|| c.renderRoot.querySelector('.dev');
|
||||
if (!marker) return {
|
||||
pinchZoomsFromDevice: false,
|
||||
stageThenDeviceHoldBlocked: false,
|
||||
stageThenDeviceContextMenuBlocked: false,
|
||||
pinchIntermediateClickBlocked: false,
|
||||
pinchDelayedClickBlocked: false,
|
||||
pinchTerminalVariantsBlocked: false,
|
||||
pinchCancelsDeviceLongPress: false,
|
||||
nextDeliberateDeviceTapWorks: false,
|
||||
nextDeliberateDeviceLongPressWorks: false,
|
||||
mouseContextMenuStillWorks: false,
|
||||
keyboardContextMenuStillWorks: false,
|
||||
};
|
||||
let actionCalls = 0;
|
||||
let moreInfoCalls = 0;
|
||||
let serviceCalls = 0;
|
||||
const originalClickDevice = c._clickDevice;
|
||||
const originalOpenMoreInfo = c._openMoreInfo;
|
||||
const originalCallService = c.hass.callService;
|
||||
c._clickDevice = () => { actionCalls += 1; };
|
||||
c._openMoreInfo = () => { moreInfoCalls += 1; };
|
||||
c.hass.callService = async () => { serviceCalls += 1; };
|
||||
c._infoCard = null;
|
||||
c._tapConfirm = null;
|
||||
const pointer = (type, id, target, x, pointerType = 'touch') => target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, pointerType,
|
||||
clientX: x, clientY: 300, button: type === 'pointerdown' ? 0 : -1,
|
||||
}));
|
||||
const compatibilityClick = () => marker.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
}));
|
||||
const compatibilityContextMenu = (pointerType = 'touch', id = 900) => {
|
||||
const event = new PointerEvent('contextmenu', {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id,
|
||||
pointerType, clientX: 300, clientY: 300, button: 2,
|
||||
});
|
||||
marker.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
};
|
||||
const noActivation = () => actionCalls === 0 && moreInfoCalls === 0
|
||||
&& serviceCalls === 0 && c._infoCard === null && c._tapConfirm === null;
|
||||
|
||||
// The missed #563 order: stage first, marker second. Capture has already
|
||||
// made the sequence multi-touch before the marker's pointerdown handler runs.
|
||||
c._resetZoom();
|
||||
const stageFirstZoom0 = c._zoom;
|
||||
pointer('pointerdown', 41, stage, 400);
|
||||
pointer('pointerdown', 42, marker, 300);
|
||||
await new Promise((resolve) => setTimeout(resolve, 620));
|
||||
const stageThenDeviceHoldBlocked = noActivation();
|
||||
const activeContextPrevented = compatibilityContextMenu('touch', 42);
|
||||
const stageThenDeviceContextMenuBlocked = activeContextPrevented && noActivation();
|
||||
pointer('pointermove', 41, stage, 450);
|
||||
pointer('pointermove', 42, marker, 250);
|
||||
const stageThenDeviceZooms = c._zoom > stageFirstZoom0 * 1.5;
|
||||
pointer('pointerup', 42, marker, 250);
|
||||
pointer('pointerup', 41, stage, 450);
|
||||
const postContextPrevented = compatibilityContextMenu('touch', 42);
|
||||
// An unrelated key is not evidence that a delayed touch click became an
|
||||
// intentional pointer action. Only the dedicated context-menu key may re-arm
|
||||
// the keyboard context menu after all contacts have ended.
|
||||
marker.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
bubbles: true, composed: true, cancelable: true, key: 'Tab',
|
||||
}));
|
||||
compatibilityClick();
|
||||
const stageThenDevicePostContextMenuBlocked = postContextPrevented && noActivation();
|
||||
|
||||
// Original #563 order: marker first, stage second. The already armed hold
|
||||
// must still be cancelled and every compatibility click must remain blocked.
|
||||
c._resetZoom();
|
||||
const zoom0 = c._zoom;
|
||||
pointer('pointerdown', 51, probe);
|
||||
pointer('pointerdown', 52, stage);
|
||||
pointer('pointermove', 51, probe, 250);
|
||||
pointer('pointerdown', 51, marker, 300);
|
||||
pointer('pointerdown', 52, stage, 400);
|
||||
pointer('pointermove', 51, marker, 250);
|
||||
pointer('pointermove', 52, stage, 450);
|
||||
const pinchZoomed = c._zoom > zoom0 * 1.5;
|
||||
pointer('pointerup', 51, probe);
|
||||
pointer('pointerup', 52, stage);
|
||||
probe.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
|
||||
const gestureClickBlocked = clicks === 0;
|
||||
await new Promise((resolve) => setTimeout(resolve, 520));
|
||||
probe.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
|
||||
probe.remove();
|
||||
const ordinaryTapStillWorks = clicks === 1;
|
||||
const pinchZoomsFromDevice = c._zoom > zoom0 * 1.5;
|
||||
pointer('pointerup', 51, marker, 250);
|
||||
compatibilityClick(); // between the two releases
|
||||
const pinchIntermediateClickBlocked = actionCalls === 0;
|
||||
pointer('pointerup', 52, stage, 450);
|
||||
await new Promise((resolve) => setTimeout(resolve, 620));
|
||||
compatibilityClick(); // deliberately later than the old 500 ms window
|
||||
const pinchDelayedClickBlocked = actionCalls === 0;
|
||||
const pinchCancelsDeviceLongPress = noActivation();
|
||||
|
||||
const terminalGesture = (firstTerminal, secondTerminal, base) => {
|
||||
pointer('pointerdown', base, marker, 300);
|
||||
pointer('pointerdown', base + 1, stage, 400);
|
||||
pointer(firstTerminal, base + 1, stage, 400); // reverse the first gesture's order
|
||||
compatibilityClick();
|
||||
compatibilityContextMenu('touch', base + 1);
|
||||
pointer(secondTerminal, base, marker, 300);
|
||||
compatibilityClick();
|
||||
compatibilityContextMenu('touch', base);
|
||||
};
|
||||
terminalGesture('pointerup', 'pointercancel', 61);
|
||||
terminalGesture('lostpointercapture', 'pointerup', 71);
|
||||
// A third marker contact is still part of the old navigation-only sequence.
|
||||
pointer('pointerdown', 75, stage, 400);
|
||||
pointer('pointerdown', 76, marker, 300);
|
||||
pointer('pointerdown', 77, marker, 320);
|
||||
pointer('pointerup', 76, marker, 300);
|
||||
pointer('pointercancel', 77, marker, 320);
|
||||
pointer('lostpointercapture', 75, stage, 400);
|
||||
compatibilityClick();
|
||||
compatibilityContextMenu('touch', 77);
|
||||
const pinchTerminalVariantsBlocked = noActivation();
|
||||
|
||||
// No timeout: the pointerdown itself, not elapsed time, proves a new intent.
|
||||
pointer('pointerdown', 81, marker, 300);
|
||||
pointer('pointerup', 81, marker, 300);
|
||||
compatibilityClick();
|
||||
const nextDeliberateDeviceTapWorks = actionCalls === 1;
|
||||
|
||||
// A fresh single-touch long press is not delayed or swallowed by the old
|
||||
// pinch. It opens only House Plan's local card through the existing timer.
|
||||
c._infoCard = null;
|
||||
pointer('pointerdown', 82, marker, 300);
|
||||
await new Promise((resolve) => setTimeout(resolve, 620));
|
||||
const nextDeliberateDeviceLongPressWorks = c._infoCard !== null
|
||||
&& moreInfoCalls === 0 && actionCalls === 1 && serviceCalls === 0;
|
||||
pointer('pointerup', 82, marker, 300);
|
||||
c._closeInfoCard();
|
||||
|
||||
// A real right click has its own mouse pointerdown, which is positive intent
|
||||
// and must not inherit the completed touch sequence's contextmenu barrier.
|
||||
pointer('pointerdown', 91, marker, 300, 'mouse');
|
||||
compatibilityContextMenu('mouse', 91);
|
||||
pointer('pointerup', 91, marker, 300, 'mouse');
|
||||
const mouseContextMenuStillWorks = moreInfoCalls === 1;
|
||||
|
||||
// Shift+F10 has no pointerdown. Its keydown is equivalent positive evidence
|
||||
// once all old contacts have ended, but never while a pinch is still active.
|
||||
pointer('pointerdown', 101, marker, 300);
|
||||
pointer('pointerdown', 102, stage, 400);
|
||||
pointer('pointerup', 101, marker, 300);
|
||||
pointer('pointerup', 102, stage, 400);
|
||||
marker.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
bubbles: true, composed: true, cancelable: true, key: 'F10', shiftKey: true,
|
||||
}));
|
||||
marker.dispatchEvent(new MouseEvent('contextmenu', {
|
||||
bubbles: true, composed: true, cancelable: true, button: 0,
|
||||
}));
|
||||
const keyboardContextMenuStillWorks = moreInfoCalls === 2;
|
||||
|
||||
c._clickDevice = originalClickDevice;
|
||||
c._openMoreInfo = originalOpenMoreInfo;
|
||||
c.hass.callService = originalCallService;
|
||||
c._setMode('plan');
|
||||
await c.updateComplete;
|
||||
return pinchZoomed && gestureClickBlocked && ordinaryTapStillWorks;
|
||||
});
|
||||
return {
|
||||
pinchZoomsFromDevice: pinchZoomsFromDevice && stageThenDeviceZooms,
|
||||
stageThenDeviceHoldBlocked,
|
||||
stageThenDeviceContextMenuBlocked: stageThenDeviceContextMenuBlocked
|
||||
&& stageThenDevicePostContextMenuBlocked,
|
||||
pinchIntermediateClickBlocked,
|
||||
pinchDelayedClickBlocked,
|
||||
pinchTerminalVariantsBlocked,
|
||||
pinchCancelsDeviceLongPress,
|
||||
nextDeliberateDeviceTapWorks,
|
||||
nextDeliberateDeviceLongPressWorks,
|
||||
mouseContextMenuStillWorks,
|
||||
keyboardContextMenuStillWorks,
|
||||
};
|
||||
}));
|
||||
|
||||
// Robot-map calibration owns the gesture surface. The card-level capture
|
||||
// guard must still suppress a two-finger misclick, but must not seed or apply
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Браузерная половина геометрического корпуса #560.
|
||||
//
|
||||
// Числовые оракулы корпуса живут в test/geometry-corpus.test.mjs и карточку не
|
||||
// поднимают. Здесь проверяется ровно то, что без карточки проверить нельзя:
|
||||
//
|
||||
// 1. штатный writer ресайза — перенос ЗАПИСЕЙ ТОЛЩИНЫ вслед за сдвинутым
|
||||
// ребром делает хост (`project()` в ResizeController), а не resize.ts;
|
||||
// 2. хвост цепочки «→ View»: план корпуса действительно рисуется, и важные
|
||||
// участки (комнаты, проём, штриховка стен) присутствуют при DPR 1 и 2.
|
||||
//
|
||||
// Рендер здесь НЕ эталон: сравниваются не пиксели, а присутствие и число узлов
|
||||
// плюс сохранность модели после жеста. Приёмка #560 прямо запрещает принимать
|
||||
// текущий рендер за единственный источник истины.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const corpus = (name) => JSON.parse(readFileSync(
|
||||
new URL(`../test/fixtures/560-corpus/${name}.json`, import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
const { page, browser } = await launch();
|
||||
|
||||
/** Положить план корпуса в карточку как готовую модель v10. */
|
||||
const loadCorpus = async (config) => {
|
||||
await page.evaluate((incoming) => {
|
||||
const card = window.__card;
|
||||
const space = incoming.spaces[0];
|
||||
card._serverCfg.model_version = incoming.model_version;
|
||||
const target = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
for (const key of ['rooms', 'walls', 'wall_segments', 'openings', 'partitions']) {
|
||||
if (space[key]) target[key] = structuredClone(space[key]);
|
||||
else delete target[key];
|
||||
}
|
||||
target.cell_cm = space.cell_cm;
|
||||
for (const key of ['decor', 'wall_columns', 'room_drafts', 'open_spans']) delete target[key];
|
||||
card._geometryHistory.clear();
|
||||
card._resize.reset();
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
return card.updateComplete;
|
||||
}, config);
|
||||
await page.waitForTimeout(60);
|
||||
};
|
||||
|
||||
const enter = async (tool) => {
|
||||
await page.evaluate((next) => {
|
||||
const card = window.__card;
|
||||
if (!card._markup) card._setMode('plan');
|
||||
card._tool = next;
|
||||
card._resize.reset();
|
||||
card.requestUpdate();
|
||||
return card.updateComplete;
|
||||
}, tool);
|
||||
await page.waitForTimeout(40);
|
||||
};
|
||||
|
||||
const settle = () => page.evaluate(() => new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
const rendered = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const svg = root.querySelector('.stage svg');
|
||||
const rooms = [...root.querySelectorAll('[data-hp="room"]')];
|
||||
const box = svg?.viewBox?.baseVal;
|
||||
// Каждая комната обязана попадать в кадр: уехавшая за viewBox геометрия
|
||||
// «нарисована» и невидима одновременно — ровно тот случай, когда скриншот
|
||||
// зелёный, а пользователь видит пустой план.
|
||||
const inside = rooms.every((node) => {
|
||||
const bbox = node.getBBox();
|
||||
return box ? bbox.x + bbox.width > box.x - 1 && bbox.x < box.x + box.width + 1
|
||||
&& bbox.y + bbox.height > box.y - 1 && bbox.y < box.y + box.height + 1 : false;
|
||||
});
|
||||
return {
|
||||
rooms: rooms.length,
|
||||
roomsInsideViewBox: inside,
|
||||
openings: root.querySelectorAll('[data-hp="opening"],[data-hp="opening-tunnel"]').length,
|
||||
wallFills: root.querySelectorAll('.wallfill,.wallhatch,[data-hp="wall"]').length,
|
||||
strokeWidths: [...new Set(rooms.map((node) => getComputedStyle(node).strokeWidth))].length,
|
||||
};
|
||||
});
|
||||
|
||||
const thicknessMultiset = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
return (space.walls || []).map((wall) => wall.cm).sort((a, b) => a - b).join(',');
|
||||
});
|
||||
|
||||
const roomPolys = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
return JSON.stringify(space.rooms.map((room) => [room.id, room.poly]));
|
||||
});
|
||||
|
||||
const pointer = (type, planX, planY, { cx, cy, pointerId = 91 } = {}) =>
|
||||
page.evaluate((args) => {
|
||||
const card = window.__card;
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const svg = stage.querySelector('svg');
|
||||
const point = new DOMPoint(args.planX, args.planY).matrixTransform(svg.getScreenCTM());
|
||||
const handles = [...card.renderRoot.querySelectorAll('.rszhandle')];
|
||||
const target = args.cx == null
|
||||
? handles.find((handle) => handle.getAttribute('aria-disabled') === 'false')
|
||||
: handles.find((handle) => Math.abs(Number(handle.getAttribute('cx')) - args.cx) < 1
|
||||
&& Math.abs(Number(handle.getAttribute('cy')) - args.cy) < 1);
|
||||
target?.dispatchEvent(new PointerEvent(args.type, {
|
||||
bubbles: true, cancelable: true, pointerId: args.pointerId,
|
||||
clientX: point.x, clientY: point.y, pointerType: 'mouse',
|
||||
buttons: args.type === 'pointerup' ? 0 : 1,
|
||||
}));
|
||||
return { sent: !!target, handle: target ? [Number(target.getAttribute('cx')), Number(target.getAttribute('cy'))] : null };
|
||||
}, { type, planX, planY, cx, cy, pointerId });
|
||||
|
||||
// --- 1. T-узел с тремя толщинами: рисуется и переживает штатный ресайз -------
|
||||
await loadCorpus(corpus('c2-t-junction-thicknesses'));
|
||||
await enter('plan');
|
||||
await settle();
|
||||
const c2view = await rendered();
|
||||
check('corpus.c2.rooms_rendered', c2view.rooms, 3);
|
||||
check('corpus.c2.rooms_inside_viewbox', c2view.roomsInsideViewBox, true);
|
||||
check('corpus.c2.wall_fills_present', c2view.wallFills > 0, true);
|
||||
const c2thickness = await thicknessMultiset();
|
||||
// Ожидание берётся из самой фикстуры, а не вписывается числом: вписанное число
|
||||
// разошлось бы с корпусом молча, и проверка стала бы декорацией.
|
||||
const c2fixture = corpus('c2-t-junction-thicknesses').spaces[0].walls
|
||||
.map((wall) => wall.cm).sort((a, b) => a - b).join(',');
|
||||
check('corpus.c2.thickness_multiset', c2thickness, c2fixture);
|
||||
check('corpus.c2.thickness_variety', new Set(c2fixture.split(',')).size >= 3, true);
|
||||
|
||||
await enter('resize');
|
||||
await settle();
|
||||
const handles = await page.evaluate(() => [...window.__card.renderRoot.querySelectorAll('.rszhandle')]
|
||||
.filter((handle) => handle.getAttribute('aria-disabled') === 'false').length);
|
||||
check('corpus.c2.enabled_handles', handles > 0, true);
|
||||
const target = await page.evaluate(() => {
|
||||
const handle = [...window.__card.renderRoot.querySelectorAll('.rszhandle')]
|
||||
.find((candidate) => candidate.getAttribute('aria-disabled') === 'false');
|
||||
return handle ? [Number(handle.getAttribute('cx')), Number(handle.getAttribute('cy'))] : null;
|
||||
});
|
||||
const down = await pointer('pointerdown', target[0], target[1], { cx: target[0], cy: target[1] });
|
||||
check('corpus.c2.resize_down_sent', down.sent, true);
|
||||
check('corpus.c2.resize_dragging', await page.evaluate(() => window.__card._resize.dragging), true);
|
||||
const step = 1000 / 240; // один шаг решётки в единицах плана
|
||||
await pointer('pointermove', target[0] + step, target[1], { cx: target[0], cy: target[1] });
|
||||
await settle();
|
||||
await pointer('pointerup', target[0] + step, target[1], { cx: target[0], cy: target[1] });
|
||||
await settle();
|
||||
await page.waitForTimeout(80);
|
||||
const c2after = await thicknessMultiset();
|
||||
// Главный оракул браузерной половины: набор толщин после штатного ресайза тот
|
||||
// же. Именно эту сохранность контроллер ресайза проверяет сам
|
||||
// (checkWallRecordsPreserved с exactMultiplicity) и откатывает жест иначе.
|
||||
check('corpus.c2.thickness_survives_resize', c2after, c2thickness);
|
||||
const c2rendered = await rendered();
|
||||
check('corpus.c2.rooms_after_resize', c2rendered.rooms, 3);
|
||||
check('corpus.c2.inside_viewbox_after_resize', c2rendered.roomsInsideViewBox, true);
|
||||
|
||||
// --- 2. Проём у вершины: рисуется и переживает перечитывание -----------------
|
||||
await loadCorpus(corpus('c4-opening-at-vertex'));
|
||||
await enter('plan');
|
||||
await settle();
|
||||
const c4view = await rendered();
|
||||
check('corpus.c4.rooms_rendered', c4view.rooms, 2);
|
||||
check('corpus.c4.opening_rendered', c4view.openings > 0, true);
|
||||
check('corpus.c4.rooms_inside_viewbox', c4view.roomsInsideViewBox, true);
|
||||
const c4before = await roomPolys();
|
||||
|
||||
// save → reload: тот же конфиг приходит заново, как после перезагрузки вкладки.
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(card._serverCfg);
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
return card.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
check('corpus.c4.geometry_survives_reload', await roomPolys(), c4before);
|
||||
const c4reloaded = await rendered();
|
||||
check('corpus.c4.rooms_after_reload', c4reloaded.rooms, 2);
|
||||
check('corpus.c4.opening_after_reload', c4reloaded.openings > 0, true);
|
||||
|
||||
await finish(browser, { c2view, c2thickness, c2after, c4view, c4reloaded });
|
||||
@@ -0,0 +1,480 @@
|
||||
// Household-сценарии основного View (#560).
|
||||
//
|
||||
// Шесть путей домашнего пользователя, а не шесть проверок функций: открыть
|
||||
// карточку на проснувшемся экране, найти тревогу, прочитать значение, выполнить
|
||||
// разрешённое действие, переключить этаж, вернуться после обновления. У каждого
|
||||
// названы персона, вход, действие и НЕЗАВИСИМЫЙ наблюдаемый оракул: где можно —
|
||||
// не «класс появился», а вызов сервиса, доступное имя или содержимое модели.
|
||||
//
|
||||
// Две ширины, потому что домашний пользователь живёт в двух разных местах:
|
||||
// настенный планшет (штатная ширина демо-стенда, 780 px) и узкая колонка
|
||||
// телефона (390 px) — та самая, на которой ломается первый контакт.
|
||||
//
|
||||
// Граница честности: это синтетический стенд. Реальная установка владельца в
|
||||
// этом заходе читалась только на чтение (профиль в docs/QUALITY-560.md),
|
||||
// добровольцев и физического Companion в проверке не было.
|
||||
import { launch, check, finish, watchPage } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
const settle = () => page.evaluate(() => new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
/** Геометрия карточки успокоилась: два одинаковых замера подряд (#533). */
|
||||
const settledBox = async (id, attempts = 25) => {
|
||||
let previous = null;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await page.waitForTimeout(120);
|
||||
const now = await page.evaluate((markerId) => {
|
||||
const node = window.__card.renderRoot.querySelector(`[data-hp="device"][data-id="${markerId}"]`);
|
||||
if (!node) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return [Number((rect.x + rect.width / 2).toFixed(2)), Number((rect.y + rect.height / 2).toFixed(2)),
|
||||
Number(rect.width.toFixed(2))];
|
||||
}, id);
|
||||
if (previous && now && previous[0] === now[0] && previous[1] === now[1]) return now;
|
||||
previous = now;
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
|
||||
const setWidth = async (px) => {
|
||||
await page.evaluate(async (width) => {
|
||||
const card = window.__card;
|
||||
card.style.width = `${width}px`;
|
||||
card._lastValidStageSize = null;
|
||||
card._refitView?.();
|
||||
await card.updateComplete;
|
||||
}, px);
|
||||
await settle();
|
||||
};
|
||||
|
||||
// --- J1. «Экран проснулся» ---------------------------------------------------
|
||||
// Персона: домашний пользователь у настенного планшета. Вход: вкладка, которая
|
||||
// никогда не загружала редакторский чанк. Оракул: план показан (вуаль снята),
|
||||
// комнаты и устройства нарисованы, редакторский чанк не запрашивался.
|
||||
const requested = [];
|
||||
page.on('request', (request) => requested.push(new URL(request.url()).pathname));
|
||||
const j1 = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
return {
|
||||
booting: card._booting,
|
||||
rooms: root.querySelectorAll('[data-hp="room"]').length,
|
||||
devices: root.querySelectorAll('[data-hp="device"]').length,
|
||||
stageVisible: getComputedStyle(root.querySelector('.stage')).visibility,
|
||||
};
|
||||
});
|
||||
out.j1 = j1;
|
||||
check('j1.plan_revealed', j1.booting === false && j1.stageVisible === 'visible', true);
|
||||
check('j1.rooms_drawn', j1.rooms > 0, true);
|
||||
check('j1.devices_drawn', j1.devices > 0, true);
|
||||
|
||||
// --- J2. «Где протечка» ------------------------------------------------------
|
||||
// Вход: датчик протечки уходит в тревогу. Оракул независимый: доступное имя
|
||||
// маркера называет состояние, а не только цвет пятна.
|
||||
const j2 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const leak = card._devices.find((device) => device.id === 'd_leak') || card._devices[0];
|
||||
const entity = leak.primary || Object.keys(card.hass.states)[0];
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states, [entity]: { ...card.hass.states[entity], state: 'on' } } };
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const node = card.renderRoot.querySelector(`[data-hp="device"][data-id="${leak.id}"]`);
|
||||
return {
|
||||
id: leak.id,
|
||||
label: node?.getAttribute('aria-label') || '',
|
||||
state: node?.dataset.state || null,
|
||||
alarmClass: node?.classList.contains('alarm') || false,
|
||||
role: node?.getAttribute('role') || null,
|
||||
segments: (node?.getAttribute('aria-label') || '').split(', ')
|
||||
.map((value) => value.trim().toLowerCase()).filter(Boolean),
|
||||
};
|
||||
});
|
||||
out.j2 = j2;
|
||||
check('j2.alarm_has_accessible_state', /\S/.test(j2.label) && j2.state !== null, true);
|
||||
check('j2.alarm_not_colour_only', j2.label.split(',').length >= 2, true);
|
||||
check('j2.alarm_fact_is_not_repeated', new Set(j2.segments).size, j2.segments.length);
|
||||
check('j2.alarm_is_spoken_once', j2.segments.filter((part) => part === 'alarm').length, 1);
|
||||
|
||||
// --- J3. «Сколько в спальне» -------------------------------------------------
|
||||
// Оракул: числовое значение попадает в ДОСТУПНОЕ ИМЯ, а не только в рисунок.
|
||||
const j3 = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const nodes = [...card.renderRoot.querySelectorAll('[data-hp="device"]')];
|
||||
const withValue = nodes
|
||||
.map((node) => ({ id: node.dataset.id, label: node.getAttribute('aria-label') || '' }))
|
||||
.filter((item) => /\d/.test(item.label));
|
||||
return { total: nodes.length, withValue: withValue.length, sample: withValue.slice(0, 2) };
|
||||
});
|
||||
out.j3 = j3;
|
||||
check('j3.values_are_readable_by_name', j3.withValue > 0, true);
|
||||
|
||||
// --- J4. «Выключить свет с клавиатуры» ---------------------------------------
|
||||
// Вход: Tab до маркера на плане, затем Enter. Оракул: вызов сервиса, а не
|
||||
// внутренний флаг карточки.
|
||||
const tabStops = await (async () => {
|
||||
let stops = 0;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
stops += 1;
|
||||
const reached = await page.evaluate(() => {
|
||||
const leaf = (() => {
|
||||
let node = document.activeElement;
|
||||
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
|
||||
return node;
|
||||
})();
|
||||
return leaf?.dataset?.hp === 'device' ? leaf.dataset.id : null;
|
||||
});
|
||||
if (reached) return { stops, id: reached };
|
||||
}
|
||||
return { stops, id: null };
|
||||
})();
|
||||
const j4 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const calls = [];
|
||||
const hass = card.hass;
|
||||
const original = hass.callService?.bind(hass);
|
||||
card.hass = { ...hass, callService: (...args) => { calls.push(`${args[0]}.${args[1]}`); return original?.(...args); } };
|
||||
await card.updateComplete;
|
||||
const leaf = (() => {
|
||||
let node = document.activeElement;
|
||||
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
|
||||
return node;
|
||||
})();
|
||||
const focusVisible = leaf?.matches?.(':focus-visible') || false;
|
||||
const ring = leaf ? getComputedStyle(leaf).getPropertyValue('--device-ring-color').trim() : null;
|
||||
const idle = card.renderRoot.querySelector('[data-hp="device"]:not(:focus-visible)');
|
||||
const idleRing = idle ? getComputedStyle(idle).getPropertyValue('--device-ring-color').trim() : null;
|
||||
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
const tipBox = tip?.getBoundingClientRect();
|
||||
const focusedDevice = card._devices.find((device) => device.id === leaf?.dataset?.id);
|
||||
const focusTooltip = {
|
||||
visible: !!tip && !tip.hidden,
|
||||
title: tip?.querySelector('b')?.textContent?.trim() || '',
|
||||
text: tip?.textContent?.trim() || '',
|
||||
expectedTitle: focusedDevice?.name || '',
|
||||
inViewport: !!tipBox && tipBox.left >= 0 && tipBox.top >= 0
|
||||
&& tipBox.right <= innerWidth && tipBox.bottom <= innerHeight,
|
||||
source: card._tip?.source || null,
|
||||
};
|
||||
leaf?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
return { calls, focusVisible, ring, idleRing, focusTooltip };
|
||||
});
|
||||
out.j4 = { ...j4, ...tabStops };
|
||||
check('j4.marker_reachable_by_tab', tabStops.id !== null, true);
|
||||
check('j4.focus_is_visible', j4.focusVisible && j4.ring !== j4.idleRing, true);
|
||||
check('j4.focus_shows_same_device_tooltip',
|
||||
j4.focusTooltip.visible && j4.focusTooltip.title === j4.focusTooltip.expectedTitle, true);
|
||||
check('j4.focus_tooltip_is_anchored_in_viewport',
|
||||
j4.focusTooltip.source === 'focus' && j4.focusTooltip.inViewport, true);
|
||||
check('j4.enter_calls_service', j4.calls.length > 0, true);
|
||||
|
||||
const j4DualInput = await page.evaluate((focusId) => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const tip = root.querySelector('[data-hp-live-tip]');
|
||||
const focusText = tip?.textContent?.trim() || '';
|
||||
const room = root.querySelector('[data-hp="room"]');
|
||||
const roomBox = room?.getBoundingClientRect();
|
||||
room?.dispatchEvent(new PointerEvent('pointermove', {
|
||||
bubbles: true, composed: true, pointerType: 'mouse',
|
||||
clientX: roomBox?.left || 0, clientY: roomBox?.top || 0,
|
||||
}));
|
||||
const afterRoom = {
|
||||
source: card._tip?.source || null,
|
||||
deviceId: card._tip?.deviceId || null,
|
||||
text: tip?.textContent?.trim() || '',
|
||||
};
|
||||
const other = [...root.querySelectorAll('[data-hp="device"]')]
|
||||
.find((marker) => marker.dataset.id !== focusId);
|
||||
const otherBox = other?.getBoundingClientRect();
|
||||
const otherName = card._devices.find((device) => device.id === other?.dataset.id)?.name || '';
|
||||
other?.dispatchEvent(new PointerEvent('pointerover', {
|
||||
bubbles: true, composed: true, pointerType: 'mouse',
|
||||
clientX: (otherBox?.left || 0) + (otherBox?.width || 0) / 2,
|
||||
clientY: (otherBox?.top || 0) + (otherBox?.height || 0) / 2,
|
||||
}));
|
||||
const afterDevice = {
|
||||
source: card._tip?.source || null,
|
||||
title: tip?.querySelector('b')?.textContent?.trim() || '',
|
||||
expectedTitle: otherName,
|
||||
};
|
||||
other?.dispatchEvent(new PointerEvent('pointerleave', {
|
||||
bubbles: false, composed: true, pointerType: 'mouse',
|
||||
clientX: otherBox?.right || 0, clientY: otherBox?.bottom || 0,
|
||||
}));
|
||||
const restored = {
|
||||
source: card._tip?.source || null,
|
||||
deviceId: card._tip?.deviceId || null,
|
||||
text: tip?.textContent?.trim() || '',
|
||||
};
|
||||
return { focusText, afterRoom, afterDevice, restored };
|
||||
}, tabStops.id);
|
||||
out.j4_dual_input = j4DualInput;
|
||||
check('j4.room_hover_does_not_replace_keyboard_tooltip', j4DualInput.afterRoom,
|
||||
{ source: 'focus', deviceId: tabStops.id, text: j4DualInput.focusText });
|
||||
check('j4.other_device_keeps_ordinary_pointer_hover',
|
||||
j4DualInput.afterDevice.source === 'pointer'
|
||||
&& j4DualInput.afterDevice.title === j4DualInput.afterDevice.expectedTitle, true);
|
||||
check('j4.pointer_leave_restores_keyboard_tooltip', j4DualInput.restored,
|
||||
{ source: 'focus', deviceId: tabStops.id, text: j4DualInput.focusText });
|
||||
|
||||
await page.keyboard.press('Tab');
|
||||
const j4Next = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
const leaf = (() => {
|
||||
let node = document.activeElement;
|
||||
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
|
||||
return node;
|
||||
})();
|
||||
return {
|
||||
visible: !!tip && !tip.hidden,
|
||||
activeTag: leaf?.tagName || null,
|
||||
activeHp: leaf?.dataset?.hp || null,
|
||||
activeId: leaf?.dataset?.id || null,
|
||||
tipSource: card._tip?.source || null,
|
||||
tipDevice: card._tip?.deviceId || null,
|
||||
};
|
||||
});
|
||||
out.j4_next = j4Next;
|
||||
check('j4.tab_moves_focus_tooltip_to_next_device',
|
||||
j4Next.visible && j4Next.activeHp === 'device' && j4Next.activeId === j4Next.tipDevice
|
||||
&& j4Next.tipSource === 'focus', true);
|
||||
|
||||
const j4Blur = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
let leaf = document.activeElement;
|
||||
while (leaf?.shadowRoot?.activeElement) leaf = leaf.shadowRoot.activeElement;
|
||||
leaf?.blur?.();
|
||||
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
return { hidden: !tip || tip.hidden, stateCleared: card._tip === null };
|
||||
});
|
||||
out.j4_blur = j4Blur;
|
||||
check('j4.blur_hides_focus_tooltip', j4Blur.hidden && j4Blur.stateCleared, true);
|
||||
|
||||
const j4HoverParity = await page.evaluate((deviceId) => {
|
||||
const card = window.__card;
|
||||
const marker = card.renderRoot.querySelector(`[data-hp="device"][data-id="${deviceId}"]`);
|
||||
const rect = marker?.getBoundingClientRect();
|
||||
marker?.dispatchEvent(new PointerEvent('pointerover', {
|
||||
bubbles: true, composed: true, pointerType: 'mouse',
|
||||
clientX: rect?.left || 0, clientY: rect?.top || 0,
|
||||
}));
|
||||
const tip = card.renderRoot.querySelector('[data-hp-live-tip]');
|
||||
const hoverText = tip?.textContent?.trim() || '';
|
||||
card._showTip(new PointerEvent('pointermove', {
|
||||
pointerType: 'mouse', clientX: innerWidth, clientY: innerHeight,
|
||||
}), 'Viewport edge', 'clamp probe');
|
||||
const edgeBox = tip?.getBoundingClientRect();
|
||||
const edgeInViewport = !!edgeBox && edgeBox.left >= 0 && edgeBox.top >= 0
|
||||
&& edgeBox.right <= innerWidth && edgeBox.bottom <= innerHeight;
|
||||
card._clearTransientHover();
|
||||
return { hoverText, edgeInViewport };
|
||||
}, tabStops.id);
|
||||
out.j4_hover_parity = j4HoverParity;
|
||||
check('j4.focus_and_mouse_tooltip_content_match',
|
||||
j4HoverParity.hoverText, j4.focusTooltip.text);
|
||||
check('j4.tooltip_clamps_at_viewport_edge', j4HoverParity.edgeInViewport, true);
|
||||
|
||||
// --- J5. «Переключить этаж» --------------------------------------------------
|
||||
const j5 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const tabs = [...root.querySelectorAll('[data-hp="space-tab"]')];
|
||||
const before = card._space;
|
||||
const other = tabs.find((tab) => tab.dataset.id !== before);
|
||||
const beforeRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
|
||||
other.click();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const afterRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
|
||||
const activeTab = root.querySelector('[data-hp="space-tab"].active');
|
||||
const navigation = root.querySelector('nav.tabs');
|
||||
const currentTabs = [...root.querySelectorAll('[data-hp="space-tab"][aria-current]')];
|
||||
const add = root.querySelector('[data-hp="space-add"]');
|
||||
return {
|
||||
tabs: tabs.length, before, after: card._space,
|
||||
roomsChanged: beforeRooms !== afterRooms,
|
||||
activeTabIsCurrent: activeTab?.dataset.id === card._space,
|
||||
// Состояние вкладки для программы: есть ли он вообще, кроме класса.
|
||||
ariaCurrent: activeTab?.getAttribute('aria-current'),
|
||||
ariaPressed: activeTab?.getAttribute('aria-pressed'),
|
||||
ariaSelected: activeTab?.getAttribute('aria-selected'),
|
||||
navigationName: navigation?.getAttribute('aria-label') || '',
|
||||
currentCount: currentTabs.length,
|
||||
inactiveHaveNoCurrent: [...root.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.filter((tab) => tab !== activeTab).every((tab) => !tab.hasAttribute('aria-current')),
|
||||
addHasNoCurrent: !add?.hasAttribute('aria-current'),
|
||||
hasTablistRoles: !!root.querySelector('[role="tablist"], [role="tab"]'),
|
||||
};
|
||||
});
|
||||
out.j5 = j5;
|
||||
check('j5.floor_switched', j5.after !== j5.before, true);
|
||||
check('j5.plan_rerendered', j5.roomsChanged, true);
|
||||
check('j5.active_tab_matches_space', j5.activeTabIsCurrent, true);
|
||||
check('j5.spaces_are_named_navigation', /\S/.test(j5.navigationName), true);
|
||||
check('j5.only_current_space_is_programmatic',
|
||||
j5.ariaCurrent === 'page' && j5.currentCount === 1 && j5.inactiveHaveNoCurrent
|
||||
&& j5.addHasNoCurrent, true);
|
||||
check('j5.native_button_navigation_is_kept', j5.hasTablistRoles, false);
|
||||
|
||||
const j5Keyboard = {};
|
||||
for (const [key, expected] of [['Enter', j5.before], [' ', j5.after]]) {
|
||||
const before = await page.evaluate(() => window.__card._space);
|
||||
await page.evaluate((id) => {
|
||||
window.__card.renderRoot.querySelector(`[data-hp="space-tab"][data-id="${id}"]`)?.focus();
|
||||
}, expected);
|
||||
await page.keyboard.press(key === ' ' ? 'Space' : key);
|
||||
await settle();
|
||||
const after = await page.evaluate(() => window.__card._space);
|
||||
j5Keyboard[key === ' ' ? 'space' : 'enter'] = { before, after, expected };
|
||||
}
|
||||
out.j5_keyboard = j5Keyboard;
|
||||
check('j5.enter_switches_space', j5Keyboard.enter.after, j5Keyboard.enter.expected);
|
||||
check('j5.space_switches_space', j5Keyboard.space.after, j5Keyboard.space.expected);
|
||||
|
||||
// --- J6. «Вернулся после обновления» ----------------------------------------
|
||||
const j6 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const chosen = card._space;
|
||||
card._serverCfg = structuredClone(card._serverCfg);
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return {
|
||||
chosen, after: card._space,
|
||||
rooms: card.renderRoot.querySelectorAll('[data-hp="room"]').length,
|
||||
devices: card.renderRoot.querySelectorAll('[data-hp="device"]').length,
|
||||
};
|
||||
});
|
||||
out.j6 = j6;
|
||||
check('j6.space_kept', j6.after === j6.chosen, true);
|
||||
check('j6.plan_redrawn', j6.rooms > 0 && j6.devices > 0, true);
|
||||
|
||||
// --- J7. Цель нажатия при плотной расстановке --------------------------------
|
||||
// Вход: пять маркеров в колонке с шагом 0,0333 нормированной единицы — самая
|
||||
// плотная расстановка, найденная на реальной установке владельца.
|
||||
// Оракул: в центре ВИДИМОГО маркера нажатие достаётся ему же.
|
||||
const dense = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
const room = space.rooms.find((candidate) => candidate.id && candidate.area) || space.rooms[0];
|
||||
const ids = ['dense1', 'dense2', 'dense3', 'dense4', 'dense5'];
|
||||
card._serverCfg.markers = [...(card._serverCfg.markers || []), ...ids.map((id, index) => ({
|
||||
id, name: `Dense ${index + 1}`, binding: 'virtual', is_light: true, tap_action: 'info',
|
||||
space: space.id, area: room.area, room_id: room.id,
|
||||
}))];
|
||||
const layout = { ...card._layout };
|
||||
ids.forEach((id, index) => { layout[id] = { s: space.id, x: 0.5, y: 0.3 + index * 0.0333 }; });
|
||||
card._layout = layout;
|
||||
card._cfgEpoch++;
|
||||
card._regSignature = '';
|
||||
card._maybeRebuildDevices();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return { ids };
|
||||
});
|
||||
|
||||
const ownerOfCentre = async (id) => {
|
||||
const centre = await settledBox(id);
|
||||
if (!centre) return null;
|
||||
return page.evaluate(([cx, cy]) => {
|
||||
let node = document.elementFromPoint(cx, cy);
|
||||
while (node?.shadowRoot?.elementFromPoint) {
|
||||
const deeper = node.shadowRoot.elementFromPoint(cx, cy);
|
||||
if (!deeper || deeper === node) break;
|
||||
node = deeper;
|
||||
}
|
||||
return node?.closest?.('[data-hp="device"]')?.dataset?.id || null;
|
||||
}, [centre[0], centre[1]]);
|
||||
};
|
||||
|
||||
const semanticOwnerOfCentre = async (id) => {
|
||||
const centre = await settledBox(id);
|
||||
if (!centre) return null;
|
||||
return page.evaluate(([cx, cy]) => window.__card._deviceHitOwnerAt(cx, cy)?.id || null,
|
||||
[centre[0], centre[1]]);
|
||||
};
|
||||
|
||||
const clickAndReadInfoOwner = async (centre) => {
|
||||
await page.mouse.click(centre[0], centre[1]);
|
||||
await page.waitForTimeout(100);
|
||||
return page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const id = card._infoCard?.id || null;
|
||||
card._infoCard = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return id;
|
||||
});
|
||||
};
|
||||
|
||||
const tablet = {
|
||||
size: (await settledBox('dense1'))?.[2] ?? null, owners: [], semanticOwners: [],
|
||||
};
|
||||
for (const id of dense.ids) {
|
||||
tablet.owners.push(await ownerOfCentre(id));
|
||||
tablet.semanticOwners.push(await semanticOwnerOfCentre(id));
|
||||
}
|
||||
const tabletCentre = await settledBox('dense1');
|
||||
tablet.clickWentTo = await clickAndReadInfoOwner(tabletCentre);
|
||||
out.j7_tablet = tablet;
|
||||
// Настенный планшет: центр видимого маркера принадлежит ему. Это то свойство,
|
||||
// которое правка плотных целей обязана сохранить.
|
||||
check('j7.tablet_centre_belongs_to_its_marker',
|
||||
tablet.owners.every((owner, index) => owner === dense.ids[index]), true);
|
||||
check('j7.tablet_semantic_owner_is_its_marker',
|
||||
tablet.semanticOwners.every((owner, index) => owner === dense.ids[index]), true);
|
||||
check('j7.tablet_click_reaches_its_marker', tablet.clickWentTo, 'dense1');
|
||||
|
||||
// Узкая колонка телефона — исходная регрессия #564. И native hit owner, и
|
||||
// семантический владелец действия обязаны совпасть с видимым маркером.
|
||||
await setWidth(390);
|
||||
const phone = {
|
||||
size: (await settledBox('dense1'))?.[2] ?? null, owners: [], semanticOwners: [],
|
||||
};
|
||||
for (const id of dense.ids) {
|
||||
phone.owners.push(await ownerOfCentre(id));
|
||||
phone.semanticOwners.push(await semanticOwnerOfCentre(id));
|
||||
}
|
||||
const phoneCentre = await settledBox('dense1');
|
||||
phone.clickWentTo = await clickAndReadInfoOwner(phoneCentre);
|
||||
phone.latchedOwner = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const first = card.renderRoot.querySelector('[data-hp="device"][data-id="dense1"]');
|
||||
const second = card.renderRoot.querySelector('[data-hp="device"][data-id="dense2"]');
|
||||
const a = first.getBoundingClientRect();
|
||||
const b = second.getBoundingClientRect();
|
||||
const pointerId = 564;
|
||||
const event = (type, rect, buttons) => new PointerEvent(type, {
|
||||
pointerId, pointerType: 'touch', buttons, bubbles: true, cancelable: true,
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
});
|
||||
const d1 = card._devices.find((device) => device.id === 'dense1');
|
||||
const d2 = card._devices.find((device) => device.id === 'dense2');
|
||||
card._pointerDown(event('pointerdown', a, 1), d1);
|
||||
const moved = card._pointerMove(event('pointermove', b, 1), d2)?.id || null;
|
||||
card._pointerUp(event('pointerup', b, 0), d2);
|
||||
card._clickDevice(event('click', b, 0), d2);
|
||||
await card.updateComplete;
|
||||
const clicked = card._infoCard?.id || null;
|
||||
card._infoCard = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
return { moved, clicked };
|
||||
});
|
||||
out.j7_phone = phone;
|
||||
check('j7.phone_centre_belongs_to_its_marker',
|
||||
phone.owners.every((owner, index) => owner === dense.ids[index]), true);
|
||||
check('j7.phone_semantic_owner_is_its_marker',
|
||||
phone.semanticOwners.every((owner, index) => owner === dense.ids[index]), true);
|
||||
check('j7.phone_click_reaches_its_marker', phone.clickWentTo, 'dense1');
|
||||
check('j7.pointer_owner_is_latched_through_terminal_click',
|
||||
phone.latchedOwner, { moved: 'dense1', clicked: 'dense1' });
|
||||
|
||||
await finish(browser, out);
|
||||
@@ -1,4 +1,4 @@
|
||||
// #122/#160 Stage 3: alpha lifecycle, structural composition and flat editor boundary.
|
||||
// #122/#160/#570 Stage 4: alpha lifecycle, visual handoff and flat editor boundary.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
@@ -125,7 +125,7 @@ const out = await page.evaluate(async () => {
|
||||
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-underlay"] .iso-floor-side')
|
||||
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
|
||||
result.stage3RevisionAdvertised = stage?.getAttribute('data-hp-iso-stage') === '3';
|
||||
result.stage4RevisionAdvertised = stage?.getAttribute('data-hp-iso-stage') === '4';
|
||||
result.sharedProjectionSnapshot = [
|
||||
'.iso-underlay-svg', '.plan-svg', '.iso-shadows-svg', '.iso-walls-svg', '.iso-overlays-svg',
|
||||
]
|
||||
@@ -135,52 +135,49 @@ const out = await page.evaluate(async () => {
|
||||
const requiredDefs = [
|
||||
'hp-iso-wall-side', 'hp-iso-wall-top', 'hp-iso-wall-texture',
|
||||
'hp-iso-floor-texture', 'hp-iso-ambient-shadow', 'hp-iso-contact-shadow',
|
||||
'hp-iso-leaf-shadow', 'hp-iso-overlay-ground',
|
||||
'hp-iso-leaf-shadow',
|
||||
];
|
||||
result.stage3DefinitionsBounded = materialDefs.length >= requiredDefs.length
|
||||
result.stage4DefinitionsBounded = materialDefs.length >= requiredDefs.length
|
||||
&& materialDefs.length <= 16
|
||||
&& new Set(materialDefs.map((node) => node.id)).size === materialDefs.length
|
||||
&& requiredDefs.every((id) => materialDefs.some((node) => node.id === id));
|
||||
const shadowNodes = [...root().querySelectorAll(
|
||||
'.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow, .iso-overlay-ground',
|
||||
'.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow',
|
||||
)];
|
||||
const fixedLightTransform = card._isoSceneRuntime.isoFixedLightTransform(card._cellCm);
|
||||
result.fixedLightVectorShared = shadowNodes.some((node) => node.matches('.iso-overlay-ground'))
|
||||
&& ['iso-ambient-shadow', 'iso-contact-shadow', 'iso-leaf-shadow', 'iso-overlay-ground']
|
||||
result.fixedLightVectorShared = ['iso-ambient-shadow', 'iso-contact-shadow', 'iso-leaf-shadow']
|
||||
.every((name) => shadowNodes.some((node) => node.classList.contains(name)))
|
||||
&& shadowNodes.every((node) => node.getAttribute('transform') === fixedLightTransform);
|
||||
const hasOpeningSurface = (id, surface, material) => !!root().querySelector(
|
||||
`[data-hp="iso-openings"] [data-id="${id}"][data-surface="${surface}"].iso-material-${material}`,
|
||||
);
|
||||
result.stage3OpeningMaterials = [
|
||||
result.stage4OpeningMaterials = [
|
||||
['iso-centred-door', 'jamb-reveal', 'reveal'],
|
||||
['iso-centred-door', 'leaf-front', 'matte-leaf'],
|
||||
['iso-centred-door', 'leaf-back', 'matte-leaf'],
|
||||
['iso-centred-door', 'leaf-edge', 'matte-leaf'],
|
||||
['iso-centred-door', 'leaf-top', 'matte-leaf'],
|
||||
['iso-flipped-window', 'window-insert', 'light-window'],
|
||||
['iso-flipped-window', 'window-frame-side', 'light-frame'],
|
||||
['iso-flipped-window', 'window-frame-top', 'light-frame'],
|
||||
['iso-flipped-window', 'window-sill', 'light-sill'],
|
||||
['iso-flipped-window', 'window-sash-side', 'light-frame'],
|
||||
['iso-flipped-window', 'window-sash-bottom', 'light-frame'],
|
||||
['iso-flipped-window', 'window-sash-top', 'light-frame'],
|
||||
['iso-flipped-window', 'window-glass', 'glass-side'],
|
||||
['iso-flipped-window', 'window-glass-top', 'glass-top'],
|
||||
['iso-centred-gate', 'jamb-reveal', 'reveal'],
|
||||
['iso-centred-gate', 'leaf-front', 'matte-leaf'],
|
||||
].every(([id, surface, material]) => hasOpeningSurface(id, surface, material))
|
||||
&& !root().querySelector(
|
||||
'[data-hp="iso-openings"] [data-surface*="glass"],'
|
||||
+ '[data-hp="iso-openings"] .iso-material-dark-glass',
|
||||
);
|
||||
&& !root().querySelector('[data-hp="iso-openings"] .iso-material-dark-glass');
|
||||
const raisedRoot = (node, kind) => node?.getAttribute('data-hp-iso-overlay-kind') === kind
|
||||
&& node.getAttribute('data-hp-iso-raised') === 'true'
|
||||
&& node.getAttribute('data-hp-iso-floor')
|
||||
&& node.getAttribute('data-hp-iso-visual')
|
||||
&& node.getAttribute('data-hp-iso-floor') !== node.getAttribute('data-hp-iso-visual');
|
||||
result.stage3RaisesExactInteractiveRoots = raisedRoot(device, 'device')
|
||||
result.stage4UsesExactLowInteractiveRoots = raisedRoot(device, 'device')
|
||||
&& raisedRoot(roomLabel, 'room-label')
|
||||
&& raisedRoot(openingLock, 'opening-lock')
|
||||
&& ['device', 'room-label', 'opening-lock'].every((kind) => root().querySelector(
|
||||
`[data-hp="iso-raised-overlays"] .iso-overlay-tether`
|
||||
+ `[data-hp-iso-overlay-kind="${kind}"]`,
|
||||
));
|
||||
&& !root().querySelector('.iso-overlay-tether, .iso-overlay-ground, #hp-iso-overlay-ground');
|
||||
result.raisedFootprintsStayInvisible = !root().querySelector(
|
||||
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
|
||||
);
|
||||
@@ -192,31 +189,12 @@ const out = await page.evaluate(async () => {
|
||||
const raisedNodes = [...root().querySelectorAll('[data-hp-iso-raised="true"]')]
|
||||
.filter((node) => node.closest('.devlayer'));
|
||||
const raisedGeometryMatches = (node) => {
|
||||
const kind = node.getAttribute('data-hp-iso-overlay-kind');
|
||||
const id = node.getAttribute('data-id');
|
||||
const suffix = id ? `[data-id="${CSS.escape(id)}"]` : '';
|
||||
const visual = (node.getAttribute('data-hp-iso-visual') || '').split(',').map(Number);
|
||||
const floorPoint = (node.getAttribute('data-hp-iso-floor') || '').split(',').map(Number);
|
||||
if (visual.length !== 2 || floorPoint.length !== 2) return false;
|
||||
const expected = sceneToClient(visual), actual = center(node);
|
||||
const ground = root().querySelector(
|
||||
`[data-hp="iso-overlay-grounds"] [data-hp-iso-overlay-kind="${kind}"]${suffix}`,
|
||||
) || root().querySelector(
|
||||
`[data-hp="iso-overlay-grounds"] [data-hp-iso-overlay-kind="${kind}"]`,
|
||||
);
|
||||
const tether = root().querySelector(
|
||||
`[data-hp="iso-raised-overlays"] .iso-overlay-tether[data-hp-iso-overlay-kind="${kind}"]${suffix}`,
|
||||
) || root().querySelector(
|
||||
`[data-hp="iso-raised-overlays"] .iso-overlay-tether[data-hp-iso-overlay-kind="${kind}"]`,
|
||||
);
|
||||
return Math.hypot(actual[0] - expected[0], actual[1] - expected[1]) <= 1
|
||||
&& !!ground && Math.hypot(Number(ground.getAttribute('cx')) - floorPoint[0],
|
||||
Number(ground.getAttribute('cy')) - floorPoint[1]) <= 1e-6
|
||||
&& !!tether
|
||||
&& Math.hypot(Number(tether.getAttribute('x1')) - floorPoint[0],
|
||||
Number(tether.getAttribute('y1')) - floorPoint[1]) <= 1e-6
|
||||
&& Math.hypot(Number(tether.getAttribute('x2')) - visual[0],
|
||||
Number(tether.getAttribute('y2')) - visual[1]) <= 1e-6;
|
||||
&& Math.hypot(floorPoint[0] - visual[0], floorPoint[1] - visual[1]) > 0;
|
||||
};
|
||||
result.globalFitContainsRaisedFootprints = raisedNodes.length >= 3
|
||||
&& raisedNodes.every((node) => inside(root().querySelector('.stage'), node));
|
||||
@@ -384,10 +362,10 @@ const out = await page.evaluate(async () => {
|
||||
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top')
|
||||
&& !!root().querySelector('[data-hp="iso-openings"] .iso-opening-panel')
|
||||
&& !root().querySelector('.iso-overlay-plate, .iso-overlay-plate-texture')
|
||||
&& !!root().querySelector('[data-hp="iso-raised-overlays"] .iso-overlay-tether')
|
||||
&& !root().querySelector('.iso-overlay-tether, .iso-overlay-ground, #hp-iso-overlay-ground')
|
||||
&& !root().querySelector('[data-hp-iso-material-def]')
|
||||
&& !root().querySelector('.iso-ambient-shadow, .iso-contact-shadow,'
|
||||
+ ' .iso-leaf-shadow, .iso-overlay-ground');
|
||||
+ ' .iso-leaf-shadow');
|
||||
CSS.supports = nativeCssSupports;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// #122/#160 Stage 3: live-floor preservation, raised overlays, openings and touch paths.
|
||||
// #122/#160/#570 Stage 4: live-floor preservation, low overlays, openings and touch paths.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
@@ -56,7 +56,7 @@ const out = await page.evaluate(async () => {
|
||||
},
|
||||
});
|
||||
// Put one real interactive device almost on the shared wall so this smoke
|
||||
// always owns a deterministic non-zero nudge/tether witness.
|
||||
// always owns a deterministic non-zero nudge witness.
|
||||
original._layout.d_light1 = { ...original._layout.d_light1, s: 'f1', x: 0.548, y: 0.22 };
|
||||
original._layout['iso-vacuum'] = { s: 'f1', x: 0.18, y: 0.78 };
|
||||
original._hoverRoom = { space: 'f1', room: original._spaceModel('f1').rooms[0] };
|
||||
@@ -188,12 +188,10 @@ const out = await page.evaluate(async () => {
|
||||
];
|
||||
const overlaySvg = root(original).querySelector('.iso-overlays-svg');
|
||||
const wallSvg = root(original).querySelector('.iso-walls-svg');
|
||||
const overlayGround = root(original).querySelector('[data-hp="iso-overlay-grounds"]');
|
||||
const overlayGroundFilter = root(original).querySelector('#hp-iso-overlay-ground');
|
||||
const doorResolved = original._openingsR.find((opening) => opening.id === 'iso-door');
|
||||
const isoFloorMatrix = (root(original).querySelector('.iso-floor-scene')
|
||||
?.getAttribute('transform')?.match(/-?[\d.e+]+/gi) || []).map(Number);
|
||||
const wallHeight = 64 * (5 / original._cellCm);
|
||||
const wallHeight = 84 * (5 / original._cellCm);
|
||||
const openingTopPoint = doorResolved && isoFloorMatrix.length === 6
|
||||
? new DOMPoint(
|
||||
isoFloorMatrix[0] * doorResolved.rx + isoFloorMatrix[2] * doorResolved.ry + isoFloorMatrix[4],
|
||||
@@ -206,7 +204,7 @@ const out = await page.evaluate(async () => {
|
||||
&& ![...root(original).querySelectorAll('.iso-wall-top')]
|
||||
.some((path) => path.isPointInFill(openingTopPoint));
|
||||
const result = {
|
||||
isoOnTouch: root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '3'
|
||||
isoOnTouch: root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4'
|
||||
&& !!root(original).querySelector('[data-hp="iso-walls"]'),
|
||||
flatIsoLayerParity: JSON.stringify({ ...flat, openings: 0, verticalOpenings: before.verticalOpenings })
|
||||
=== JSON.stringify(before)
|
||||
@@ -225,10 +223,11 @@ const out = await page.evaluate(async () => {
|
||||
&& ordered.slice(1).every((node, index) => Boolean(
|
||||
ordered[index].compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
)),
|
||||
overlayGroundLivesAboveWallsWithDefs: overlayGround?.closest('svg') === overlaySvg
|
||||
&& overlayGroundFilter?.closest('svg') === overlaySvg
|
||||
&& root(original).querySelectorAll('#hp-iso-overlay-ground').length === 1
|
||||
&& Boolean(wallSvg?.compareDocumentPosition(overlayGround) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
overlaysStayAboveWallsWithoutDebugGrounds: !!overlaySvg && !!wallSvg
|
||||
&& Boolean(wallSvg.compareDocumentPosition(overlaySvg) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
&& !root(original).querySelector(
|
||||
'.iso-overlay-ground, .iso-overlay-tether, #hp-iso-overlay-ground',
|
||||
),
|
||||
haColorUpdatePainted: [...root(original).querySelectorAll('radialGradient stop')]
|
||||
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
|
||||
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
|
||||
@@ -295,10 +294,12 @@ const out = await page.evaluate(async () => {
|
||||
const noBordersRoom = root(original).querySelector('[data-hp="room-label"]');
|
||||
const noBordersLock = root(original).querySelector('.oplock');
|
||||
result.noBordersUsesTrueAffineFloor = original._effectiveProjection() === 'iso'
|
||||
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '3'
|
||||
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4'
|
||||
&& root(original).querySelector('.plan-svg')?.getAttribute('data-hp-live-viewbox') === 'camera'
|
||||
&& floorMatrix.length === 6 && floorMatrix.every(Number.isFinite)
|
||||
&& (Math.abs(floorMatrix[1]) > 1e-6 || Math.abs(floorMatrix[2]) > 1e-6)
|
||||
&& Math.abs(floorMatrix[0] - 1) < 1e-6
|
||||
&& Math.abs(floorMatrix[1]) < 1e-6 && Math.abs(floorMatrix[2]) < 1e-6
|
||||
&& Math.abs(floorMatrix[3] - Math.cos(20 * Math.PI / 180)) < 1e-6
|
||||
&& root(original).querySelectorAll('[data-hp="opening"]').length === 3
|
||||
&& [noBordersDevice, noBordersRoom, noBordersLock].every((node) => node
|
||||
&& !node.hasAttribute('data-hp-iso-raised'));
|
||||
@@ -329,9 +330,9 @@ const out = await page.evaluate(async () => {
|
||||
original._cfgEpoch++;
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
result.visibleBordersRestoreStage3 = !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !!root(original).querySelector('[data-hp="iso-raised-overlays"]')
|
||||
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '3'
|
||||
result.visibleBordersRestoreStage4 = !!root(original).querySelector('[data-hp="iso-openings"]')
|
||||
&& !!root(original).querySelector('[data-hp-iso-raised="true"]')
|
||||
&& root(original).querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4'
|
||||
&& original._isoGeometryCache.size === 1;
|
||||
|
||||
original._hoverRoom = null;
|
||||
@@ -350,7 +351,7 @@ const out = await page.evaluate(async () => {
|
||||
result.runtimeNudgeNeverPersists = JSON.stringify(original._layout) === floorLayoutBeforeLive
|
||||
&& forbiddenWrites.length === 0 && storageWrites.length === 0
|
||||
&& !!root(original).querySelector('[data-hp-iso-nudged="true"]')
|
||||
&& !!root(original).querySelector('.iso-overlay-tether');
|
||||
&& !root(original).querySelector('.iso-overlay-tether, .iso-overlay-ground');
|
||||
Storage.prototype.setItem = nativeStorageSet;
|
||||
|
||||
const fallbackCalls = [];
|
||||
@@ -365,9 +366,6 @@ const out = await page.evaluate(async () => {
|
||||
await original.updateComplete;
|
||||
const fallbackNudged = root(original).querySelector('[data-hp-iso-nudged="true"][data-id]');
|
||||
const fallbackId = fallbackNudged?.getAttribute('data-id');
|
||||
const fallbackTether = fallbackId && root(original).querySelector(
|
||||
`.iso-overlay-tether[data-id="${CSS.escape(fallbackId)}"]`,
|
||||
);
|
||||
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
|
||||
await frame();
|
||||
result.unsupportedDecorationKeepsIsoStructure = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
@@ -377,7 +375,8 @@ const out = await page.evaluate(async () => {
|
||||
result.unsupportedDecorationKeepsFootprintInvisible = !root(original).querySelector(
|
||||
'.iso-overlay-plate, .iso-overlay-plate-texture, #hp-iso-overlay-texture',
|
||||
);
|
||||
result.unsupportedDecorationKeepsTether = !!fallbackTether;
|
||||
result.unsupportedDecorationAddsNoDebugCue = !!fallbackId
|
||||
&& !root(original).querySelector('.iso-overlay-tether, .iso-overlay-ground');
|
||||
result.unsupportedDecorationKeepsActions = fallbackCalls.length === 1;
|
||||
CSS.supports = supports;
|
||||
original.requestUpdate();
|
||||
|
||||
@@ -279,9 +279,46 @@ if (handle) {
|
||||
}
|
||||
}
|
||||
|
||||
// #330 AC4 / #550: the production cache is keyed by the config object because
|
||||
// physical commits intentionally update that object in place. Prove that a
|
||||
// changed geometry fingerprint invalidates an entry even when object identity
|
||||
// is unchanged; the gesture above normally receives a fresh object after the
|
||||
// write and therefore cannot distinguish this case on its own.
|
||||
resize.resizeBaselineFingerprintRejectsStaleObject = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const runtime = card._editorRuntime;
|
||||
const previous = structuredClone(card._serverCfg);
|
||||
const space = previous?.spaces?.find((entry) => entry.id === card._space);
|
||||
if (!runtime || !space || !space.rooms?.length) return false;
|
||||
const firstRoom = space.rooms[0];
|
||||
const point = Array.isArray(firstRoom.poly) ? firstRoom.poly[0] : null;
|
||||
if (!Array.isArray(point)) return false;
|
||||
const original = card._junctionLimitViolations;
|
||||
let calls = 0;
|
||||
card._junctionLimitViolations = (...args) => {
|
||||
if (args[0] === previous) calls += 1;
|
||||
return original(...args);
|
||||
};
|
||||
try {
|
||||
runtime._junctionLimitsIntroduced(structuredClone(previous), previous, space.id);
|
||||
if (!runtime._junctionBaselineCache.get(previous)) return false;
|
||||
point[0] += 0.001;
|
||||
runtime._junctionLimitsIntroduced(structuredClone(previous), previous, space.id);
|
||||
return calls === 2;
|
||||
} finally {
|
||||
card._junctionLimitViolations = original;
|
||||
}
|
||||
});
|
||||
|
||||
checkAll({ ...out, ...resize }, {
|
||||
resizeGapBefore: 10,
|
||||
resizeStoppedAtLastAllowed: 6,
|
||||
resizeRefusalOnce: 1,
|
||||
resizeHandleFound: true,
|
||||
resizeRefusalNamesRule: true,
|
||||
resizeBaselineComputedOncePerGesture: true,
|
||||
resizeSecondHandleFound: true,
|
||||
resizeBaselineRecomputedAfterCommit: true,
|
||||
resizeBaselineFingerprintRejectsStaleObject: true,
|
||||
});
|
||||
await finish(browser);
|
||||
|
||||
@@ -15,10 +15,12 @@ const result = await page.evaluate(async () => {
|
||||
const frame = () => new Promise((resolve) => requestAnimationFrame(
|
||||
() => requestAnimationFrame(resolve),
|
||||
));
|
||||
const until = async (predicate, timeout = 6000) => {
|
||||
const until = async (predicate, timeout = 6000, label = '') => {
|
||||
const started = performance.now();
|
||||
while (!predicate()) {
|
||||
if (performance.now() - started > timeout) throw new Error('linked-light smoke timed out');
|
||||
if (performance.now() - started > timeout) {
|
||||
throw new Error(`linked-light smoke timed out${label ? `: ${label}` : ''}`);
|
||||
}
|
||||
await wait(25);
|
||||
}
|
||||
};
|
||||
@@ -162,8 +164,24 @@ const result = await page.evaluate(async () => {
|
||||
})}`, { cause: error });
|
||||
}
|
||||
};
|
||||
let clickPointerId = 17300;
|
||||
const click = async (id) => {
|
||||
node(id).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
const target = node(id);
|
||||
const rect = target.getBoundingClientRect();
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
const pointerId = ++clickPointerId;
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse',
|
||||
clientX, clientY, button: 0, buttons: 1,
|
||||
}));
|
||||
target.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, composed: true, pointerId, pointerType: 'mouse',
|
||||
clientX, clientY, button: 0, buttons: 0,
|
||||
}));
|
||||
target.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, composed: true, clientX, clientY, button: 0,
|
||||
}));
|
||||
await wait(0);
|
||||
};
|
||||
const targetsOf = (call) => {
|
||||
@@ -283,11 +301,28 @@ const result = await page.evaluate(async () => {
|
||||
card._regSignature = '';
|
||||
card._maybeRebuildDevices();
|
||||
await card.updateComplete;
|
||||
await until(() => !isOn(sourceId));
|
||||
await until(() => !isOn(sourceId), 6000, 'unlink did not restore manual off');
|
||||
const unlinkRestoresManualOff = !isOn(sourceId) && virtual.off.includes(sourceId);
|
||||
const beforeManualService = serviceCalls.length;
|
||||
await click(sourceId);
|
||||
await until(() => isOn(sourceId));
|
||||
try {
|
||||
await until(() => isOn(sourceId), 6000, 'manual virtual toggle did not turn on');
|
||||
} catch (error) {
|
||||
throw new Error(`manual virtual toggle did not settle: ${JSON.stringify({
|
||||
operationalToggleCalls,
|
||||
serviceCalls: serviceCalls.length,
|
||||
beforeManualService,
|
||||
off: virtual.off,
|
||||
suppressClick: card._suppressClick,
|
||||
holdFired: card._holdFired,
|
||||
deviceDrag: card._deviceDrag,
|
||||
sourceAction: item(sourceId)?.tapAction,
|
||||
sourceBinding: item(sourceId)?.binding,
|
||||
sourceState: card._stateClass(item(sourceId)),
|
||||
sourceIntent: card._toggleIntent(item(sourceId)),
|
||||
sourceConnected: node(sourceId)?.isConnected,
|
||||
})}`, { cause: error });
|
||||
}
|
||||
const manualToggleRestored = operationalToggleCalls === 1
|
||||
&& serviceCalls.length === beforeManualService && !virtual.off.includes(sourceId);
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
// #544: `setLayerProjection` must expose scene pixels beyond the old SVG
|
||||
// viewport while the compositor transform is live. The outer `.stage` still
|
||||
// clips the card. DOM strings and a screenshot after pointerup cannot prove
|
||||
// this contract, so the oracle compares held and forced-settled pixels for the
|
||||
// same camera target and counts scene pixels replaced by stage background.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 1100, height: 760 }, 1, [], { hasTouch: true },
|
||||
);
|
||||
|
||||
const checks = {};
|
||||
const diagnostics = {};
|
||||
const frame = () => page.evaluate(() => new Promise((done) => requestAnimationFrame(done)));
|
||||
const settle = async () => {
|
||||
await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
});
|
||||
};
|
||||
const stage = () => page.locator('houseplan-card').locator('.stage');
|
||||
|
||||
const resetCamera = async () => {
|
||||
await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const width = 300;
|
||||
card._view = {
|
||||
x: 350,
|
||||
y: 350,
|
||||
w: width,
|
||||
h: width * stage.clientHeight / stage.clientWidth,
|
||||
};
|
||||
card._zoom = 3;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
});
|
||||
};
|
||||
|
||||
const liveState = () => page.evaluate(() => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
const root = card.renderRoot;
|
||||
const scenes = [...root.querySelectorAll('[data-hp-live-viewbox]')];
|
||||
const centre = (node) => {
|
||||
const rect = node?.getBoundingClientRect();
|
||||
return rect ? [rect.left + rect.width / 2, rect.top + rect.height / 2] : null;
|
||||
};
|
||||
const stage = root.querySelector('.stage');
|
||||
return {
|
||||
view: structuredClone(card._view),
|
||||
transformed: scenes.filter((node) => node.style.transform).length,
|
||||
sceneCount: scenes.length,
|
||||
allSceneOverflowOpen: scenes.length > 0
|
||||
&& scenes.every((node) => node.style.overflow === 'visible'),
|
||||
allScenePromoted: scenes.length > 0 && scenes.every((node) => node.style.transform
|
||||
&& node.style.transformOrigin && node.style.willChange === 'transform'
|
||||
&& node.style.overflow === 'visible'),
|
||||
allTemporaryStylesCleared: scenes.every((node) => !node.style.overflow
|
||||
&& !node.style.transform && !node.style.transformOrigin && !node.style.willChange),
|
||||
marker: centre(root.querySelector('[data-hp="device"]')),
|
||||
room: centre(root.querySelector('[data-hp="room"]')),
|
||||
effects: {
|
||||
dayCycle: !!root.querySelector('.hp-day-cycle-env .hp-day-cycle-bg.active'),
|
||||
glowBases: root.querySelectorAll('.glow-base-layer .glow-base').length,
|
||||
glowPools: root.querySelectorAll('.glowlayer circle, .glow-pool').length,
|
||||
roomFills: root.querySelectorAll('.room.filled').length,
|
||||
hatchedWalls: root.querySelectorAll('.wallbody:not(.solid)[fill^="url("]').length,
|
||||
customBackground: (stage?.getAttribute('style') || '').includes('#123456'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const backgroundRgb = async () => page.evaluate(() => {
|
||||
const stage = document.querySelector('houseplan-card').renderRoot.querySelector('.stage');
|
||||
return (getComputedStyle(stage).backgroundColor.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
|
||||
});
|
||||
|
||||
const compareCoverage = async (actual, reference, edge, background) => page.evaluate(async (input) => {
|
||||
const decode = async (base64) => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/png;base64,${base64}`;
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(image, 0, 0);
|
||||
return {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
data: context.getImageData(0, 0, image.width, image.height).data,
|
||||
};
|
||||
};
|
||||
const [left, right] = await Promise.all([decode(input.actual), decode(input.reference)]);
|
||||
if (left.width !== right.width || left.height !== right.height) {
|
||||
return { sameSize: false, missing: Number.POSITIVE_INFINITY, referenceScenePixels: 0 };
|
||||
}
|
||||
const nearBackground = (data, index) => input.background.every(
|
||||
(channel, offset) => Math.abs(data[index + offset] - channel) <= 5,
|
||||
);
|
||||
const inBand = (x, y) => {
|
||||
if (input.edge === 'left') return x < left.width * 0.14;
|
||||
if (input.edge === 'right') return x >= left.width * 0.86;
|
||||
if (input.edge === 'top') return y < left.height * 0.14;
|
||||
if (input.edge === 'bottom') return y >= left.height * 0.86;
|
||||
return true;
|
||||
};
|
||||
let missing = 0;
|
||||
let referenceScenePixels = 0;
|
||||
for (let y = 0; y < left.height; y += 1) {
|
||||
for (let x = 0; x < left.width; x += 1) {
|
||||
if (!inBand(x, y)) continue;
|
||||
const index = (y * left.width + x) * 4;
|
||||
const referenceIsScene = !nearBackground(right.data, index);
|
||||
if (!referenceIsScene) continue;
|
||||
referenceScenePixels += 1;
|
||||
if (nearBackground(left.data, index)) missing += 1;
|
||||
}
|
||||
}
|
||||
return { sameSize: true, missing, referenceScenePixels };
|
||||
}, {
|
||||
actual: actual.toString('base64'),
|
||||
reference: reference.toString('base64'),
|
||||
edge,
|
||||
background,
|
||||
});
|
||||
|
||||
const pointerCentre = async () => {
|
||||
const rect = await stage().boundingBox();
|
||||
if (!rect) throw new Error('stage has no bounding box');
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
};
|
||||
|
||||
const preparePointerCapture = () => page.evaluate(() => {
|
||||
const stage = document.querySelector('houseplan-card').renderRoot.querySelector('.stage');
|
||||
stage.setPointerCapture = () => {};
|
||||
stage.releasePointerCapture = () => {};
|
||||
});
|
||||
|
||||
const dispatchTouch = (type, id, x, y, buttons = type === 'pointerup' ? 0 : 1) => page.evaluate(
|
||||
({ type, id, x, y, buttons }) => {
|
||||
const stage = document.querySelector('houseplan-card').renderRoot.querySelector('.stage');
|
||||
stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
pointerId: id,
|
||||
isPrimary: id % 2 === 1,
|
||||
pointerType: 'touch',
|
||||
button: type === 'pointerdown' ? 0 : -1,
|
||||
buttons,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
}));
|
||||
},
|
||||
{ type, id, x, y, buttons },
|
||||
);
|
||||
|
||||
const configureEffectMatrix = ({ dayCycle, glow }) => page.evaluate(async (variant) => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
const config = structuredClone(card._serverCfg);
|
||||
const space = config.spaces.find((item) => item.id === card._space) || config.spaces[0];
|
||||
config.settings = {
|
||||
...(config.settings || {}),
|
||||
bg_color: '#123456',
|
||||
bg_mode: variant.dayCycle ? 'daynight' : 'static',
|
||||
};
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
bg_color: '#123456',
|
||||
bg_mode: variant.dayCycle ? 'daynight' : 'static',
|
||||
fill_mode: 'custom',
|
||||
custom_fill: { c: '#d02020', a: 1 },
|
||||
glow_enabled: variant.glow,
|
||||
show_borders: true,
|
||||
};
|
||||
card._serverCfg = config;
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => setTimeout(done, 250));
|
||||
}, { dayCycle, glow });
|
||||
|
||||
const runPan = async ({ name, kind, dx, dy, edge, parity = false, effects = null }) => {
|
||||
await resetCamera();
|
||||
await preparePointerCapture();
|
||||
const origin = await pointerCentre();
|
||||
const before = parity ? await liveState() : null;
|
||||
if (kind === 'mouse') {
|
||||
await page.mouse.move(origin.x, origin.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(origin.x + dx * 0.18, origin.y + dy * 0.18);
|
||||
await frame();
|
||||
await page.mouse.move(origin.x + dx, origin.y + dy);
|
||||
} else {
|
||||
await dispatchTouch('pointerdown', 701, origin.x, origin.y);
|
||||
await dispatchTouch('pointermove', 701, origin.x + dx * 0.18, origin.y + dy * 0.18);
|
||||
await frame();
|
||||
await dispatchTouch('pointermove', 701, origin.x + dx, origin.y + dy);
|
||||
}
|
||||
await frame();
|
||||
const immediateState = await liveState();
|
||||
const immediate = await stage().screenshot({ animations: 'disabled' });
|
||||
await page.waitForTimeout(160);
|
||||
const heldState = await liveState();
|
||||
const held = await stage().screenshot({ animations: 'disabled' });
|
||||
if (kind === 'mouse') await page.mouse.up();
|
||||
else await dispatchTouch('pointerup', 701, origin.x + dx, origin.y + dy, 0);
|
||||
await settle();
|
||||
const settledState = await liveState();
|
||||
const reference = await stage().screenshot({ animations: 'disabled' });
|
||||
const background = await backgroundRgb();
|
||||
const immediateCoverage = await compareCoverage(immediate, reference, edge, background);
|
||||
const heldCoverage = await compareCoverage(held, reference, edge, background);
|
||||
const parityPx = parity && before?.marker && before?.room
|
||||
&& immediateState.marker && immediateState.room
|
||||
? Math.max(
|
||||
Math.abs((immediateState.marker[0] - before.marker[0])
|
||||
- (immediateState.room[0] - before.room[0])),
|
||||
Math.abs((immediateState.marker[1] - before.marker[1])
|
||||
- (immediateState.room[1] - before.room[1])),
|
||||
)
|
||||
: null;
|
||||
diagnostics[name] = {
|
||||
immediateCoverage,
|
||||
heldCoverage,
|
||||
transformed: immediateState.transformed,
|
||||
heldTransformed: heldState.transformed,
|
||||
sceneCount: immediateState.sceneCount,
|
||||
overflowImmediate: immediateState.allSceneOverflowOpen,
|
||||
overflowHeld: heldState.allSceneOverflowOpen,
|
||||
promotedImmediate: immediateState.allScenePromoted,
|
||||
promotedHeld: heldState.allScenePromoted,
|
||||
targetStableWhileHeld: JSON.stringify(immediateState.view) === JSON.stringify(heldState.view),
|
||||
temporaryStylesCleared: settledState.allTemporaryStylesCleared,
|
||||
effectsImmediate: immediateState.effects,
|
||||
effectsHeld: heldState.effects,
|
||||
parityPx,
|
||||
};
|
||||
checks[`${name}ImmediateCoverage`] = immediateCoverage.sameSize
|
||||
&& immediateCoverage.referenceScenePixels > 500 && immediateCoverage.missing === 0;
|
||||
checks[`${name}HeldCoverage`] = heldCoverage.sameSize
|
||||
&& heldCoverage.referenceScenePixels > 500 && heldCoverage.missing === 0;
|
||||
checks[`${name}UsesTemporaryOverflow`] = immediateState.allScenePromoted
|
||||
&& heldState.allScenePromoted
|
||||
&& settledState.allTemporaryStylesCleared;
|
||||
checks[`${name}TargetStableWhileHeld`] = diagnostics[name].targetStableWhileHeld;
|
||||
if (effects) {
|
||||
const expected = (sample) => sample.roomFills > 0 && sample.hatchedWalls > 0
|
||||
&& sample.customBackground
|
||||
&& sample.dayCycle === effects.dayCycle
|
||||
&& (effects.glow
|
||||
? sample.glowPools > 0
|
||||
: sample.glowPools === 0);
|
||||
checks[`${name}EffectsVisible`] = expected(immediateState.effects)
|
||||
&& expected(heldState.effects);
|
||||
}
|
||||
if (parity) checks[`${name}MarkerParity`] = parityPx !== null && parityPx <= 1;
|
||||
};
|
||||
|
||||
const runTouchZoomOut = async ({ name }) => {
|
||||
await resetCamera();
|
||||
await preparePointerCapture();
|
||||
const origin = await pointerCentre();
|
||||
const beforeZoom = await page.evaluate(() => document.querySelector('houseplan-card')._zoom);
|
||||
await dispatchTouch('pointerdown', 711, origin.x - 80, origin.y);
|
||||
await dispatchTouch('pointerdown', 712, origin.x + 80, origin.y);
|
||||
await dispatchTouch('pointermove', 711, origin.x - 76, origin.y);
|
||||
await dispatchTouch('pointermove', 712, origin.x + 76, origin.y);
|
||||
await frame();
|
||||
await dispatchTouch('pointermove', 711, origin.x - 68, origin.y);
|
||||
await dispatchTouch('pointermove', 712, origin.x + 68, origin.y);
|
||||
await frame();
|
||||
const immediateState = await liveState();
|
||||
const immediate = await stage().screenshot({ animations: 'disabled' });
|
||||
await page.waitForTimeout(160);
|
||||
const heldState = await liveState();
|
||||
const held = await stage().screenshot({ animations: 'disabled' });
|
||||
await dispatchTouch('pointerup', 711, origin.x - 68, origin.y, 0);
|
||||
await dispatchTouch('pointerup', 712, origin.x + 68, origin.y, 0);
|
||||
await settle();
|
||||
const settledState = await liveState();
|
||||
const reference = await stage().screenshot({ animations: 'disabled' });
|
||||
const background = await backgroundRgb();
|
||||
const immediateCoverage = await compareCoverage(immediate, reference, 'all', background);
|
||||
const heldCoverage = await compareCoverage(held, reference, 'all', background);
|
||||
const afterZoom = await page.evaluate(() => document.querySelector('houseplan-card')._zoom);
|
||||
diagnostics[name] = {
|
||||
beforeZoom,
|
||||
afterZoom,
|
||||
immediateCoverage,
|
||||
heldCoverage,
|
||||
transformed: immediateState.transformed,
|
||||
heldTransformed: heldState.transformed,
|
||||
overflowImmediate: immediateState.allSceneOverflowOpen,
|
||||
overflowHeld: heldState.allSceneOverflowOpen,
|
||||
promotedImmediate: immediateState.allScenePromoted,
|
||||
promotedHeld: heldState.allScenePromoted,
|
||||
targetStableWhileHeld: JSON.stringify(immediateState.view) === JSON.stringify(heldState.view),
|
||||
temporaryStylesCleared: settledState.allTemporaryStylesCleared,
|
||||
};
|
||||
checks[`${name}ActuallyZoomsOut`] = afterZoom < beforeZoom;
|
||||
checks[`${name}ImmediateCoverage`] = immediateCoverage.sameSize
|
||||
&& immediateCoverage.referenceScenePixels > 5000 && immediateCoverage.missing === 0;
|
||||
checks[`${name}HeldCoverage`] = heldCoverage.sameSize
|
||||
&& heldCoverage.referenceScenePixels > 5000 && heldCoverage.missing === 0;
|
||||
checks[`${name}UsesTemporaryOverflow`] = immediateState.allScenePromoted
|
||||
&& heldState.allScenePromoted
|
||||
&& settledState.allTemporaryStylesCleared;
|
||||
checks[`${name}TargetStableWhileHeld`] = diagnostics[name].targetStableWhileHeld;
|
||||
};
|
||||
|
||||
// #579: observe the actual style attribute through several budget refreshes.
|
||||
// Starting the observer after the first promoted frame excludes the legitimate
|
||||
// initial style construction; from that point until pointerup every oldValue
|
||||
// must already describe the same promoted compositor lifecycle.
|
||||
const runStablePromotion = async () => {
|
||||
await resetCamera();
|
||||
await preparePointerCapture();
|
||||
const origin = await pointerCentre();
|
||||
await dispatchTouch('pointerdown', 721, origin.x - 80, origin.y);
|
||||
await dispatchTouch('pointerdown', 722, origin.x + 80, origin.y);
|
||||
await dispatchTouch('pointermove', 721, origin.x - 90, origin.y);
|
||||
await dispatchTouch('pointermove', 722, origin.x + 90, origin.y);
|
||||
await frame();
|
||||
const initial = await liveState();
|
||||
await page.evaluate(() => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
const scenes = [...card.renderRoot.querySelectorAll('[data-hp-live-viewbox]')];
|
||||
const records = [];
|
||||
const observer = new MutationObserver((batch) => records.push(...batch));
|
||||
for (const scene of scenes) observer.observe(scene, {
|
||||
attributes: true, attributeFilter: ['style', 'viewBox'], attributeOldValue: true,
|
||||
});
|
||||
window.__hp579Promotion = { observer, records };
|
||||
});
|
||||
|
||||
// Force the exact second ownership boundary from the bug: Home Assistant or
|
||||
// another reactive input may deliver a complete Lit frame while both touch
|
||||
// contacts are still held. Waiting for incidental fixture activity made the
|
||||
// mutation witness timing-dependent on slower Linux runners.
|
||||
await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await frame();
|
||||
|
||||
for (const distance of [112, 72, 118]) {
|
||||
await page.waitForTimeout(110);
|
||||
await dispatchTouch('pointermove', 721, origin.x - distance, origin.y);
|
||||
await dispatchTouch('pointermove', 722, origin.x + distance, origin.y);
|
||||
await frame();
|
||||
}
|
||||
const active = await page.evaluate(async () => {
|
||||
await Promise.resolve();
|
||||
const card = document.querySelector('houseplan-card');
|
||||
const scenes = [...card.renderRoot.querySelectorAll('[data-hp-live-viewbox]')];
|
||||
const state = window.__hp579Promotion;
|
||||
const records = [...state.records, ...state.observer.takeRecords()];
|
||||
state.observer.disconnect();
|
||||
delete window.__hp579Promotion;
|
||||
const styleRecords = records.filter((record) => record.attributeName === 'style');
|
||||
const promoted = (text) => typeof text === 'string'
|
||||
&& text.includes('overflow: visible') && text.includes('transform-origin:')
|
||||
&& text.includes('will-change: transform') && text.includes('transform:');
|
||||
return {
|
||||
sceneCount: scenes.length,
|
||||
styleMutations: styleRecords.length,
|
||||
viewBoxMutations: records.filter((record) => record.attributeName === 'viewBox').length,
|
||||
demotedOldValues: styleRecords.filter((record) => !promoted(record.oldValue)).length,
|
||||
allPromoted: scenes.every((scene) => promoted(scene.getAttribute('style'))),
|
||||
};
|
||||
});
|
||||
await dispatchTouch('pointerup', 721, origin.x - 118, origin.y, 0);
|
||||
await dispatchTouch('pointerup', 722, origin.x + 118, origin.y, 0);
|
||||
await settle();
|
||||
const terminal = await liveState();
|
||||
diagnostics.stablePromotion = { initial, active, terminal };
|
||||
checks.stablePromotionStartsOnce = initial.allScenePromoted;
|
||||
checks.stablePromotionSurvivesRefresh = active.sceneCount > 0 && active.allPromoted
|
||||
&& active.demotedOldValues === 0;
|
||||
// The forced Lit commit contributes one deliberate anchor write per scene;
|
||||
// subtract it before judging the ordinary 100 ms / 15% refresh budget.
|
||||
const budgetedViewBoxMutations = active.viewBoxMutations - active.sceneCount;
|
||||
checks.stablePromotionKeepsViewBoxBudget = budgetedViewBoxMutations >= active.sceneCount * 3
|
||||
&& budgetedViewBoxMutations <= active.sceneCount * 5;
|
||||
checks.stablePromotionCleansAtTerminal = terminal.allTemporaryStylesCleared;
|
||||
};
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const config = structuredClone(card._serverCfg);
|
||||
const space = config.spaces.find((item) => item.id === card._space) || config.spaces[0];
|
||||
const poly = [[0, 0], [1, 0], [1, 1], [0, 1]];
|
||||
space.rooms = [{
|
||||
id: 'coverage-room',
|
||||
name: 'Coverage room',
|
||||
poly,
|
||||
fill_mode: 'custom',
|
||||
fill_color: '#d02020',
|
||||
fill_opacity: 1,
|
||||
}];
|
||||
space.walls = poly.map((a, index) => ({
|
||||
key: `coverage-wall-${index}`,
|
||||
a,
|
||||
b: poly[(index + 1) % poly.length],
|
||||
cm: 15,
|
||||
}));
|
||||
config.settings = {
|
||||
...(config.settings || {}), bg_color: '#123456', bg_mode: 'static',
|
||||
};
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
bg_color: '#123456',
|
||||
bg_mode: 'static',
|
||||
fill_mode: 'custom',
|
||||
custom_fill: { c: '#d02020', a: 1 },
|
||||
glow_enabled: false,
|
||||
show_borders: true,
|
||||
};
|
||||
card._serverCfg = config;
|
||||
card._space = space.id;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
|
||||
await runPan({ name: 'flatMouseRightEdge', kind: 'mouse', dx: -65, dy: 0, edge: 'right' });
|
||||
await runPan({ name: 'flatMouseLeftEdge', kind: 'mouse', dx: 65, dy: 0, edge: 'left' });
|
||||
await runPan({ name: 'flatMouseBottomEdge', kind: 'mouse', dx: 0, dy: -48, edge: 'bottom' });
|
||||
await runPan({ name: 'flatMouseTopEdge', kind: 'mouse', dx: 0, dy: 48, edge: 'top' });
|
||||
await runPan({ name: 'flatTouchRightEdge', kind: 'touch', dx: -65, dy: 0, edge: 'right' });
|
||||
await runTouchZoomOut({ name: 'flatTouchZoomOut' });
|
||||
await runStablePromotion();
|
||||
|
||||
// #579 AC4: every combination keeps the same expensive scene effects alive
|
||||
// in both the immediate and held compositor frames. The custom fill, wall
|
||||
// hatch and user background stay on in all four rows of the matrix.
|
||||
for (const dayCycle of [false, true]) {
|
||||
for (const glow of [false, true]) {
|
||||
await configureEffectMatrix({ dayCycle, glow });
|
||||
await runPan({
|
||||
name: `effectsDay${Number(dayCycle)}Glow${Number(glow)}`,
|
||||
kind: 'touch', dx: -65, dy: 0, edge: 'right', effects: { dayCycle, glow },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(async () => {
|
||||
history.replaceState(null, '', '?hp_alpha=1#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
const card = document.querySelector('houseplan-card');
|
||||
await card.updateComplete;
|
||||
await window.__hpEnsureHarnessIsoRuntime(card);
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
});
|
||||
checks.alphaIsoEnabled = await page.evaluate(() => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
return card._effectiveProjection() === 'iso'
|
||||
&& card.renderRoot.querySelector('.stage')?.getAttribute('data-hp-iso-stage') === '4';
|
||||
});
|
||||
await runPan({ name: 'isoMouseRightEdge', kind: 'mouse', dx: -65, dy: 0, edge: 'right', parity: true });
|
||||
await runTouchZoomOut({ name: 'isoTouchZoomOut' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
card._setProjection('flat');
|
||||
card.setConfig({ type: 'custom:houseplan-card', kiosk: true });
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
});
|
||||
checks.kioskEnabled = await page.evaluate(() => document.querySelector('houseplan-card')._kiosk === true);
|
||||
await runPan({ name: 'kioskTouchRightEdge', kind: 'touch', dx: -65, dy: 0, edge: 'right' });
|
||||
|
||||
checkAll(checks);
|
||||
await finish(browser, { checks, diagnostics });
|
||||