mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
Compare commits
344 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d119eb473 | |||
| 9877fe403a | |||
| 46ee078343 | |||
| 7f2bf9d2d3 | |||
| 31fa66d8fe | |||
| a8e61d02a2 | |||
| c8c0c48ead | |||
| f1b61b9e9e | |||
| 9774b8f1c3 | |||
| c147fd5cb7 | |||
| ffb380187f | |||
| 80ffed9a28 | |||
| 1106117e33 | |||
| e03ba3b509 | |||
| 3d6a09e90a | |||
| b188d338f9 | |||
| 7c9769c644 | |||
| 52478a6c39 | |||
| ebdd596d4f | |||
| 0dde66e2eb | |||
| 547f336cf8 | |||
| f424d2ac70 | |||
| 3a89aa21e6 | |||
| 7de2689c18 | |||
| 96926d65ef | |||
| f2b8ad741e | |||
| 89b109ab04 | |||
| 81596503e8 | |||
| e36544c1c7 | |||
| 80323a77cc | |||
| 0e5ad754e8 | |||
| 9766b8262e | |||
| 44521c5c16 | |||
| 8f2fa21814 | |||
| e05c0a4a61 | |||
| dff3ced847 | |||
| 403da66dd5 | |||
| 70be567b13 | |||
| 60151c801f | |||
| a9949ff5fe | |||
| 07353c416e | |||
| 6e09351730 | |||
| 961f27afb0 | |||
| 8f0d3937b4 | |||
| f554ef4599 | |||
| 486890c388 | |||
| 707033e25b | |||
| a381fca0a1 | |||
| 280df54fa6 | |||
| 8fd5544abd | |||
| b518b614bb | |||
| b08ee99521 | |||
| 89efcad125 | |||
| 340142b0a2 | |||
| 7f7ab2f217 | |||
| 85003782db | |||
| 65ea06ab6e | |||
| 71607bef99 | |||
| c930b4b04d | |||
| 8bdcb9e664 | |||
| ab10761667 | |||
| a24c39b6de | |||
| 8739d1d15c | |||
| f31770d79c | |||
| a5c0be6766 | |||
| 50d40a9dd5 | |||
| 0ba8127d52 | |||
| 9ee0473412 | |||
| cbd580a0bd | |||
| d67874ebd0 | |||
| 6781bb3ee5 | |||
| 3a6d5abffc | |||
| c4b1aee9c9 | |||
| 12c5cf5e66 | |||
| 6fb5b9f617 | |||
| 9ac0cb3b87 | |||
| e215957344 | |||
| 09b2084672 | |||
| 3fe038277f | |||
| 5b6243bbcc | |||
| 2cba13a8cc | |||
| fca65ef658 | |||
| 4f01f01286 | |||
| 398c515390 | |||
| cecf4dd9a2 | |||
| d3be9c8dea | |||
| bb2f8fb29e | |||
| 60cdbf0aec | |||
| cd3e50c408 | |||
| da6ac2bab8 | |||
| 7a7d03dcff | |||
| 20a243143a | |||
| fea2f37ba6 | |||
| d8457bb307 | |||
| daa7716f24 | |||
| bbed308c85 | |||
| 11ddc2b5a2 | |||
| e59c3281fd | |||
| 609698b00d | |||
| ceaaa8bc55 | |||
| 61cc8f3b9a | |||
| 4630af463f | |||
| dd02dcf3c4 | |||
| e2e3e02a1b | |||
| 9b331aa41d | |||
| 91ef1d07eb | |||
| 8cb7309ee5 | |||
| 0425b79a87 | |||
| c8cba6f7ce | |||
| f34303f9ed | |||
| c230142bf1 | |||
| d9010cd580 | |||
| 19da4df61f | |||
| fe9d8500dc | |||
| 2ad35f484c | |||
| abca61da66 | |||
| db6a1f2212 | |||
| 85d42ec34c | |||
| a7344a1397 | |||
| f642f673d9 | |||
| fdc8c518aa | |||
| 53536b72f5 | |||
| 21ae9a6d80 | |||
| ad99f3db0b | |||
| 3ef4f44028 | |||
| bb7696bf28 | |||
| 0466ae8fd8 | |||
| 796403abe3 | |||
| 639568c039 | |||
| 9ef554a576 | |||
| 1b36aa97c8 | |||
| 573602ae65 | |||
| 632c58eef1 | |||
| 64c2037fe1 | |||
| fb9d29408c | |||
| 730decf9ce | |||
| 48e2b6bd44 | |||
| f75c71064c | |||
| 5ca8ce2b64 | |||
| 5793f91ebb | |||
| b83bbc333f | |||
| a417b76e2c | |||
| 659451815d | |||
| f271a6f6ba | |||
| 342b3f0687 | |||
| 4b3819762c | |||
| 5e864cb05f | |||
| a66246095c | |||
| 9b4bb3d9b8 | |||
| b1eb8bebe3 | |||
| 87bc274346 | |||
| 6a8efc9538 | |||
| 0846740db7 | |||
| 480d828c35 | |||
| 8009a8150f | |||
| 29a9fbe950 | |||
| 3832952400 | |||
| 10fef03681 | |||
| 957af1c421 | |||
| 616ac2a23e | |||
| a9deeee2d0 | |||
| 4af51235db | |||
| da0b326c40 | |||
| 65a2b5870d | |||
| cab0b4d26b | |||
| 092cd8ebb4 | |||
| f19032594c | |||
| a07a105024 | |||
| f5432eb48d | |||
| b8a6783ccf | |||
| 2b8cdc40ca | |||
| 013d3c3a01 | |||
| eee5aaafd3 | |||
| 32e06cbc56 | |||
| 77fc0cee2f | |||
| ac72ca8a51 | |||
| 6e3632e5ff | |||
| e843cd8997 | |||
| 4c26e97abf | |||
| cbad0fe743 | |||
| 03cc59d243 | |||
| 540af9655d | |||
| a7d5c46176 | |||
| 38b5445f15 | |||
| 9aac032ac8 | |||
| f9774d799c | |||
| 49f17ef5fd | |||
| 515c82c398 | |||
| 206fed0125 | |||
| e5e6392b8c | |||
| b58f9aa6a2 | |||
| 2235b11700 | |||
| 04b742fe78 | |||
| 1d75456f4e | |||
| 3e0f6bba02 | |||
| 19c2557778 | |||
| 3453e64bea | |||
| eac0ae5a02 | |||
| a46d5a166b | |||
| bf4005bf76 | |||
| 501abd9fac | |||
| c3a8a9c874 | |||
| 8b71054478 | |||
| 55b80fdc08 | |||
| e0ee927156 | |||
| a1131965cc | |||
| be9ebedf14 | |||
| 7f3a8861ad | |||
| 3119700c71 | |||
| 4bd8f27dec | |||
| a73517ee07 | |||
| 3a68e1cb44 | |||
| cbc9f0cf61 | |||
| 4413a06c49 | |||
| 537adfd3f8 | |||
| 009656991f | |||
| 8cf29c601e | |||
| d8d80af88e | |||
| 48b08d77e5 | |||
| 31d1ae2650 | |||
| 54d4dd6904 | |||
| 840e3ed768 | |||
| 4497b1d4e2 | |||
| 61c4bd418d | |||
| be69bc231c | |||
| 765e1d8885 | |||
| 643211f2fb | |||
| 5ec21f24b4 | |||
| 048edafc15 | |||
| 334241bea4 | |||
| ce1ba0bab1 | |||
| 6e14f2502e | |||
| ff2d8513c4 | |||
| efdf56691c | |||
| 12745b14c3 | |||
| a5a1636918 | |||
| 05374100c1 | |||
| 449f814d16 | |||
| 3eaa7ffa3b | |||
| 98ebb478ee | |||
| fb67b1b80d | |||
| 79db5de6ee | |||
| 23d2cb42de | |||
| 511bfe7fdf | |||
| f91b569564 | |||
| e93185c18b | |||
| 7fa279c354 | |||
| 28a2a34c84 | |||
| f0f1e88aaa | |||
| e7cbf306a0 | |||
| 610720334b | |||
| 5090deb5bc | |||
| 8b0bcf0194 | |||
| 529a5cacae | |||
| 8b0f55f1cc | |||
| 54cbc69a59 | |||
| ac3047cf55 | |||
| 5da154adea | |||
| f4748d76af | |||
| b992cd11c5 | |||
| fc76407877 | |||
| a55195ab66 | |||
| 1eff62de42 | |||
| eb87be5c04 | |||
| 43ed9ba142 | |||
| 588382cec1 | |||
| e0197683e1 | |||
| 96dad75b2f | |||
| 5c734202b6 | |||
| a92bce05fb | |||
| b8ebbf6a6f | |||
| 43ba6ce16c | |||
| 51e37f9874 | |||
| 62e864e474 | |||
| ddebd7b6df | |||
| 3d16e35e80 | |||
| 56822c4533 | |||
| 32bf9c1a3f | |||
| c94f86a9cd | |||
| 8b5fed98e0 | |||
| 9e677cfc61 | |||
| 211a4726d6 | |||
| 91b39b758b | |||
| 4af69f1600 | |||
| 8c7c442705 | |||
| c541d51f33 | |||
| a650325baf | |||
| 6c19846051 | |||
| eef4f4bcea | |||
| 2c553fee98 | |||
| 95e4674825 | |||
| 9751840cc8 | |||
| f257bb428c | |||
| 71e3691cf1 | |||
| 1af318325d | |||
| 986480748e | |||
| 7561faf736 | |||
| 2849087fd9 | |||
| 105171cdc2 | |||
| c9cd0d62ab | |||
| c1a0a54971 | |||
| 927c0827f3 | |||
| 56cd18a6b4 | |||
| 3ac1d425cd | |||
| 3c596f4550 | |||
| 36d29a51a9 | |||
| 11e961c816 | |||
| 05ca810461 | |||
| a0407b74dc | |||
| 3aad254399 | |||
| 07753921be | |||
| ba0c71fa45 | |||
| 33c9fdade0 | |||
| 1c1fe7b2d9 | |||
| 0bd2587e74 | |||
| 6f17b1c2f6 | |||
| 36ad686316 | |||
| 66e37fb8cc | |||
| 99c77e1dde | |||
| 8d6612027f | |||
| c3b67a18cb | |||
| 4d1b66638c | |||
| 363a9f34f2 | |||
| 4f8ce7b747 | |||
| fc48d68ed1 | |||
| 3e34ec0bdd | |||
| fff2e83960 | |||
| 1ff209849f | |||
| 1a8415283e | |||
| db1e78a534 | |||
| a828f1847f | |||
| 3e8159b591 | |||
| 57bb964c1e | |||
| 41c4b6b1f7 | |||
| ffb0ee740d | |||
| f5e487096f | |||
| 6d5c1a0b19 | |||
| b322c02fdb | |||
| 61ef7ac891 | |||
| ba19b4111d | |||
| ded11437c5 | |||
| 5eda7f8eb3 | |||
| f39a0d9c5e | |||
| 2347859bdd |
@@ -240,6 +240,15 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
|
|||||||
]}
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Картинка-поле (picField)
|
||||||
|
|
||||||
|
PictureField, привязанный к булеву/числу, рисует иконку только при заданном `valuesPicture`:
|
||||||
|
|
||||||
|
| Ключ | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `valuesPicture` | Ref картинки значения: `"StdPicture.Favorites"`, `"CommonPicture.X"` |
|
||||||
|
| `loadTransparent: true` | Скрыть кадр «нет значения» |
|
||||||
|
|
||||||
### Страницы (pages + page)
|
### Страницы (pages + page)
|
||||||
|
|
||||||
| Ключ (pages) | Описание |
|
| Ключ (pages) | Описание |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-compile v1.20 — Compile 1C managed form from JSON or object metadata
|
# form-compile v1.23 — Compile 1C managed form from JSON or object metadata
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[string]$JsonPath,
|
[string]$JsonPath,
|
||||||
@@ -1912,6 +1912,7 @@ function Emit-Element {
|
|||||||
# input-specific
|
# input-specific
|
||||||
"multiLine"=1;"passwordMode"=1;"choiceButton"=1;"clearButton"=1
|
"multiLine"=1;"passwordMode"=1;"choiceButton"=1;"clearButton"=1
|
||||||
"spinButton"=1;"dropListButton"=1;"markIncomplete"=1;"skipOnInput"=1;"inputHint"=1
|
"spinButton"=1;"dropListButton"=1;"markIncomplete"=1;"skipOnInput"=1;"inputHint"=1
|
||||||
|
"textEdit"=1
|
||||||
# label/hyperlink
|
# label/hyperlink
|
||||||
"hyperlink"=1
|
"hyperlink"=1
|
||||||
# group-specific
|
# group-specific
|
||||||
@@ -1928,7 +1929,7 @@ function Emit-Element {
|
|||||||
# button-specific
|
# button-specific
|
||||||
"type"=1;"command"=1;"stdCommand"=1;"defaultButton"=1;"locationInCommandBar"=1
|
"type"=1;"command"=1;"stdCommand"=1;"defaultButton"=1;"locationInCommandBar"=1
|
||||||
# picture/decoration
|
# picture/decoration
|
||||||
"src"=1
|
"src"=1;"valuesPicture"=1;"loadTransparent"=1
|
||||||
# cmdBar-specific
|
# cmdBar-specific
|
||||||
"autofill"=1
|
"autofill"=1
|
||||||
}
|
}
|
||||||
@@ -2133,10 +2134,12 @@ function Emit-Input {
|
|||||||
if ($el.multiLine -eq $true) { X "$inner<MultiLine>true</MultiLine>" }
|
if ($el.multiLine -eq $true) { X "$inner<MultiLine>true</MultiLine>" }
|
||||||
if ($el.passwordMode -eq $true) { X "$inner<PasswordMode>true</PasswordMode>" }
|
if ($el.passwordMode -eq $true) { X "$inner<PasswordMode>true</PasswordMode>" }
|
||||||
if ($el.choiceButton -eq $false) { X "$inner<ChoiceButton>false</ChoiceButton>" }
|
if ($el.choiceButton -eq $false) { X "$inner<ChoiceButton>false</ChoiceButton>" }
|
||||||
|
elseif ($el.choiceButton -eq $true -and ($el.on -contains 'StartChoice')) { X "$inner<ChoiceButton>true</ChoiceButton>" }
|
||||||
if ($el.clearButton -eq $true) { X "$inner<ClearButton>true</ClearButton>" }
|
if ($el.clearButton -eq $true) { X "$inner<ClearButton>true</ClearButton>" }
|
||||||
if ($el.spinButton -eq $true) { X "$inner<SpinButton>true</SpinButton>" }
|
if ($el.spinButton -eq $true) { X "$inner<SpinButton>true</SpinButton>" }
|
||||||
if ($el.dropListButton -eq $true) { X "$inner<DropListButton>true</DropListButton>" }
|
if ($el.dropListButton -eq $true) { X "$inner<DropListButton>true</DropListButton>" }
|
||||||
if ($el.markIncomplete -eq $true) { X "$inner<AutoMarkIncomplete>true</AutoMarkIncomplete>" }
|
if ($el.markIncomplete -eq $true) { X "$inner<AutoMarkIncomplete>true</AutoMarkIncomplete>" }
|
||||||
|
if ($el.textEdit -eq $false) { X "$inner<TextEdit>false</TextEdit>" }
|
||||||
if ($el.skipOnInput -eq $true) { X "$inner<SkipOnInput>true</SkipOnInput>" }
|
if ($el.skipOnInput -eq $true) { X "$inner<SkipOnInput>true</SkipOnInput>" }
|
||||||
$hasAmw = $el.PSObject.Properties.Name -contains 'autoMaxWidth'
|
$hasAmw = $el.PSObject.Properties.Name -contains 'autoMaxWidth'
|
||||||
if ($hasAmw) {
|
if ($hasAmw) {
|
||||||
@@ -2720,6 +2723,16 @@ function Emit-PictureField {
|
|||||||
Emit-Title -el $el -name $name -indent $inner
|
Emit-Title -el $el -name $name -indent $inner
|
||||||
Emit-CommonFlags -el $el -indent $inner
|
Emit-CommonFlags -el $el -indent $inner
|
||||||
|
|
||||||
|
# ValuesPicture — picture (collection) used to render the field's value.
|
||||||
|
# Required for a Boolean-bound PictureField to actually show an icon.
|
||||||
|
# loadTransparent emitted only when true (1С default is false).
|
||||||
|
if ($el.valuesPicture) {
|
||||||
|
X "$inner<ValuesPicture>"
|
||||||
|
X "$inner`t<xr:Ref>$($el.valuesPicture)</xr:Ref>"
|
||||||
|
if ($el.loadTransparent) { X "$inner`t<xr:LoadTransparent>true</xr:LoadTransparent>" }
|
||||||
|
X "$inner</ValuesPicture>"
|
||||||
|
}
|
||||||
|
|
||||||
if ($el.width) { X "$inner<Width>$($el.width)</Width>" }
|
if ($el.width) { X "$inner<Width>$($el.width)</Width>" }
|
||||||
if ($el.height) { X "$inner<Height>$($el.height)</Height>" }
|
if ($el.height) { X "$inner<Height>$($el.height)</Height>" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-compile v1.20 — Compile 1C managed form from JSON or object metadata
|
# form-compile v1.23 — Compile 1C managed form from JSON or object metadata
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import copy
|
import copy
|
||||||
@@ -1350,6 +1350,7 @@ KNOWN_KEYS = {
|
|||||||
"maxWidth", "maxHeight",
|
"maxWidth", "maxHeight",
|
||||||
"multiLine", "passwordMode", "choiceButton", "clearButton",
|
"multiLine", "passwordMode", "choiceButton", "clearButton",
|
||||||
"spinButton", "dropListButton", "markIncomplete", "skipOnInput", "inputHint",
|
"spinButton", "dropListButton", "markIncomplete", "skipOnInput", "inputHint",
|
||||||
|
"textEdit",
|
||||||
"hyperlink",
|
"hyperlink",
|
||||||
"showTitle", "united", "collapsed",
|
"showTitle", "united", "collapsed",
|
||||||
"children", "columns",
|
"children", "columns",
|
||||||
@@ -1357,7 +1358,7 @@ KNOWN_KEYS = {
|
|||||||
"commandBarLocation", "searchStringLocation",
|
"commandBarLocation", "searchStringLocation",
|
||||||
"pagesRepresentation",
|
"pagesRepresentation",
|
||||||
"type", "command", "stdCommand", "defaultButton", "locationInCommandBar",
|
"type", "command", "stdCommand", "defaultButton", "locationInCommandBar",
|
||||||
"src",
|
"src", "valuesPicture", "loadTransparent",
|
||||||
"autofill",
|
"autofill",
|
||||||
"choiceMode", "initialTreeView", "enableDrag", "enableStartDrag",
|
"choiceMode", "initialTreeView", "enableDrag", "enableStartDrag",
|
||||||
"rowPictureDataPath", "tableAutofill",
|
"rowPictureDataPath", "tableAutofill",
|
||||||
@@ -1932,6 +1933,8 @@ def emit_input(lines, el, name, eid, indent):
|
|||||||
lines.append(f'{inner}<PasswordMode>true</PasswordMode>')
|
lines.append(f'{inner}<PasswordMode>true</PasswordMode>')
|
||||||
if el.get('choiceButton') is False:
|
if el.get('choiceButton') is False:
|
||||||
lines.append(f'{inner}<ChoiceButton>false</ChoiceButton>')
|
lines.append(f'{inner}<ChoiceButton>false</ChoiceButton>')
|
||||||
|
elif el.get('choiceButton') is True and 'StartChoice' in (el.get('on') or []):
|
||||||
|
lines.append(f'{inner}<ChoiceButton>true</ChoiceButton>')
|
||||||
if el.get('clearButton') is True:
|
if el.get('clearButton') is True:
|
||||||
lines.append(f'{inner}<ClearButton>true</ClearButton>')
|
lines.append(f'{inner}<ClearButton>true</ClearButton>')
|
||||||
if el.get('spinButton') is True:
|
if el.get('spinButton') is True:
|
||||||
@@ -1940,6 +1943,8 @@ def emit_input(lines, el, name, eid, indent):
|
|||||||
lines.append(f'{inner}<DropListButton>true</DropListButton>')
|
lines.append(f'{inner}<DropListButton>true</DropListButton>')
|
||||||
if el.get('markIncomplete') is True:
|
if el.get('markIncomplete') is True:
|
||||||
lines.append(f'{inner}<AutoMarkIncomplete>true</AutoMarkIncomplete>')
|
lines.append(f'{inner}<AutoMarkIncomplete>true</AutoMarkIncomplete>')
|
||||||
|
if el.get('textEdit') is False:
|
||||||
|
lines.append(f'{inner}<TextEdit>false</TextEdit>')
|
||||||
if el.get('skipOnInput') is True:
|
if el.get('skipOnInput') is True:
|
||||||
lines.append(f'{inner}<SkipOnInput>true</SkipOnInput>')
|
lines.append(f'{inner}<SkipOnInput>true</SkipOnInput>')
|
||||||
if 'autoMaxWidth' in el:
|
if 'autoMaxWidth' in el:
|
||||||
@@ -2359,6 +2364,16 @@ def emit_picture_field(lines, el, name, eid, indent):
|
|||||||
emit_title(lines, el, name, inner)
|
emit_title(lines, el, name, inner)
|
||||||
emit_common_flags(lines, el, inner)
|
emit_common_flags(lines, el, inner)
|
||||||
|
|
||||||
|
# ValuesPicture \u2014 picture (collection) used to render the field's value.
|
||||||
|
# Required for a Boolean-bound PictureField to actually show an icon.
|
||||||
|
# loadTransparent emitted only when true (1\u0421 default is false).
|
||||||
|
if el.get('valuesPicture'):
|
||||||
|
lines.append(f'{inner}<ValuesPicture>')
|
||||||
|
lines.append(f'{inner}\t<xr:Ref>{el["valuesPicture"]}</xr:Ref>')
|
||||||
|
if el.get('loadTransparent'):
|
||||||
|
lines.append(f'{inner}\t<xr:LoadTransparent>true</xr:LoadTransparent>')
|
||||||
|
lines.append(f'{inner}</ValuesPicture>')
|
||||||
|
|
||||||
if el.get('width'):
|
if el.get('width'):
|
||||||
lines.append(f'{inner}<Width>{el["width"]}</Width>')
|
lines.append(f'{inner}<Width>{el["width"]}</Width>')
|
||||||
if el.get('height'):
|
if el.get('height'):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-validate v1.4 — Validate 1C managed form
|
# form-validate v1.6 — Validate 1C managed form
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -366,13 +366,51 @@ if (-not $stopped) {
|
|||||||
$dataPath = $dpNode.InnerText.Trim()
|
$dataPath = $dpNode.InnerText.Trim()
|
||||||
if (-not $dataPath) { continue }
|
if (-not $dataPath) { continue }
|
||||||
|
|
||||||
|
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
|
||||||
|
# - bare numeric (e.g. "10", "1000003") — internal index
|
||||||
|
# - "N/M:<uuid>" — metadata reference by UUID
|
||||||
|
if ($dataPath -match '^\d+$' -or $dataPath -match '^\d+/\d+:[0-9a-fA-F-]+$') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
$pathChecked++
|
$pathChecked++
|
||||||
|
|
||||||
# Extract root segment of path, strip array indices like [0]
|
# Extract root segment of path, strip array indices like [0]
|
||||||
$cleanPath = $dataPath -replace '\[\d+\]', ''
|
$cleanPath = $dataPath -replace '\[\d+\]', ''
|
||||||
|
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
|
||||||
|
if ($cleanPath.StartsWith('~')) { $cleanPath = $cleanPath.Substring(1) }
|
||||||
$segments = $cleanPath -split '\.'
|
$segments = $cleanPath -split '\.'
|
||||||
$rootAttr = $segments[0]
|
$rootAttr = $segments[0]
|
||||||
|
|
||||||
|
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||||
|
if ($rootAttr -eq 'Items') {
|
||||||
|
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
||||||
|
Report-Warn "[$tag] '$elName': DataPath='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tableName = $segments[1]
|
||||||
|
$tableEl = $null
|
||||||
|
foreach ($candidate in $allElements) {
|
||||||
|
if ($candidate.Tag -eq 'Table' -and $candidate.Name -eq $tableName) {
|
||||||
|
$tableEl = $candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $tableEl) {
|
||||||
|
Report-Error "[$tag] '$elName': DataPath='$dataPath' — table element '$tableName' not found"
|
||||||
|
$pathErrors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
||||||
|
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
||||||
|
# Table without DataPath — can't resolve further, accept silently
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
||||||
|
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
||||||
|
$rootAttr = ($tableDp -split '\.')[0]
|
||||||
|
}
|
||||||
|
|
||||||
if (-not $attrMap.ContainsKey($rootAttr)) {
|
if (-not $attrMap.ContainsKey($rootAttr)) {
|
||||||
Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found"
|
Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found"
|
||||||
$pathErrors++
|
$pathErrors++
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-validate v1.4 — Validate 1C managed form
|
# form-validate v1.6 — Validate 1C managed form
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -376,12 +376,44 @@ def main():
|
|||||||
if not data_path:
|
if not data_path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
|
||||||
|
# - bare numeric (e.g. "10", "1000003") — internal index
|
||||||
|
# - "N/M:<uuid>" — metadata reference by UUID
|
||||||
|
if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path):
|
||||||
|
continue
|
||||||
|
|
||||||
path_checked += 1
|
path_checked += 1
|
||||||
|
|
||||||
clean_path = re.sub(r'\[\d+\]', '', data_path)
|
clean_path = re.sub(r'\[\d+\]', '', data_path)
|
||||||
|
# Strip leading '~' (current row of DynamicList: ~\u0421\u043f\u0438\u0441\u043e\u043a.\u041f\u043e\u043b\u0435)
|
||||||
|
if clean_path.startswith('~'):
|
||||||
|
clean_path = clean_path[1:]
|
||||||
segments = clean_path.split(".")
|
segments = clean_path.split(".")
|
||||||
root_attr = segments[0]
|
root_attr = segments[0]
|
||||||
|
|
||||||
|
# Resolve Items.<TableName>.CurrentData.<Field>... \u2014 table element, not attribute
|
||||||
|
if root_attr == 'Items':
|
||||||
|
if len(segments) < 3 or segments[2] != 'CurrentData':
|
||||||
|
report_warn(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
||||||
|
continue
|
||||||
|
table_name = segments[1]
|
||||||
|
table_el = None
|
||||||
|
for candidate in all_elements:
|
||||||
|
if candidate["Tag"] == 'Table' and candidate["Name"] == table_name:
|
||||||
|
table_el = candidate
|
||||||
|
break
|
||||||
|
if table_el is None:
|
||||||
|
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 table element '{table_name}' not found")
|
||||||
|
path_errors += 1
|
||||||
|
continue
|
||||||
|
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
||||||
|
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
||||||
|
continue
|
||||||
|
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
||||||
|
if table_dp.startswith('~'):
|
||||||
|
table_dp = table_dp[1:]
|
||||||
|
root_attr = table_dp.split(".")[0]
|
||||||
|
|
||||||
if root_attr not in attr_map:
|
if root_attr not in attr_map:
|
||||||
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 attribute '{root_attr}' not found")
|
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 attribute '{root_attr}' not found")
|
||||||
path_errors += 1
|
path_errors += 1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-compile v1.11 — Compile 1C metadata object from JSON
|
# meta-compile v1.12 — Compile 1C metadata object from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -502,6 +502,7 @@ function Parse-AttributeShorthand {
|
|||||||
fillChecking = if ($val.fillChecking) { "$($val.fillChecking)" } else { "" }
|
fillChecking = if ($val.fillChecking) { "$($val.fillChecking)" } else { "" }
|
||||||
indexing = if ($val.indexing) { "$($val.indexing)" } else { "" }
|
indexing = if ($val.indexing) { "$($val.indexing)" } else { "" }
|
||||||
multiLine = if ($val.multiLine -eq $true) { $true } else { $false }
|
multiLine = if ($val.multiLine -eq $true) { $true } else { $false }
|
||||||
|
choiceHistoryOnInput = if ($val.choiceHistoryOnInput) { "$($val.choiceHistoryOnInput)" } else { "" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,7 +823,8 @@ function Emit-Attribute {
|
|||||||
X "$indent`t`t<CreateOnInput>Auto</CreateOnInput>"
|
X "$indent`t`t<CreateOnInput>Auto</CreateOnInput>"
|
||||||
X "$indent`t`t<ChoiceForm/>"
|
X "$indent`t`t<ChoiceForm/>"
|
||||||
X "$indent`t`t<LinkByType/>"
|
X "$indent`t`t<LinkByType/>"
|
||||||
X "$indent`t`t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>"
|
$chi = if ($parsed.choiceHistoryOnInput) { $parsed.choiceHistoryOnInput } else { "Auto" }
|
||||||
|
X "$indent`t`t<ChoiceHistoryOnInput>$chi</ChoiceHistoryOnInput>"
|
||||||
|
|
||||||
# Use — only for catalog top-level attributes
|
# Use — only for catalog top-level attributes
|
||||||
if ($context -eq "catalog") {
|
if ($context -eq "catalog") {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# meta-compile v1.11 — Compile 1C metadata object from JSON
|
# meta-compile v1.12 — Compile 1C metadata object from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -465,6 +465,7 @@ def parse_attribute_shorthand(val):
|
|||||||
'fillChecking': str(val['fillChecking']) if val.get('fillChecking') else '',
|
'fillChecking': str(val['fillChecking']) if val.get('fillChecking') else '',
|
||||||
'indexing': str(val['indexing']) if val.get('indexing') else '',
|
'indexing': str(val['indexing']) if val.get('indexing') else '',
|
||||||
'multiLine': True if val.get('multiLine') is True else False,
|
'multiLine': True if val.get('multiLine') is True else False,
|
||||||
|
'choiceHistoryOnInput': str(val['choiceHistoryOnInput']) if val.get('choiceHistoryOnInput') else '',
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_enum_value_shorthand(val):
|
def parse_enum_value_shorthand(val):
|
||||||
@@ -774,7 +775,8 @@ def emit_attribute(indent, parsed, context):
|
|||||||
X(f'{indent}\t\t<CreateOnInput>Auto</CreateOnInput>')
|
X(f'{indent}\t\t<CreateOnInput>Auto</CreateOnInput>')
|
||||||
X(f'{indent}\t\t<ChoiceForm/>')
|
X(f'{indent}\t\t<ChoiceForm/>')
|
||||||
X(f'{indent}\t\t<LinkByType/>')
|
X(f'{indent}\t\t<LinkByType/>')
|
||||||
X(f'{indent}\t\t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>')
|
chi = parsed.get('choiceHistoryOnInput') or 'Auto'
|
||||||
|
X(f'{indent}\t\t<ChoiceHistoryOnInput>{chi}</ChoiceHistoryOnInput>')
|
||||||
if context == 'catalog':
|
if context == 'catalog':
|
||||||
X(f'{indent}\t\t<Use>ForItem</Use>')
|
X(f'{indent}\t\t<Use>ForItem</Use>')
|
||||||
if context not in ('processor', 'processor-tabular'):
|
if context not in ('processor', 'processor-tabular'):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-info v1.1 — Compact summary of 1C metadata object
|
# meta-info v1.2 — Compact summary of 1C metadata object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ObjectPath,
|
[Parameter(Mandatory=$true)][Alias('Path')][string]$ObjectPath,
|
||||||
@@ -422,6 +422,22 @@ $objName = $props.SelectSingleNode("md:Name", $ns).InnerText
|
|||||||
$synNode = $props.SelectSingleNode("md:Synonym", $ns)
|
$synNode = $props.SelectSingleNode("md:Synonym", $ns)
|
||||||
$synonym = Get-MLText $synNode
|
$synonym = Get-MLText $synNode
|
||||||
|
|
||||||
|
# Presentations (type-choice dialogs show "Представление объекта" as the ref type name)
|
||||||
|
$objPresentation = Get-MLText $props.SelectSingleNode("md:ObjectPresentation", $ns)
|
||||||
|
$extObjPresentation = Get-MLText $props.SelectSingleNode("md:ExtendedObjectPresentation", $ns)
|
||||||
|
$listPresentation = Get-MLText $props.SelectSingleNode("md:ListPresentation", $ns)
|
||||||
|
$extListPresentation = Get-MLText $props.SelectSingleNode("md:ExtendedListPresentation", $ns)
|
||||||
|
|
||||||
|
# Reference (ref-typed) metadata objects — those with a ...Ref type
|
||||||
|
$refMdTypes = @("Catalog","Document","Enum","ChartOfAccounts","ChartOfCharacteristicTypes",
|
||||||
|
"ChartOfCalculationTypes","ExchangePlan","BusinessProcess","Task")
|
||||||
|
$isRefObject = $refMdTypes -contains $mdType
|
||||||
|
|
||||||
|
# Effective type presentation: ObjectPresentation -> Synonym -> Name
|
||||||
|
$typePresentation = if ($objPresentation) { $objPresentation }
|
||||||
|
elseif ($synonym) { $synonym }
|
||||||
|
else { $objName }
|
||||||
|
|
||||||
# --- Handle -Name drill-down ---
|
# --- Handle -Name drill-down ---
|
||||||
$drillDone = $false
|
$drillDone = $false
|
||||||
if ($Name -and $childObjs) {
|
if ($Name -and $childObjs) {
|
||||||
@@ -593,6 +609,17 @@ if (-not $drillDone) {
|
|||||||
$header += " ==="
|
$header += " ==="
|
||||||
Out $header
|
Out $header
|
||||||
|
|
||||||
|
# --- Type presentation (ref objects) ---
|
||||||
|
if ($isRefObject) {
|
||||||
|
Out "Представление типа: $typePresentation"
|
||||||
|
if ($Mode -eq "full") {
|
||||||
|
if ($objPresentation) { Out "Представление объекта: $objPresentation" }
|
||||||
|
if ($extObjPresentation) { Out "Расширенное представление объекта: $extObjPresentation" }
|
||||||
|
if ($listPresentation) { Out "Представление списка: $listPresentation" }
|
||||||
|
if ($extListPresentation) { Out "Расширенное представление списка: $extListPresentation" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Mode: brief ---
|
# --- Mode: brief ---
|
||||||
if ($Mode -eq "brief") {
|
if ($Mode -eq "brief") {
|
||||||
# Attributes
|
# Attributes
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-info v1.1 — Compact summary of 1C metadata object (Python port)
|
# meta-info v1.2 — Compact summary of 1C metadata object (Python port)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
@@ -477,6 +477,21 @@ obj_name = inner_text(find(props, "md:Name"))
|
|||||||
syn_node = find(props, "md:Synonym")
|
syn_node = find(props, "md:Synonym")
|
||||||
synonym = get_ml_text(syn_node)
|
synonym = get_ml_text(syn_node)
|
||||||
|
|
||||||
|
# Presentations (type-choice dialogs show "Представление объекта" as the ref type name)
|
||||||
|
obj_presentation = get_ml_text(find(props, "md:ObjectPresentation"))
|
||||||
|
ext_obj_presentation = get_ml_text(find(props, "md:ExtendedObjectPresentation"))
|
||||||
|
list_presentation = get_ml_text(find(props, "md:ListPresentation"))
|
||||||
|
ext_list_presentation = get_ml_text(find(props, "md:ExtendedListPresentation"))
|
||||||
|
|
||||||
|
# Reference (ref-typed) metadata objects — those with a ...Ref type
|
||||||
|
ref_md_types = {"Catalog", "Document", "Enum", "ChartOfAccounts",
|
||||||
|
"ChartOfCharacteristicTypes", "ChartOfCalculationTypes",
|
||||||
|
"ExchangePlan", "BusinessProcess", "Task"}
|
||||||
|
is_ref_object = md_type in ref_md_types
|
||||||
|
|
||||||
|
# Effective type presentation: ObjectPresentation -> Synonym -> Name
|
||||||
|
type_presentation = obj_presentation or synonym or obj_name
|
||||||
|
|
||||||
# ── Handle -Name drill-down ──────────────────────────────────
|
# ── Handle -Name drill-down ──────────────────────────────────
|
||||||
|
|
||||||
drill_done = False
|
drill_done = False
|
||||||
@@ -636,6 +651,19 @@ if not drill_done:
|
|||||||
header += " ==="
|
header += " ==="
|
||||||
out(header)
|
out(header)
|
||||||
|
|
||||||
|
# Type presentation (ref objects)
|
||||||
|
if is_ref_object:
|
||||||
|
out(f"Представление типа: {type_presentation}")
|
||||||
|
if mode == "full":
|
||||||
|
if obj_presentation:
|
||||||
|
out(f"Представление объекта: {obj_presentation}")
|
||||||
|
if ext_obj_presentation:
|
||||||
|
out(f"Расширенное представление объекта: {ext_obj_presentation}")
|
||||||
|
if list_presentation:
|
||||||
|
out(f"Представление списка: {list_presentation}")
|
||||||
|
if ext_list_presentation:
|
||||||
|
out(f"Расширенное представление списка: {ext_list_presentation}")
|
||||||
|
|
||||||
if mode == "brief":
|
if mode == "brief":
|
||||||
# Attributes
|
# Attributes
|
||||||
attrs = get_attributes(child_objs) if child_objs is not None else []
|
attrs = get_attributes(child_objs) if child_objs is not None else []
|
||||||
|
|||||||
@@ -88,11 +88,20 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-compile.ps1" -V
|
|||||||
|
|
||||||
Многоязычный заголовок: `"title": { "ru": "...", "en": "..." }`. Применимо везде, где принимается title/presentation (поля, calculatedFields, parameters, settingsVariants, availableValues и пр.). Строка эквивалентна `{ "ru": "..." }`.
|
Многоязычный заголовок: `"title": { "ru": "...", "en": "..." }`. Применимо везде, где принимается title/presentation (поля, calculatedFields, parameters, settingsVariants, availableValues и пр.). Строка эквивалентна `{ "ru": "..." }`.
|
||||||
|
|
||||||
Типы: `string`, `string(N)`, `decimal(D,F)`, `boolean`, `date`, `dateTime`, `CatalogRef.X`, `DocumentRef.X`, `EnumRef.X`, `StandardPeriod`. Ссылочные типы эмитируются с inline namespace `d5p1:` (`http://v8.1c.ru/8.1/data/enterprise/current-config`). Сборка EPF со ссылочными типами требует базу с соответствующей конфигурацией.
|
Типы: `string`, `string(N)`, `decimal`, `decimal(D)`, `decimal(D,F)`, `boolean`, `date`, `dateTime`, `CatalogRef.X`, `DocumentRef.X`, `EnumRef.X`, `StandardPeriod`. Ссылочные типы эмитируются с inline namespace `d5p1:` (`http://v8.1c.ru/8.1/data/enterprise/current-config`). Сборка EPF со ссылочными типами требует базу с соответствующей конфигурацией.
|
||||||
|
|
||||||
|
`decimal` без скобок = `10,2` (деньги по умолчанию), `decimal(N)` = `N,0` (целое); `,nonneg` в конце скобок → AllowedSign=Nonnegative.
|
||||||
|
|
||||||
Составной тип (несколько типов значений) — массив в объектной форме: `"type": ["CatalogRef.A", "CatalogRef.B"]`. Квалификаторы (`(N)`, `(D,F)`) применяются к каждому элементу.
|
Составной тип (несколько типов значений) — массив в объектной форме: `"type": ["CatalogRef.A", "CatalogRef.B"]`. Квалификаторы (`(N)`, `(D,F)`) применяются к каждому элементу.
|
||||||
|
|
||||||
Роли: `@dimension`, `@account`, `@balance`, `@period`.
|
Роли (shorthand или объект):
|
||||||
|
|
||||||
|
- `@`-флаги: `@dimension`, `@account`, `@balance`, `@period`, `@required`, `@autoOrder`, `@ignoreNullValues`
|
||||||
|
- KV: `balanceGroupName`, `balanceType` (`OpeningBalance`/`ClosingBalance`), `parentDimension`, `accountTypeExpression`, `expression`, `orderType` (`Asc`/`Desc`), `periodNumber`, `periodType`
|
||||||
|
|
||||||
|
```
|
||||||
|
"Сумма: decimal(15,2) @balance balanceGroupName=Сумма balanceType=OpeningBalance"
|
||||||
|
```
|
||||||
|
|
||||||
Ограничения: `#noField`, `#noFilter`, `#noGroup`, `#noOrder`.
|
Ограничения: `#noField`, `#noFilter`, `#noGroup`, `#noOrder`.
|
||||||
|
|
||||||
@@ -101,6 +110,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-compile.ps1" -V
|
|||||||
Дополнительные ключи объектной формы:
|
Дополнительные ключи объектной формы:
|
||||||
- `"presentationExpression": "<выражение>"` — что показывать вместо значения поля. Исходное значение остаётся «под капотом» для перехода/расшифровки.
|
- `"presentationExpression": "<выражение>"` — что показывать вместо значения поля. Исходное значение остаётся «под капотом» для перехода/расшифровки.
|
||||||
- `"appearance": { "<параметр>": "<значение>" }` — оформление колонки по умолчанию (применяется во всех вариантах настроек). Ключи — параметры платформы (`ГоризонтальноеПоложение`, `МинимальнаяШирина`, `Формат`, `Текст` и т.п.).
|
- `"appearance": { "<параметр>": "<значение>" }` — оформление колонки по умолчанию (применяется во всех вариантах настроек). Ключи — параметры платформы (`ГоризонтальноеПоложение`, `МинимальнаяШирина`, `Формат`, `Текст` и т.п.).
|
||||||
|
- `"orderExpression": { "expression": "<выражение>", "orderType": "Asc"/"Desc", "autoOrder": true/false }` — сортировка поля по выражению (например `ЕстьNULL(Поле.Порядок, 10000)`).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "field": "Сумма", "title": "Сумма продажи", "type": "decimal(15,2)",
|
{ "field": "Сумма", "title": "Сумма продажи", "type": "decimal(15,2)",
|
||||||
@@ -142,11 +152,21 @@ Shorthand: `"Имя [Заголовок]: тип = значение @флаги"
|
|||||||
|
|
||||||
Флаги shorthand:
|
Флаги shorthand:
|
||||||
- `@autoDates` — добавляет к параметру StandardPeriod пару дат `НачалоПериода`/`КонецПериода`, вычисляемых из него. Используй их в тексте запроса как `&НачалоПериода`/`&КонецПериода`; пользователь выбирает только сам период. По умолчанию сам параметр получает `use=Always` и `denyIncompleteValues=true` (чтобы производные даты всегда были заполнены); в объектной форме можно явно переопределить.
|
- `@autoDates` — добавляет к параметру StandardPeriod пару дат `НачалоПериода`/`КонецПериода`, вычисляемых из него. Используй их в тексте запроса как `&НачалоПериода`/`&КонецПериода`; пользователь выбирает только сам период. По умолчанию сам параметр получает `use=Always` и `denyIncompleteValues=true` (чтобы производные даты всегда были заполнены); в объектной форме можно явно переопределить.
|
||||||
- `@valueList` — `<valueListAllowed>true</valueListAllowed>` — разрешает передавать список значений
|
- `@valueList` — `<valueListAllowed>true</valueListAllowed>` — разрешает передавать список значений (при значении-списке ниже подразумевается автоматически)
|
||||||
- `@hidden` — скрытый параметр: `availableAsField=false` + исключается из `"dataParameters": "auto"`
|
- `@hidden` — скрытый параметр: `availableAsField=false` + исключается из `"dataParameters": "auto"`
|
||||||
|
|
||||||
Объектная форма: `title`, `hidden: true`, `valueListAllowed: true`, `availableAsField: false`, `denyIncompleteValues: true`, `use: "Always"`.
|
Объектная форма: `title`, `hidden: true`, `valueListAllowed: true`, `availableAsField: false`, `denyIncompleteValues: true`, `use: "Always"`.
|
||||||
|
|
||||||
|
Значение-список: несколько значений по умолчанию через запятую в `значение` (для запятой внутри значения — кавычки `'...'`). В объектной форме — массив в `value`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"parameters": [
|
||||||
|
"Виды: ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные = ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Если значения по умолчанию нет — пропусти `=` в shorthand или укажи `"value": null` в объектной форме.
|
||||||
|
|
||||||
Список допустимых значений (availableValues):
|
Список допустимых значений (availableValues):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: skd-decompile
|
||||||
|
description: Декомпиляция схемы компоновки данных 1С (СКД) в JSON-черновик в формате skd-compile. Используй для scaffold нового отчёта по образцу или структурного рефакторинга. Не для точечных правок
|
||||||
|
argument-hint: <TemplatePath> [-OutputPath <out.json>]
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# /skd-decompile — JSON-черновик из Template.xml СКД
|
||||||
|
|
||||||
|
Читает Template.xml и эмитит JSON в формате `skd-compile`. **Результат — черновик**, а не обратимое представление: см. раздел «Что получаешь».
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
- **Scaffold нового отчёта по образцу** — взять существующий СКД, получить JSON, поправить и скомпилировать в новый.
|
||||||
|
- **Структурный рефакторинг** — переписать вариант, перерисовать шаблон, перебрать набор полей.
|
||||||
|
|
||||||
|
## Когда **не** использовать
|
||||||
|
|
||||||
|
- **Точечные правки готового отчёта** (добавить поле, фильтр, итог, переименовать) → `/skd-edit`. Цикл «декомпиляция → правка JSON → компиляция» переписывает шаблон целиком, может терять непокрытые конструкции и даёт большой diff в исходниках. `/skd-edit` правит адресно, без полной реконструкции.
|
||||||
|
|
||||||
|
## Параметры
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `TemplatePath` | Путь к Template.xml (обязательный) |
|
||||||
|
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-decompile.ps1" -TemplatePath "<Template.xml>" -OutputPath "<out.json>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что получаешь
|
||||||
|
|
||||||
|
JSON-черновик в формате `/skd-compile` — **не полное обратимое представление СКД**. На вход компилятору такой JSON напрямую может не пойти: в нём встречаются sentinel-узлы (маркер `__unsupported__`).
|
||||||
|
|
||||||
|
- **Готовые узлы** — большая часть СКД (поля, параметры, шаблоны, варианты со structure/filter/order/conditionalAppearance и т.п.) ложится в JSON как обычные узлы DSL.
|
||||||
|
- **Sentinel-узлы** — места, где встретилась конструкция, которую декомпилятор не умеет выразить в DSL. JSON остаётся валидным, но компилятор откажется его собирать, пока sentinel не **заменён ручной реализацией** (явный raw `template`, прописанный appearance и т.п.) **или не удалён**, если в новом отчёте конструкция не нужна. Это намеренный барьер — чтобы непокрытое не уехало в финальный отчёт незамеченным.
|
||||||
|
- **`<basename>.warnings.md`** рядом с `OutputPath` — список всех sentinel-узлов с координатами в исходнике, по нему удобно обходить места под ручную доработку.
|
||||||
|
- **Критичные конструкции** (Picture cells, ХранилищеЗначения, вложенные схемы, не-СКД root) — скрипт падает с ненулевым кодом и сообщением в stderr; такой Template как образец не годится.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. `/skd-decompile <Template.xml> -OutputPath draft.json` — получить черновик.
|
||||||
|
2. Открыть `draft.warnings.md`, посмотреть, что не покрылось.
|
||||||
|
3. Поправить JSON под задачу. Sentinel-узлы — заменить на ручную реализацию (через явный raw `template`, через ручное описание appearance и т.п.) либо удалить, если конструкция в новом отчёте не нужна.
|
||||||
|
4. `/skd-compile -DefinitionFile draft.json -OutputPath new-Template.xml` — собрать обратно.
|
||||||
|
5. `/skd-validate` + `/skd-info` — проверить.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -52,11 +52,16 @@ Shorthand: `"Имя [Заголовок]: тип @роль #ограничени
|
|||||||
|
|
||||||
Поле добавляется в набор и в selection варианта (если нет `-NoSelection`). Дубликат dataPath — предупреждение, пропуск.
|
Поле добавляется в набор и в selection варианта (если нет `-NoSelection`). Дубликат dataPath — предупреждение, пропуск.
|
||||||
|
|
||||||
|
Чтобы поле попало в selection не варианта, а конкретной группировки структуры — используй `-NoSelection` и затем `add-selection "Имя @group=ИмяГруппы"`.
|
||||||
|
|
||||||
### add-total — добавить итог
|
### add-total — добавить итог
|
||||||
|
|
||||||
|
Shorthand: `"<dataPath>: <выражение>"`. Если выражение — известная аггрегатная функция без скобок (`Сумма`, `Количество`, `Минимум`, `Максимум`, `Среднее`), оно автоматически оборачивается в `Func(dataPath)`. Если функция со скобками или произвольное выражение — используется как есть.
|
||||||
|
|
||||||
```
|
```
|
||||||
"Цена: Среднее"
|
"Цена: Среднее" # → Среднее(Цена)
|
||||||
"Стоимость: Сумма(Кол * Цена)"
|
"Стоимость: Сумма(Кол * Цена)" # → как есть
|
||||||
|
"Проверка: Проверка" # identity: выражение = Проверка
|
||||||
```
|
```
|
||||||
|
|
||||||
### add-calculated-field — добавить вычисляемое поле
|
### add-calculated-field — добавить вычисляемое поле
|
||||||
@@ -80,24 +85,54 @@ Shorthand: `"Имя [Заголовок]: тип = Выражение #noFilter
|
|||||||
"Организация: CatalogRef.Организации"
|
"Организация: CatalogRef.Организации"
|
||||||
```
|
```
|
||||||
|
|
||||||
Shorthand: `"Имя [Заголовок]: тип = значение @флаги"`. `[Заголовок]` опциональный — добавляет `<title>`.
|
Shorthand: `"Имя [Заголовок]: тип = значение [availableValue=список] [@флаги]"`. `[Заголовок]` опциональный — добавляет `<title>`.
|
||||||
|
|
||||||
`@autoDates` генерирует пару скрытых параметров `ДатаНачала`/`ДатаОкончания` для StandardPeriod-параметра — для БСП-отчётов, чтобы получить пару полей «Начало/Конец» в панели быстрых настроек.
|
Флаги:
|
||||||
|
- `@autoDates` — генерирует пару скрытых параметров `ДатаНачала`/`ДатаОкончания` для StandardPeriod-параметра.
|
||||||
|
- `@hidden` — скрывает параметр от пользовательских настроек (для параметров-констант, используемых в запросе).
|
||||||
|
- `@always` — параметр всегда подставляется в запрос. Часто вместе с `@hidden`, но используется и отдельно (для видимых обязательных параметров типа отчётного периода).
|
||||||
|
- `@valueList` — разрешает передавать в параметр список значений (при значении-списке ниже подразумевается автоматически, отдельно указывать не обязательно).
|
||||||
|
|
||||||
|
Значение-список: несколько значений по умолчанию задаются через запятую в `значение`. Для запятой внутри одного значения — кавычки `'...'`.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Виды [Виды субконто]: ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные = ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"ПС: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка @hidden"
|
||||||
|
"Период: StandardPeriod = LastMonth @always"
|
||||||
|
"ПСчет: ChartOfAccountsRef.Хозрасчетный = ПланСчетов.Хозрасчетный.X @hidden @always"
|
||||||
|
"Округление: EnumRef.Округления = Окр1 availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс."
|
||||||
|
```
|
||||||
|
|
||||||
|
`availableValue=` задаёт начальный список допустимых значений. Формат списка: `v1[: p1], v2[: p2], ...` — элементы через `,`, представление после `:`. Если в значении или представлении встречается `,` или `:` — оборачивай в одинарные кавычки `'...'`:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Округление: ... = Окр1 availableValue=Окр1_00: 'руб., коп.', Окр1: руб."
|
||||||
|
```
|
||||||
|
|
||||||
### modify-parameter — изменить существующий параметр
|
### modify-parameter — изменить существующий параметр
|
||||||
|
|
||||||
Находит параметр по имени, добавляет/обновляет свойства.
|
Shorthand: `"ИмяПараметра [Заголовок] [ключ=значение]... [@флаги]"`. Находит параметр по имени, обновляет указанные свойства.
|
||||||
|
|
||||||
```
|
```
|
||||||
"ПорядокОкругления use=Always"
|
"ПорядокОкругления use=Always"
|
||||||
"ПорядокОкругления [Округление сумм] denyIncompleteValues=true"
|
"ПорядокОкругления [Округление сумм] denyIncompleteValues=true"
|
||||||
"ПериодОтчета [Отчетный период]" # только title
|
"ПериодОтчета [Отчетный период]" # только title
|
||||||
"ПорядокОкругления availableValue=Перечисление.Округления.Окр1 presentation=руб."
|
"ПорядокОкругления availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс."
|
||||||
|
"СчетПС value=ПланСчетов.Хозрасчетный.КассаПредприятия"
|
||||||
|
"Виды value=ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"
|
||||||
|
"Контрагент @hidden @always"
|
||||||
```
|
```
|
||||||
|
|
||||||
`[Заголовок]` опциональный — устанавливает или заменяет `<title>`. Можно вызывать без других kv-пар, чтобы только обновить title.
|
`[Заголовок]` опциональный — устанавливает или заменяет `<title>`. Можно вызывать без других kv-пар, чтобы только обновить title.
|
||||||
|
|
||||||
`availableValue=` добавляет один элемент списка допустимых значений (можно несколько через `;;`). Тип значения определяется автоматически (DesignTimeValue для ссылок).
|
`availableValue=` **заменяет весь список** допустимых значений (старые удаляются). Формат и кавычки — те же, что в `add-parameter`.
|
||||||
|
|
||||||
|
`value=` заменяет значение параметра. Несколько значений через запятую → **список значений** (заменяет все прежние); для запятой внутри значения — кавычки `'...'`.
|
||||||
|
|
||||||
|
Флаги `@hidden` / `@always` — те же, что и в `add-parameter`. Идемпотентны.
|
||||||
|
|
||||||
### rename-parameter — переименовать параметр
|
### rename-parameter — переименовать параметр
|
||||||
|
|
||||||
@@ -229,15 +264,21 @@ Value — имена ресурсов (как в полях/вычисляемы
|
|||||||
|
|
||||||
Не поддерживает пакетный режим. Value — полный текст запроса или `@path/to/file.sql` (ссылка на внешний файл). Путь разрешается относительно Template.xml, затем CWD.
|
Не поддерживает пакетный режим. Value — полный текст запроса или `@path/to/file.sql` (ссылка на внешний файл). Путь разрешается относительно Template.xml, затем CWD.
|
||||||
|
|
||||||
|
Когда что: **существенная переработка** (добавить поля, соединения, переписать пакет) → выгрузи запрос через `/skd-info <tpl> -Mode query -Name <набор> -Raw -OutFile file.sql`, отредактируй файл, верни `set-query @file`. `-Raw` отдаёт запрос целиком без декораций, поэтому выгрузка ↔ возврат точны (включая многопакетные запросы). **Точечная замена** (переименовать идентификатор, заменить подстроку) → выгрузка не нужна, используй `patch-query` ниже.
|
||||||
|
|
||||||
### patch-query — точечная замена в тексте запроса
|
### patch-query — точечная замена в тексте запроса
|
||||||
|
|
||||||
Shorthand: `"старое => новое"`. Заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
|
Shorthand: `"старое => новое [@once]"`. По умолчанию заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
|
||||||
|
|
||||||
```
|
```
|
||||||
"СубконтоДт1) В => СубконтоКт1) В"
|
"СубконтоДт1) В => СубконтоКт1) В"
|
||||||
"ЛЕВОЕ СОЕДИНЕНИЕ => ВНУТРЕННЕЕ СОЕДИНЕНИЕ"
|
"КАК ВТ_СтароеИмя => КАК ВТ_НовоеИмя @once"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`@once` — упасть с ошибкой, если в запросе не **ровно одно** вхождение. Защищает от случайных замен в комментариях и однотипных идентификаторах.
|
||||||
|
|
||||||
|
Многострочные подстроки поддерживаются.
|
||||||
|
|
||||||
### set-outputParameter — установить параметр вывода
|
### set-outputParameter — установить параметр вывода
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -249,16 +290,27 @@ Shorthand: `"старое => новое"`. Заменяет все вхожде
|
|||||||
|
|
||||||
### set-structure — установить структуру варианта
|
### set-structure — установить структуру варианта
|
||||||
|
|
||||||
Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — детальные записи. Заменяет всю структуру. Не поддерживает пакетный режим.
|
Shorthand: `"Поле1 > Поле2 > details"`. `>` — вложенный уровень группировки, `,` — несколько полей в одном уровне, `details` — детальные записи. **Заменяет всю структуру полностью** (включая Selection/order/filter/conditionalAppearance каждой группы). Для точечной модификации полей группировки с сохранением настроек — используй `modify-structure`. Не поддерживает пакетный режим.
|
||||||
|
|
||||||
```
|
```
|
||||||
"Организация > Номенклатура > details"
|
"Организация > Номенклатура > details"
|
||||||
|
"Валюта, НаименованиеБанка, ИНН"
|
||||||
"details"
|
"details"
|
||||||
"СчетМеждународногоУчета @name=ДанныеОтчета"
|
"СчетМеждународногоУчета @name=ДанныеОтчета"
|
||||||
```
|
```
|
||||||
|
|
||||||
`@name=Имя` — присваивает имя группировке (`<dcsset:name>`). Используется для привязки шаблонов через `groupName`.
|
`@name=Имя` — присваивает имя группировке (`<dcsset:name>`). Используется для привязки шаблонов через `groupName`.
|
||||||
|
|
||||||
|
### modify-structure — изменить поля группировки существующей группы
|
||||||
|
|
||||||
|
Тот же shorthand что и `set-structure`. Находит группу по `@name=`, заменяет только `<groupItems>` (поля группировки). Selection/order/filter/conditionalAppearance/outputParameters группы сохраняются. Без `@name=` — ошибка.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Валюта @name=ДанныеОтчета"
|
||||||
|
"Валюта, НаименованиеБанка @name=ДанныеОтчета"
|
||||||
|
"details @name=ДанныеОтчета"
|
||||||
|
```
|
||||||
|
|
||||||
### modify-field — изменить существующее поле
|
### modify-field — изменить существующее поле
|
||||||
|
|
||||||
Тот же shorthand что и `add-field`. Находит по dataPath, объединяет свойства (непустые переопределяют), сохраняет позицию.
|
Тот же shorthand что и `add-field`. Находит по dataPath, объединяет свойства (непустые переопределяют), сохраняет позицию.
|
||||||
@@ -267,6 +319,23 @@ Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — д
|
|||||||
"Цена [Цена USD]: decimal(10,4) @dimension"
|
"Цена [Цена USD]: decimal(10,4) @dimension"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### set-field-role — установить роль поля
|
||||||
|
|
||||||
|
Shorthand: `"<dataPath> [@флаги] [kv=значение]"`. **Полностью заменяет** роль поля. Если в значении только dataPath без флагов/kv — удаляет роль.
|
||||||
|
|
||||||
|
```
|
||||||
|
"Сумма" # снять роль полностью
|
||||||
|
"СуммаОстаток @balance" # простая балансовая роль
|
||||||
|
"СуммаНач @balance balanceGroupName=Сумма balanceType=OpeningBalance" # с уточнением
|
||||||
|
"Контрагент @dimension parentDimension=Группа"
|
||||||
|
"Период @period" # роль периода
|
||||||
|
```
|
||||||
|
|
||||||
|
Флаги: `@balance`, `@dimension`, `@account`, `@period`, `@required`, `@autoOrder`, `@ignoreNullValues`.
|
||||||
|
KV: `balanceGroupName`, `balanceType` (OpeningBalance/ClosingBalance), `parentDimension`, `accountTypeExpression`, `orderType` (Asc/Desc), `expression`, `periodNumber`, `periodType`.
|
||||||
|
|
||||||
|
Поддерживает пакетный режим (`;;`).
|
||||||
|
|
||||||
### modify-filter — изменить существующий фильтр
|
### modify-filter — изменить существующий фильтр
|
||||||
|
|
||||||
Тот же shorthand что и `add-filter`. Находит по полю, обновляет оператор/значение/флаги. См. правило для `<use>` ниже.
|
Тот же shorthand что и `add-filter`. Находит по полю, обновляет оператор/значение/флаги. См. правило для `<use>` ниже.
|
||||||
@@ -294,6 +363,7 @@ Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — д
|
|||||||
| `clear-selection` | `*` | Очищает все элементы selection |
|
| `clear-selection` | `*` | Очищает все элементы selection |
|
||||||
| `clear-order` | `*` | Очищает все элементы order |
|
| `clear-order` | `*` | Очищает все элементы order |
|
||||||
| `clear-filter` | `*` | Очищает все элементы filter |
|
| `clear-filter` | `*` | Очищает все элементы filter |
|
||||||
|
| `clear-conditionalAppearance` | `*` | Очищает все правила условного оформления |
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: skd-info
|
name: skd-info
|
||||||
description: Анализ структуры схемы компоновки данных 1С (СКД) — наборы, поля, параметры, варианты. Используй для понимания отчёта — источник данных (запрос), доступные поля, параметры
|
description: Анализ структуры схемы компоновки данных 1С (СКД) — наборы, поля, параметры, варианты. Используй для понимания отчёта — источник данных (запрос), доступные поля, параметры
|
||||||
argument-hint: <TemplatePath> [-Mode overview|query|fields|links|calculated|resources|params|variant|templates|trace|full] [-Name <dataset|variant|field|group>]
|
argument-hint: <TemplatePath> [-Mode overview|query|fields|links|calculated|resources|params|variant|templates|trace|full] [-Name <dataset|variant|field|group>] [-Raw]
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
@@ -20,7 +20,8 @@ allowed-tools:
|
|||||||
| `Mode` | Режим анализа (по умолчанию `overview`) |
|
| `Mode` | Режим анализа (по умолчанию `overview`) |
|
||||||
| `Name` | Имя набора (query), поля (fields/calculated/resources/trace), варианта (variant) или группировки/поля (templates) |
|
| `Name` | Имя набора (query), поля (fields/calculated/resources/trace), варианта (variant) или группировки/поля (templates) |
|
||||||
| `Batch` | Номер пакета запроса, 0 = все (только query) |
|
| `Batch` | Номер пакета запроса, 0 = все (только query) |
|
||||||
| `Limit` / `Offset` | Пагинация (по умолчанию 150 строк) |
|
| `Raw` | (только query) сырой текст запроса целиком, без заголовков/оглавления/разделителей пакетов. Для выгрузки в `.sql` и возврата через `skd-edit set-query @file` |
|
||||||
|
| `Limit` / `Offset` | Пагинация (по умолчанию 150 строк; `-Raw` не усекается) |
|
||||||
| `OutFile` | Записать результат в файл (UTF-8 BOM) |
|
| `OutFile` | Записать результат в файл (UTF-8 BOM) |
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -31,6 +32,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-info.ps1" -Temp
|
|||||||
```powershell
|
```powershell
|
||||||
... -Mode query -Name НоменклатураСЦенами
|
... -Mode query -Name НоменклатураСЦенами
|
||||||
... -Mode query -Name ДанныеТ13 -Batch 3
|
... -Mode query -Name ДанныеТ13 -Batch 3
|
||||||
|
... -Mode query -Name ДанныеТ13 -Raw -OutFile query.sql
|
||||||
... -Mode fields -Name КадастроваяСтоимость
|
... -Mode fields -Name КадастроваяСтоимость
|
||||||
... -Mode calculated -Name КоэффициентКи
|
... -Mode calculated -Name КоэффициентКи
|
||||||
... -Mode resources -Name СуммаНалога
|
... -Mode resources -Name СуммаНалога
|
||||||
@@ -45,7 +47,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-info.ps1" -Temp
|
|||||||
| Режим | Без `-Name` | С `-Name` |
|
| Режим | Без `-Name` | С `-Name` |
|
||||||
|-------|-------------|-----------|
|
|-------|-------------|-----------|
|
||||||
| `overview` | Навигационная карта схемы + подсказки Next | — |
|
| `overview` | Навигационная карта схемы + подсказки Next | — |
|
||||||
| `query` | — | Текст запроса набора (с оглавлением батчей) |
|
| `query` | — | Текст запроса набора (с оглавлением батчей); `-Raw` — чистая выгрузка для правки |
|
||||||
| `fields` | Карта: имена полей по наборам | Деталь поля: набор, тип, роль, формат |
|
| `fields` | Карта: имена полей по наборам | Деталь поля: набор, тип, роль, формат |
|
||||||
| `links` | Все связи наборов | — |
|
| `links` | Все связи наборов | — |
|
||||||
| `calculated` | Карта: имена вычисляемых полей | Выражение + заголовок + ограничения |
|
| `calculated` | Карта: имена вычисляемых полей | Выражение + заголовок + ограничения |
|
||||||
@@ -65,7 +67,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/skd-info.ps1" -Temp
|
|||||||
3. `query -Name <набор>` — посмотреть текст SQL-запроса
|
3. `query -Name <набор>` — посмотреть текст SQL-запроса
|
||||||
4. `variant -Name <N>` — посмотреть группировки и фильтры варианта
|
4. `variant -Name <N>` — посмотреть группировки и фильтры варианта
|
||||||
|
|
||||||
Подробные примеры вывода каждого режима — в `modes-reference.md`.
|
Переработка запроса (round-trip): `query -Name <набор> -Raw -OutFile q.sql` →
|
||||||
|
правка `q.sql` → `/skd-edit <tpl> -Operation set-query -Value "@q.sql"`. Флаг
|
||||||
|
`-Raw` отдаёт запрос целиком без декораций, поэтому выгрузка ↔ возврат
|
||||||
|
точны (включая многопакетные запросы с временными таблицами).
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
# /skd-info — полная справка по режимам
|
|
||||||
|
|
||||||
Компактное описание — в [SKILL.md](SKILL.md).
|
|
||||||
|
|
||||||
## overview (по умолчанию) — карта схемы
|
|
||||||
|
|
||||||
Компактная навигационная карта (10-25 строк). Показывает структуру и подсказывает следующие шаги:
|
|
||||||
|
|
||||||
```
|
|
||||||
=== DCS: ОсновнаяСхемаКомпоновкиДанных (362 lines) ===
|
|
||||||
|
|
||||||
Sources: ИсточникДанных1 (Local)
|
|
||||||
|
|
||||||
Datasets:
|
|
||||||
[Query] НоменклатураСЦенами 7 fields, query 40 lines
|
|
||||||
Calculated: 1
|
|
||||||
Resources: 1
|
|
||||||
Templates: 1 templates, 1 group bindings
|
|
||||||
Params: (none)
|
|
||||||
|
|
||||||
Variants:
|
|
||||||
[1] НоменклатураИЦены "Номенклатура и цены" Table(detail) 3 filters
|
|
||||||
[2] НоменклатураБезЦен "Номенклатура без цен" Group(detail) 2 filters
|
|
||||||
|
|
||||||
Next:
|
|
||||||
-Mode query query text
|
|
||||||
-Mode fields field tables by dataset
|
|
||||||
-Mode calculated calculated field expressions
|
|
||||||
-Mode resources resource aggregation
|
|
||||||
-Mode variant -Name <N> variant structure (1..2)
|
|
||||||
```
|
|
||||||
|
|
||||||
Для DataSetUnion — дерево наборов + связи:
|
|
||||||
```
|
|
||||||
Datasets:
|
|
||||||
[Union] РасчетНалогаНаИмущество 52 fields
|
|
||||||
├─ [Query] РасчетНалогаНаИмущество 51 fields, query 181 lines
|
|
||||||
├─ [Query] ДанныеПоКадастровой 29 fields, query 40 lines
|
|
||||||
├─ [Query] ДанныеПоСреднегодовой 34 fields, query 41 lines
|
|
||||||
Links: РасчетНалогаНаИмущество -> СостояниеОС (2 fields)
|
|
||||||
```
|
|
||||||
|
|
||||||
Параметры разделяются на видимые/скрытые:
|
|
||||||
```
|
|
||||||
Params: 18 (7 visible, 11 hidden): Период, Ответственный, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## query — текст запроса
|
|
||||||
|
|
||||||
`-Name <набор>` — имя DataSet (обязателен если наборов > 1).
|
|
||||||
|
|
||||||
Извлекает raw-текст запроса с деэкранированием XML (`&`→`&`, `>`→`>`). Для пакетных запросов — оглавление батчей:
|
|
||||||
|
|
||||||
```
|
|
||||||
=== Query: ДанныеТ13 (334 lines, 13 batches) ===
|
|
||||||
Batch 1: lines 1-8 → ПОМЕСТИТЬ Представления_Периоды
|
|
||||||
Batch 2: lines 9-26 → ПОМЕСТИТЬ Представления_СотрудникиОрганизации
|
|
||||||
...
|
|
||||||
--- Batch 1 ---
|
|
||||||
ВЫБРАТЬ
|
|
||||||
ДАТАВРЕМЯ(1, 1, 1) КАК Период
|
|
||||||
ПОМЕСТИТЬ Представления_Периоды
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
Фильтр по номеру батча: `-Batch 3` покажет только 3-й пакет.
|
|
||||||
|
|
||||||
## fields — поля наборов данных
|
|
||||||
|
|
||||||
Без `-Name` — карта: имена полей по наборам:
|
|
||||||
```
|
|
||||||
=== Fields map ===
|
|
||||||
СостояниеОС [Query] (3): Организация, ОсновноеСредство, ДатаСостояния
|
|
||||||
РасчетНалогаНаИмущество [Union] (52): ДоляСтоимостиЧислитель, ...
|
|
||||||
РасчетНалогаНаИмущество [Query] (51): КадастроваяСтоимость, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
С `-Name <поле>` — детали конкретного поля:
|
|
||||||
```
|
|
||||||
=== Field: ДатаСостояния "Дата ввода в эксплуатацию" ===
|
|
||||||
|
|
||||||
Dataset: СостояниеОС [Query]
|
|
||||||
Format: ДФ=dd.MM.yyyy
|
|
||||||
```
|
|
||||||
|
|
||||||
Показывает: dataset, title, type, role, useRestriction, format, presentationExpression.
|
|
||||||
|
|
||||||
## links — связи наборов данных
|
|
||||||
|
|
||||||
```
|
|
||||||
=== Links (4) ===
|
|
||||||
|
|
||||||
РасчетНалогаНаИмущество -> СостояниеОС :
|
|
||||||
Организация -> Организация
|
|
||||||
ОсновноеСредство -> ОсновноеСредство
|
|
||||||
```
|
|
||||||
|
|
||||||
Группирует по парам наборов. Показывает поля связи и параметры.
|
|
||||||
|
|
||||||
## calculated — вычисляемые поля
|
|
||||||
|
|
||||||
Без `-Name` — карта: имена и заголовки:
|
|
||||||
```
|
|
||||||
=== Calculated fields (23) ===
|
|
||||||
ДоляСтоимости "Доля стоимости"
|
|
||||||
КоэффициентКи "Коэффициент Ки"
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
С `-Name <поле>` — полное выражение:
|
|
||||||
```
|
|
||||||
=== Calculated: ДоляСтоимости ===
|
|
||||||
|
|
||||||
Expression:
|
|
||||||
ВЫБОР КОГДА ... ТОГДА "1" ИНАЧЕ ... КОНЕЦ
|
|
||||||
Title: Доля стоимости
|
|
||||||
Restrict: condition
|
|
||||||
```
|
|
||||||
|
|
||||||
## resources — ресурсы (итоги по группировкам)
|
|
||||||
|
|
||||||
Без `-Name` — карта: имена полей, `*` = есть формулы по группировкам:
|
|
||||||
```
|
|
||||||
=== Resources (51) ===
|
|
||||||
НалоговаяБаза
|
|
||||||
КоэффициентКи *
|
|
||||||
...
|
|
||||||
* = has group-level formulas
|
|
||||||
```
|
|
||||||
|
|
||||||
С `-Name <поле>` — формулы агрегации:
|
|
||||||
```
|
|
||||||
=== Resource: ДатаСостояния ===
|
|
||||||
|
|
||||||
[ОсновноеСредство] ЕстьNull(ДатаСостояния, "")
|
|
||||||
```
|
|
||||||
|
|
||||||
## params — параметры схемы
|
|
||||||
|
|
||||||
```
|
|
||||||
=== Parameters (16) ===
|
|
||||||
Name Type Default Visible Expression
|
|
||||||
Период StandardPeriod LastMonth yes -
|
|
||||||
НачалоПериода DateTime - hidden &Период.ДатаНачала
|
|
||||||
Организация CatalogRef.Организации null yes -
|
|
||||||
```
|
|
||||||
|
|
||||||
## variant — варианты отчёта
|
|
||||||
|
|
||||||
Без `-Name` — список вариантов:
|
|
||||||
```
|
|
||||||
=== Variants (2) ===
|
|
||||||
[1] НоменклатураИЦены "Номенклатура и цены" Table(detail) 3 filters
|
|
||||||
[2] НоменклатураБезЦен "Номенклатура без цен" Group(detail) 2 filters
|
|
||||||
```
|
|
||||||
|
|
||||||
С `-Name <N|имя>` — структура конкретного варианта:
|
|
||||||
```
|
|
||||||
=== Variant [1]: НоменклатураИЦены "Номенклатура и цены" ===
|
|
||||||
|
|
||||||
Structure:
|
|
||||||
Table "Таблица"
|
|
||||||
├── Columns: [ТипЦен Items]
|
|
||||||
│ Selection: Auto, Цена
|
|
||||||
└── Rows: [Номенклатура Items]
|
|
||||||
Selection: Номенклатура, УИД, Auto
|
|
||||||
|
|
||||||
Filter:
|
|
||||||
[ ] Номенклатура InHierarchy [user]
|
|
||||||
[ ] ТипЦен Equal
|
|
||||||
[x] ВАрхиве = false "Исключая скрытые товары"
|
|
||||||
|
|
||||||
DataParams: КлючВарианта="НоменклатураИЦены"
|
|
||||||
Output: style=ЧерноБелый groups=Separately totalsH=None totalsV=None
|
|
||||||
```
|
|
||||||
|
|
||||||
## templates — привязки шаблонов вывода
|
|
||||||
|
|
||||||
Три типа привязок: `fieldTemplate` (к полю), `groupTemplate` (к группировке, Header/Footer), `groupHeaderTemplate` (заголовок группы).
|
|
||||||
|
|
||||||
Без `-Name` — карта привязок:
|
|
||||||
```
|
|
||||||
=== Templates (70 defined: 49 field, 37 group) ===
|
|
||||||
|
|
||||||
Field bindings (49): (all trivial)
|
|
||||||
ОстаточнаяСтоимостьНа0101, ОстаточнаяСтоимостьНа0102, ...
|
|
||||||
|
|
||||||
Group bindings (37):
|
|
||||||
ВидНалоговойБазы
|
|
||||||
Header -> Макет3 (1 rows, 1 params)
|
|
||||||
СреднегодоваяСтоимость2019
|
|
||||||
Footer -> Макет50 (1 rows) spacer
|
|
||||||
GroupHeader -> Макет40 (3 rows)
|
|
||||||
```
|
|
||||||
|
|
||||||
С `-Name <группировка|поле>` — содержимое шаблонов:
|
|
||||||
```
|
|
||||||
=== Templates: СреднегодоваяСтоимость2019 ===
|
|
||||||
|
|
||||||
Footer -> Макет50 [1 rows, 1 cells]:
|
|
||||||
Row 1: (empty)
|
|
||||||
|
|
||||||
GroupHeader -> Макет40 [3 rows, 78 cells]:
|
|
||||||
Row 1: "№ п/п" | "###Группировки1###" | "Инв. номер" | ...
|
|
||||||
Row 2: "01.01" | "01.02" | ... | "31.12"
|
|
||||||
Row 3: "1" | "2" | ... | "26"
|
|
||||||
```
|
|
||||||
|
|
||||||
Для field-привязок:
|
|
||||||
```
|
|
||||||
=== Field template: ОстаточнаяСтоимостьНа0101 -> Макет4 ===
|
|
||||||
[1 rows, 1 cells]
|
|
||||||
Row 1: {ОстаточнаяСтоимостьНа0101}
|
|
||||||
(all params trivial)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Тривиальность выражений**: `Поле = Поле` и `Поле = Представление(Поле)` считаются тривиальными и НЕ выводятся. Показываются только нетривиальные — когда выражение содержит другое поле, вызов метода, пустую строку и т.д.
|
|
||||||
|
|
||||||
## trace — трассировка поля от заголовка до запроса
|
|
||||||
|
|
||||||
Ищет поле по dataPath ИЛИ заголовку (включая подстроку) и показывает полную цепочку происхождения за один вызов:
|
|
||||||
|
|
||||||
```
|
|
||||||
=== Trace: КоэффициентКи "Коэффициент Ки" ===
|
|
||||||
|
|
||||||
Dataset: (schema-level only, not in dataset fields)
|
|
||||||
|
|
||||||
Calculated:
|
|
||||||
ВЫБОР КОГДА ... ТОГДА 0 ИНАЧЕ ... КОНЕЦ
|
|
||||||
Operands:
|
|
||||||
КоличествоМесяцевИспользования -> РасчетНалогаНаИмущество [Query]
|
|
||||||
КоличествоМесяцевВладения -> РасчетНалогаНаИмущество [Query]
|
|
||||||
|
|
||||||
Resource:
|
|
||||||
[ОсновноеСредство] Сумма(КоэффициентКи)
|
|
||||||
```
|
|
||||||
|
|
||||||
Типичный сценарий: пользователь видит колонку "Коэффициент Ки" в отчёте и спрашивает как она считается. Один вызов `trace` показывает: формулу вычисления, откуда берутся операнды, как агрегируется в ресурс.
|
|
||||||
|
|
||||||
## Что не выводится
|
|
||||||
|
|
||||||
- XML namespace-декларации
|
|
||||||
- Обёртки v8:item/v8:lang/v8:content (извлекаем чистый текст)
|
|
||||||
- userSettingID (GUID-ы пользовательских настроек)
|
|
||||||
- Дефолтные periodAdditionBegin/End = 0001-01-01
|
|
||||||
- viewMode
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# skd-info v1.3 — Analyze 1C DCS structure
|
# skd-info v1.6 — Analyze 1C DCS structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
@@ -10,7 +10,8 @@ param(
|
|||||||
[int]$Batch = 0,
|
[int]$Batch = 0,
|
||||||
[int]$Limit = 150,
|
[int]$Limit = 150,
|
||||||
[int]$Offset = 0,
|
[int]$Offset = 0,
|
||||||
[string]$OutFile
|
[string]$OutFile,
|
||||||
|
[switch]$Raw
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -655,6 +656,13 @@ function Show-Query {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$rawQuery = Unescape-Xml $queryNode.InnerText
|
$rawQuery = Unescape-Xml $queryNode.InnerText
|
||||||
|
|
||||||
|
# Raw mode: emit verbatim query text only (no headers/TOC/batch split) for round-trip
|
||||||
|
if ($Raw) {
|
||||||
|
foreach ($ql in ($rawQuery.Trim() -split "`n")) { $lines.Add($ql.TrimEnd()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
$dsNameStr = $targetDs.SelectSingleNode("s:name", $ns).InnerText
|
$dsNameStr = $targetDs.SelectSingleNode("s:name", $ns).InnerText
|
||||||
|
|
||||||
# Split into batches
|
# Split into batches
|
||||||
@@ -824,8 +832,14 @@ function Show-Fields {
|
|||||||
$roleParts = @()
|
$roleParts = @()
|
||||||
if ($role) {
|
if ($role) {
|
||||||
foreach ($child in $role.ChildNodes) {
|
foreach ($child in $role.ChildNodes) {
|
||||||
if ($child.NodeType -eq "Element" -and $child.InnerText -eq "true") {
|
if ($child.NodeType -ne "Element") { continue }
|
||||||
|
$txt = $child.InnerText.Trim()
|
||||||
|
if ($txt -eq "true") {
|
||||||
$roleParts += $child.LocalName
|
$roleParts += $child.LocalName
|
||||||
|
} elseif ($txt -eq "false") {
|
||||||
|
# skip default-false flags
|
||||||
|
} else {
|
||||||
|
$roleParts += "$($child.LocalName)=$txt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1869,7 +1883,12 @@ $totalLines = $result.Count
|
|||||||
# OutFile
|
# OutFile
|
||||||
if ($OutFile) {
|
if ($OutFile) {
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllLines((Join-Path (Get-Location) $OutFile), $result, $utf8Bom)
|
if ([System.IO.Path]::IsPathRooted($OutFile)) {
|
||||||
|
$outPath = [System.IO.Path]::GetFullPath($OutFile)
|
||||||
|
} else {
|
||||||
|
$outPath = [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $OutFile))
|
||||||
|
}
|
||||||
|
[System.IO.File]::WriteAllLines($outPath, $result, $utf8Bom)
|
||||||
Write-Host "Written $totalLines lines to $OutFile"
|
Write-Host "Written $totalLines lines to $OutFile"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -1883,7 +1902,7 @@ if ($Offset -gt 0) {
|
|||||||
$result = $result[$Offset..($totalLines - 1)]
|
$result = $result[$Offset..($totalLines - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result.Count -gt $Limit) {
|
if (-not $Raw -and $result.Count -gt $Limit) {
|
||||||
$shown = $result[0..($Limit - 1)]
|
$shown = $result[0..($Limit - 1)]
|
||||||
foreach ($l in $shown) { Write-Host $l }
|
foreach ($l in $shown) { Write-Host $l }
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# skd-info v1.3 — Analyze 1C DCS structure
|
# skd-info v1.6 — Analyze 1C DCS structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -278,6 +278,7 @@ def main():
|
|||||||
parser.add_argument("-Limit", type=int, default=150)
|
parser.add_argument("-Limit", type=int, default=150)
|
||||||
parser.add_argument("-Offset", type=int, default=0)
|
parser.add_argument("-Offset", type=int, default=0)
|
||||||
parser.add_argument("-OutFile", default=None)
|
parser.add_argument("-OutFile", default=None)
|
||||||
|
parser.add_argument("-Raw", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# --- Resolve path ---
|
# --- Resolve path ---
|
||||||
@@ -634,6 +635,13 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
raw_query = unescape_xml("".join(query_node.itertext()))
|
raw_query = unescape_xml("".join(query_node.itertext()))
|
||||||
|
|
||||||
|
# Raw mode: emit verbatim query text only (no headers/TOC/batch split) for round-trip
|
||||||
|
if args.Raw:
|
||||||
|
for ql in raw_query.strip().split("\n"):
|
||||||
|
lines.append(ql.rstrip())
|
||||||
|
return
|
||||||
|
|
||||||
ds_name_str = (target_ds.find("s:name", NSMAP).text or "")
|
ds_name_str = (target_ds.find("s:name", NSMAP).text or "")
|
||||||
|
|
||||||
# Split into batches
|
# Split into batches
|
||||||
@@ -777,8 +785,15 @@ def main():
|
|||||||
role_parts = []
|
role_parts = []
|
||||||
if role is not None:
|
if role is not None:
|
||||||
for child in role:
|
for child in role:
|
||||||
if isinstance(child.tag, str) and (child.text or "").strip() == "true":
|
if not isinstance(child.tag, str):
|
||||||
|
continue
|
||||||
|
txt = (child.text or "").strip()
|
||||||
|
if txt == "true":
|
||||||
role_parts.append(localname(child))
|
role_parts.append(localname(child))
|
||||||
|
elif txt == "false":
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
role_parts.append(f"{localname(child)}={txt}")
|
||||||
info["role"] = ", ".join(role_parts)
|
info["role"] = ", ".join(role_parts)
|
||||||
|
|
||||||
# UseRestriction
|
# UseRestriction
|
||||||
@@ -1712,7 +1727,7 @@ def main():
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
result = result[args.Offset:]
|
result = result[args.Offset:]
|
||||||
|
|
||||||
if len(result) > args.Limit:
|
if not args.Raw and len(result) > args.Limit:
|
||||||
shown = result[:args.Limit]
|
shown = result[:args.Limit]
|
||||||
for line in shown:
|
for line in shown:
|
||||||
print(line)
|
print(line)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# skd-validate v1.1 — Validate 1C DCS structure
|
# skd-validate v1.2 — Validate 1C DCS structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -438,6 +438,17 @@ if ($script:stopped) { & $finalize; exit 1 }
|
|||||||
if ($calcFieldNodes.Count -gt 0) {
|
if ($calcFieldNodes.Count -gt 0) {
|
||||||
$cfOk = $true
|
$cfOk = $true
|
||||||
$cfSeen = @{}
|
$cfSeen = @{}
|
||||||
|
# Collect totalField dataPaths — an empty calculatedField is legitimate if a
|
||||||
|
# totalField with the same dataPath provides the expression (real-world
|
||||||
|
# pattern in vendor ERP/БП reports for fields visible only in totals).
|
||||||
|
$tfPaths = @{}
|
||||||
|
foreach ($tf in $totalFieldNodes) {
|
||||||
|
$tfDp = $tf.SelectSingleNode("s:dataPath", $ns)
|
||||||
|
if ($tfDp -and $tfDp.InnerText) {
|
||||||
|
$tfPaths[$tfDp.InnerText] = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($cf in $calcFieldNodes) {
|
foreach ($cf in $calcFieldNodes) {
|
||||||
$dp = $cf.SelectSingleNode("s:dataPath", $ns)
|
$dp = $cf.SelectSingleNode("s:dataPath", $ns)
|
||||||
$expr = $cf.SelectSingleNode("s:expression", $ns)
|
$expr = $cf.SelectSingleNode("s:expression", $ns)
|
||||||
@@ -457,8 +468,15 @@ if ($calcFieldNodes.Count -gt 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $expr -or -not $expr.InnerText.Trim()) {
|
if (-not $expr -or -not $expr.InnerText.Trim()) {
|
||||||
Report-Error "CalculatedField '$path' has empty expression"
|
# Empty expression is legitimate in several vendor patterns:
|
||||||
$cfOk = $false
|
# - totalField with same dataPath provides the calculation
|
||||||
|
# - groupTemplate uses the field as group name (declarative only)
|
||||||
|
# - field is referenced only by settingsVariants for grouping
|
||||||
|
# Surface as warning, not error, to avoid false positives on real
|
||||||
|
# ERP/БП reports while still flagging the unusual shape.
|
||||||
|
if (-not $tfPaths.ContainsKey($path)) {
|
||||||
|
Report-Warn "CalculatedField '$path' has empty expression (declarative-only?)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Warn if collides with a dataset field
|
# Warn if collides with a dataset field
|
||||||
@@ -542,14 +560,16 @@ if ($templateNodes.Count -gt 0) {
|
|||||||
}
|
}
|
||||||
$tName = $nameNode.InnerText
|
$tName = $nameNode.InnerText
|
||||||
if ($tplSeen.ContainsKey($tName)) {
|
if ($tplSeen.ContainsKey($tName)) {
|
||||||
Report-Error "Duplicate template name: $tName"
|
# Vendor configs (ERP/БП) ship templates with repeating names — the
|
||||||
$tplOk = $false
|
# platform identifies them by position/context, not by <name>. Demote
|
||||||
|
# to warning so the check still surfaces the collision without failing.
|
||||||
|
Report-Warn "Duplicate template name: $tName (allowed by platform but ambiguous)"
|
||||||
} else {
|
} else {
|
||||||
$tplSeen[$tName] = $true
|
$tplSeen[$tName] = $true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($tplOk) {
|
if ($tplOk) {
|
||||||
Report-OK "$($templateNodes.Count) template(s): names unique"
|
Report-OK "$($templateNodes.Count) template(s) found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +601,8 @@ if ($script:stopped) { & $finalize; exit 1 }
|
|||||||
|
|
||||||
$validComparisonTypes = @(
|
$validComparisonTypes = @(
|
||||||
"Equal","NotEqual","Greater","GreaterOrEqual","Less","LessOrEqual",
|
"Equal","NotEqual","Greater","GreaterOrEqual","Less","LessOrEqual",
|
||||||
"InList","NotInList","InHierarchy","InListByHierarchy",
|
"InList","NotInList","InHierarchy","NotInHierarchy",
|
||||||
|
"InListByHierarchy","NotInListByHierarchy",
|
||||||
"Contains","NotContains","BeginsWith","NotBeginsWith",
|
"Contains","NotContains","BeginsWith","NotBeginsWith",
|
||||||
"Filled","NotFilled"
|
"Filled","NotFilled"
|
||||||
)
|
)
|
||||||
@@ -734,6 +755,176 @@ if ($variantNodes.Count -eq 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- 16. valueType structural checks ---
|
||||||
|
# Catches broken XDTO that XML/structural checks miss (decimal without xs:,
|
||||||
|
# missing qualifiers, mismatched qualifier blocks, unknown sign/length tokens).
|
||||||
|
|
||||||
|
$validTypeQualifier = @{
|
||||||
|
'xs:decimal' = 'v8:NumberQualifiers'
|
||||||
|
'xs:string' = 'v8:StringQualifiers'
|
||||||
|
'xs:dateTime' = 'v8:DateQualifiers'
|
||||||
|
'xs:boolean' = ''
|
||||||
|
'v8:StandardPeriod' = ''
|
||||||
|
'v8:UUID' = ''
|
||||||
|
'v8:Null' = ''
|
||||||
|
'v8:Type' = ''
|
||||||
|
'v8:ValueStorage' = ''
|
||||||
|
}
|
||||||
|
$validSign = @('Any', 'Nonnegative', 'Negative')
|
||||||
|
$validLength = @('Variable', 'Fixed')
|
||||||
|
$validFractions = @('Date', 'DateTime', 'Time')
|
||||||
|
|
||||||
|
# DCS supports composite types: multiple <v8:Type> blocks may share a single
|
||||||
|
# trailing qualifier block (e.g. xs:string + CatalogRef.X + StringQualifiers).
|
||||||
|
# So we collect all types and qualifiers per valueType, then check consistency.
|
||||||
|
$qualifierProducers = @{
|
||||||
|
'v8:NumberQualifiers' = 'xs:decimal'
|
||||||
|
'v8:StringQualifiers' = 'xs:string'
|
||||||
|
'v8:DateQualifiers' = 'xs:dateTime'
|
||||||
|
}
|
||||||
|
|
||||||
|
$valueTypeNodes = $root.SelectNodes("//s:valueType", $ns)
|
||||||
|
$vtChecked = 0
|
||||||
|
$vtOk = $true
|
||||||
|
foreach ($vt in $valueTypeNodes) {
|
||||||
|
$vtChecked++
|
||||||
|
$types = @() # list of short type strings; '' marks a ref type
|
||||||
|
$qualifiers = @() # list of @{ name = 'v8:XQualifiers'; node = $child }
|
||||||
|
|
||||||
|
foreach ($child in $vt.ChildNodes) {
|
||||||
|
if ($child.NodeType -ne 'Element') { continue }
|
||||||
|
if ($child.NamespaceURI -ne 'http://v8.1c.ru/8.1/data/core') { continue }
|
||||||
|
$localName = $child.LocalName
|
||||||
|
|
||||||
|
if ($localName -eq 'Type') {
|
||||||
|
$t = "$($child.InnerText)".Trim()
|
||||||
|
if (-not $t) {
|
||||||
|
Report-Error "valueType: <v8:Type> is empty"
|
||||||
|
$vtOk = $false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($t -match '^([A-Za-z][A-Za-z0-9]*):(.+)$') {
|
||||||
|
$prefix = $Matches[1]
|
||||||
|
$localT = $Matches[2]
|
||||||
|
if ($prefix -eq 'xs' -or $prefix -eq 'v8') {
|
||||||
|
if (-not $validTypeQualifier.ContainsKey($t)) {
|
||||||
|
Report-Error "valueType: unknown type '$t' (allowed: xs:decimal/xs:string/xs:dateTime/xs:boolean/v8:StandardPeriod or <prefix>:*Ref.X)"
|
||||||
|
$vtOk = $false
|
||||||
|
} else {
|
||||||
|
$types += $t
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$prefixNs = $child.GetNamespaceOfPrefix($prefix)
|
||||||
|
if ($prefixNs -eq 'http://v8.1c.ru/8.1/data/enterprise/current-config') {
|
||||||
|
if (-not ($localT -match '^[A-Za-z]+(Ref)?\.')) {
|
||||||
|
Report-Error "valueType: ref type '$t' must look like '<prefix>:<Kind>.<Name>' (e.g. d5p1:CatalogRef.X)"
|
||||||
|
$vtOk = $false
|
||||||
|
} else {
|
||||||
|
$types += '' # ref — no qualifier needed
|
||||||
|
}
|
||||||
|
} elseif ($prefixNs -eq 'http://v8.1c.ru/8.1/data/enterprise') {
|
||||||
|
# System types: AccumulationRecordType etc. — no qualifiers
|
||||||
|
if (-not ($localT -match '^[A-Za-z][A-Za-z0-9]*$')) {
|
||||||
|
Report-Error "valueType: system type '$t' has unexpected local-name shape"
|
||||||
|
$vtOk = $false
|
||||||
|
} else {
|
||||||
|
$types += ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Report-Error "valueType: type '$t' uses prefix '$prefix' bound to unexpected namespace '$prefixNs'"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Report-Error "valueType: type '$t' has no namespace prefix (expected xs:/v8:/d5p1: — e.g. xs:decimal not decimal)"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
} elseif ($localName -match 'Qualifiers$') {
|
||||||
|
$qName = "v8:$localName"
|
||||||
|
$qualifiers += @{ name = $qName; node = $child }
|
||||||
|
# Validate qualifier internals
|
||||||
|
if ($qName -eq 'v8:NumberQualifiers') {
|
||||||
|
$digits = $child.SelectSingleNode("v8:Digits", $ns)
|
||||||
|
$frac = $child.SelectSingleNode("v8:FractionDigits", $ns)
|
||||||
|
$sign = $child.SelectSingleNode("v8:AllowedSign", $ns)
|
||||||
|
if (-not $digits -or -not ($digits.InnerText -match '^\d+$')) {
|
||||||
|
Report-Error "v8:NumberQualifiers: <v8:Digits> missing or not a non-negative integer"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
if (-not $frac -or -not ($frac.InnerText -match '^\d+$')) {
|
||||||
|
Report-Error "v8:NumberQualifiers: <v8:FractionDigits> missing or not a non-negative integer"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
if ($sign -and $sign.InnerText -and $sign.InnerText -notin $validSign) {
|
||||||
|
Report-Error "v8:NumberQualifiers: <v8:AllowedSign>$($sign.InnerText)</v8:AllowedSign> — must be one of: $($validSign -join ', ')"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
} elseif ($qName -eq 'v8:StringQualifiers') {
|
||||||
|
$len = $child.SelectSingleNode("v8:Length", $ns)
|
||||||
|
$al = $child.SelectSingleNode("v8:AllowedLength", $ns)
|
||||||
|
if (-not $len -or -not ($len.InnerText -match '^\d+$')) {
|
||||||
|
Report-Error "v8:StringQualifiers: <v8:Length> missing or not a non-negative integer"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
if ($al -and $al.InnerText -and $al.InnerText -notin $validLength) {
|
||||||
|
Report-Error "v8:StringQualifiers: <v8:AllowedLength>$($al.InnerText)</v8:AllowedLength> — must be one of: $($validLength -join ', ')"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
} elseif ($qName -eq 'v8:DateQualifiers') {
|
||||||
|
$df = $child.SelectSingleNode("v8:DateFractions", $ns)
|
||||||
|
if ($df -and $df.InnerText -and $df.InnerText -notin $validFractions) {
|
||||||
|
Report-Error "v8:DateQualifiers: <v8:DateFractions>$($df.InnerText)</v8:DateFractions> — must be one of: $($validFractions -join ', ')"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cross-check: every qualifier must have a matching scalar type in this valueType
|
||||||
|
foreach ($q in $qualifiers) {
|
||||||
|
$producer = $qualifierProducers[$q.name]
|
||||||
|
if (-not $producer) { continue }
|
||||||
|
if ($types -notcontains $producer) {
|
||||||
|
Report-Error "valueType: <$($q.name)> has no matching <v8:Type>$producer</v8:Type> in this valueType"
|
||||||
|
$vtOk = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($vtChecked -gt 0 -and $vtOk) {
|
||||||
|
Report-OK "$vtChecked valueType block(s): structure and qualifiers OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
|
|
||||||
|
# --- 17. value content checks ---
|
||||||
|
# Catches literal placeholders ("_") and empty strings in DesignTimeValue refs
|
||||||
|
# that XDTO would reject at db-load-xml.
|
||||||
|
|
||||||
|
$valueNodes = @()
|
||||||
|
$valueNodes += @($root.SelectNodes("//s:value[@xsi:type]", $ns))
|
||||||
|
$valueNodes += @($root.SelectNodes("//dcscor:value[@xsi:type]", $ns))
|
||||||
|
$vChecked = 0
|
||||||
|
$vOk = $true
|
||||||
|
foreach ($vn in $valueNodes) {
|
||||||
|
if (-not $vn) { continue }
|
||||||
|
$vChecked++
|
||||||
|
$xsiType = $vn.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||||
|
$text = $vn.InnerText
|
||||||
|
if ($xsiType -eq 'dcscor:DesignTimeValue') {
|
||||||
|
if (-not $text -or $text.Trim() -eq '' -or $text.Trim() -eq '_') {
|
||||||
|
Report-Error "<value xsi:type=`"dcscor:DesignTimeValue`">$text</value> — DesignTimeValue must be a reference path (e.g. Перечисление.X.Y), not '$text'"
|
||||||
|
$vOk = $false
|
||||||
|
} elseif (-not ($text -match '^[A-Za-zА-Яа-яЁё]+\.[A-Za-zА-Яа-яЁё0-9_]+')) {
|
||||||
|
Report-Warn "<value xsi:type=`"dcscor:DesignTimeValue`">$text</value> — doesn't look like a typical ref path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($vChecked -gt 0 -and $vOk) {
|
||||||
|
Report-OK "$vChecked <value> element(s) with xsi:type: content OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
|
|
||||||
# --- Final output ---
|
# --- Final output ---
|
||||||
|
|
||||||
& $finalize
|
& $finalize
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# skd-validate v1.1 — Validate 1C DCS structure (Python port)
|
# skd-validate v1.2 — Validate 1C DCS structure (Python port)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
@@ -434,6 +434,15 @@ if stopped:
|
|||||||
if len(calc_field_nodes) > 0:
|
if len(calc_field_nodes) > 0:
|
||||||
cf_ok = True
|
cf_ok = True
|
||||||
cf_seen = {}
|
cf_seen = {}
|
||||||
|
# Collect totalField dataPaths — an empty calculatedField is legitimate if a
|
||||||
|
# totalField with the same dataPath provides the expression (real-world
|
||||||
|
# pattern in vendor ERP/БП reports for fields visible only in totals).
|
||||||
|
tf_paths = set()
|
||||||
|
for tf in total_field_nodes:
|
||||||
|
tf_dp = find(tf, "s:dataPath")
|
||||||
|
if tf_dp is not None and inner_text(tf_dp):
|
||||||
|
tf_paths.add(inner_text(tf_dp))
|
||||||
|
|
||||||
for cf in calc_field_nodes:
|
for cf in calc_field_nodes:
|
||||||
dp = find(cf, "s:dataPath")
|
dp = find(cf, "s:dataPath")
|
||||||
expr = find(cf, "s:expression")
|
expr = find(cf, "s:expression")
|
||||||
@@ -451,8 +460,14 @@ if len(calc_field_nodes) > 0:
|
|||||||
cf_seen[path] = True
|
cf_seen[path] = True
|
||||||
|
|
||||||
if expr is None or not text_of(expr):
|
if expr is None or not text_of(expr):
|
||||||
report_error(f"CalculatedField '{path}' has empty expression")
|
# Empty expression is legitimate in several vendor patterns:
|
||||||
cf_ok = False
|
# - totalField with same dataPath provides the calculation
|
||||||
|
# - groupTemplate uses the field as group name (declarative only)
|
||||||
|
# - field is referenced only by settingsVariants for grouping
|
||||||
|
# Surface as warning, not error, to avoid false positives on real
|
||||||
|
# ERP/БП reports while still flagging the unusual shape.
|
||||||
|
if path not in tf_paths:
|
||||||
|
report_warn(f"CalculatedField '{path}' has empty expression (declarative-only?)")
|
||||||
|
|
||||||
# Warn if collides with a dataset field
|
# Warn if collides with a dataset field
|
||||||
if path in all_field_paths:
|
if path in all_field_paths:
|
||||||
@@ -526,12 +541,14 @@ if len(template_nodes) > 0:
|
|||||||
continue
|
continue
|
||||||
t_name = inner_text(name_node)
|
t_name = inner_text(name_node)
|
||||||
if t_name in tpl_seen:
|
if t_name in tpl_seen:
|
||||||
report_error(f"Duplicate template name: {t_name}")
|
# Vendor configs (ERP/БП) ship templates with repeating names — the
|
||||||
tpl_ok = False
|
# platform identifies them by position/context, not by <name>. Demote
|
||||||
|
# to warning so the check still surfaces the collision without failing.
|
||||||
|
report_warn(f"Duplicate template name: {t_name} (allowed by platform but ambiguous)")
|
||||||
else:
|
else:
|
||||||
tpl_seen[t_name] = True
|
tpl_seen[t_name] = True
|
||||||
if tpl_ok:
|
if tpl_ok:
|
||||||
report_ok(f"{len(template_nodes)} template(s): names unique")
|
report_ok(f"{len(template_nodes)} template(s) found")
|
||||||
|
|
||||||
# ── 13. GroupTemplate checks ─────────────────────────────────
|
# ── 13. GroupTemplate checks ─────────────────────────────────
|
||||||
|
|
||||||
@@ -558,7 +575,8 @@ if stopped:
|
|||||||
|
|
||||||
valid_comparison_types = (
|
valid_comparison_types = (
|
||||||
"Equal", "NotEqual", "Greater", "GreaterOrEqual", "Less", "LessOrEqual",
|
"Equal", "NotEqual", "Greater", "GreaterOrEqual", "Less", "LessOrEqual",
|
||||||
"InList", "NotInList", "InHierarchy", "InListByHierarchy",
|
"InList", "NotInList", "InHierarchy", "NotInHierarchy",
|
||||||
|
"InListByHierarchy", "NotInListByHierarchy",
|
||||||
"Contains", "NotContains", "BeginsWith", "NotBeginsWith",
|
"Contains", "NotContains", "BeginsWith", "NotBeginsWith",
|
||||||
"Filled", "NotFilled",
|
"Filled", "NotFilled",
|
||||||
)
|
)
|
||||||
@@ -685,6 +703,166 @@ else:
|
|||||||
if v_ok:
|
if v_ok:
|
||||||
report_ok(f"{len(variant_nodes)} settingsVariant(s) found")
|
report_ok(f"{len(variant_nodes)} settingsVariant(s) found")
|
||||||
|
|
||||||
|
# ── 16. valueType structural checks ───────────────────────────
|
||||||
|
# Catches broken XDTO that XML/structural checks miss (decimal without xs:,
|
||||||
|
# missing qualifiers, mismatched qualifier blocks, unknown sign/length tokens).
|
||||||
|
|
||||||
|
import re as _re_vt
|
||||||
|
|
||||||
|
_VALID_TYPE_QUALIFIER = {
|
||||||
|
'xs:decimal': 'v8:NumberQualifiers',
|
||||||
|
'xs:string': 'v8:StringQualifiers',
|
||||||
|
'xs:dateTime': 'v8:DateQualifiers',
|
||||||
|
'xs:boolean': '',
|
||||||
|
'v8:StandardPeriod': '',
|
||||||
|
'v8:UUID': '',
|
||||||
|
'v8:Null': '',
|
||||||
|
'v8:Type': '',
|
||||||
|
'v8:ValueStorage': '',
|
||||||
|
}
|
||||||
|
_VALID_SIGN = ('Any', 'Nonnegative', 'Negative')
|
||||||
|
_VALID_LENGTH = ('Variable', 'Fixed')
|
||||||
|
_VALID_FRACTIONS = ('Date', 'DateTime', 'Time')
|
||||||
|
_V8_NS_URI = 'http://v8.1c.ru/8.1/data/core'
|
||||||
|
_CONFIG_NS_URI = 'http://v8.1c.ru/8.1/data/enterprise/current-config'
|
||||||
|
|
||||||
|
# DCS supports composite types: multiple <v8:Type> blocks may share a single
|
||||||
|
# trailing qualifier block (e.g. xs:string + CatalogRef.X + StringQualifiers).
|
||||||
|
# So we collect all types and qualifiers per valueType, then check consistency.
|
||||||
|
_QUALIFIER_PRODUCERS = {
|
||||||
|
'v8:NumberQualifiers': 'xs:decimal',
|
||||||
|
'v8:StringQualifiers': 'xs:string',
|
||||||
|
'v8:DateQualifiers': 'xs:dateTime',
|
||||||
|
}
|
||||||
|
|
||||||
|
vt_nodes = find_all(root, "//s:valueType")
|
||||||
|
vt_checked = 0
|
||||||
|
vt_ok = True
|
||||||
|
for vt in vt_nodes:
|
||||||
|
vt_checked += 1
|
||||||
|
types = [] # short type strings; '' marks a ref type
|
||||||
|
qualifiers = [] # list of (qName, node)
|
||||||
|
|
||||||
|
for child in vt:
|
||||||
|
if not isinstance(child.tag, str):
|
||||||
|
continue
|
||||||
|
qn = etree.QName(child.tag)
|
||||||
|
if qn.namespace != _V8_NS_URI:
|
||||||
|
continue
|
||||||
|
local = qn.localname
|
||||||
|
|
||||||
|
if local == 'Type':
|
||||||
|
t = (child.text or '').strip()
|
||||||
|
if not t:
|
||||||
|
report_error("valueType: <v8:Type> is empty")
|
||||||
|
vt_ok = False
|
||||||
|
continue
|
||||||
|
m = _re_vt.match(r'^([A-Za-z][A-Za-z0-9]*):(.+)$', t)
|
||||||
|
if not m:
|
||||||
|
report_error(f"valueType: type '{t}' has no namespace prefix (expected xs:/v8:/d5p1: — e.g. xs:decimal not decimal)")
|
||||||
|
vt_ok = False
|
||||||
|
continue
|
||||||
|
prefix, local_t = m.group(1), m.group(2)
|
||||||
|
if prefix in ('xs', 'v8'):
|
||||||
|
if t not in _VALID_TYPE_QUALIFIER:
|
||||||
|
report_error(f"valueType: unknown type '{t}' (allowed: xs:decimal/xs:string/xs:dateTime/xs:boolean/v8:StandardPeriod or <prefix>:*Ref.X)")
|
||||||
|
vt_ok = False
|
||||||
|
else:
|
||||||
|
types.append(t)
|
||||||
|
else:
|
||||||
|
prefix_ns = child.nsmap.get(prefix)
|
||||||
|
if prefix_ns == _CONFIG_NS_URI:
|
||||||
|
if not _re_vt.match(r'^[A-Za-z]+(Ref)?\.', local_t):
|
||||||
|
report_error(f"valueType: ref type '{t}' must look like '<prefix>:<Kind>.<Name>' (e.g. d5p1:CatalogRef.X)")
|
||||||
|
vt_ok = False
|
||||||
|
else:
|
||||||
|
types.append('') # ref — no qualifier needed
|
||||||
|
elif prefix_ns == 'http://v8.1c.ru/8.1/data/enterprise':
|
||||||
|
# System types: AccumulationRecordType etc. — no qualifiers
|
||||||
|
if not _re_vt.match(r'^[A-Za-z][A-Za-z0-9]*$', local_t):
|
||||||
|
report_error(f"valueType: system type '{t}' has unexpected local-name shape")
|
||||||
|
vt_ok = False
|
||||||
|
else:
|
||||||
|
types.append('')
|
||||||
|
else:
|
||||||
|
report_error(f"valueType: type '{t}' uses prefix '{prefix}' bound to unexpected namespace '{prefix_ns}'")
|
||||||
|
vt_ok = False
|
||||||
|
|
||||||
|
elif local.endswith('Qualifiers'):
|
||||||
|
q_name = f"v8:{local}"
|
||||||
|
qualifiers.append((q_name, child))
|
||||||
|
if q_name == 'v8:NumberQualifiers':
|
||||||
|
digits = find(child, "v8:Digits")
|
||||||
|
frac = find(child, "v8:FractionDigits")
|
||||||
|
sign = find(child, "v8:AllowedSign")
|
||||||
|
if digits is None or not _re_vt.match(r'^\d+$', text_of(digits)):
|
||||||
|
report_error("v8:NumberQualifiers: <v8:Digits> missing or not a non-negative integer")
|
||||||
|
vt_ok = False
|
||||||
|
if frac is None or not _re_vt.match(r'^\d+$', text_of(frac)):
|
||||||
|
report_error("v8:NumberQualifiers: <v8:FractionDigits> missing or not a non-negative integer")
|
||||||
|
vt_ok = False
|
||||||
|
if sign is not None and text_of(sign) and text_of(sign) not in _VALID_SIGN:
|
||||||
|
report_error(f"v8:NumberQualifiers: <v8:AllowedSign>{text_of(sign)}</v8:AllowedSign> — must be one of: {', '.join(_VALID_SIGN)}")
|
||||||
|
vt_ok = False
|
||||||
|
elif q_name == 'v8:StringQualifiers':
|
||||||
|
length = find(child, "v8:Length")
|
||||||
|
al = find(child, "v8:AllowedLength")
|
||||||
|
if length is None or not _re_vt.match(r'^\d+$', text_of(length)):
|
||||||
|
report_error("v8:StringQualifiers: <v8:Length> missing or not a non-negative integer")
|
||||||
|
vt_ok = False
|
||||||
|
if al is not None and text_of(al) and text_of(al) not in _VALID_LENGTH:
|
||||||
|
report_error(f"v8:StringQualifiers: <v8:AllowedLength>{text_of(al)}</v8:AllowedLength> — must be one of: {', '.join(_VALID_LENGTH)}")
|
||||||
|
vt_ok = False
|
||||||
|
elif q_name == 'v8:DateQualifiers':
|
||||||
|
df = find(child, "v8:DateFractions")
|
||||||
|
if df is not None and text_of(df) and text_of(df) not in _VALID_FRACTIONS:
|
||||||
|
report_error(f"v8:DateQualifiers: <v8:DateFractions>{text_of(df)}</v8:DateFractions> — must be one of: {', '.join(_VALID_FRACTIONS)}")
|
||||||
|
vt_ok = False
|
||||||
|
|
||||||
|
# Cross-check: every qualifier must have a matching scalar type in this valueType
|
||||||
|
for q_name, _ in qualifiers:
|
||||||
|
producer = _QUALIFIER_PRODUCERS.get(q_name)
|
||||||
|
if not producer:
|
||||||
|
continue
|
||||||
|
if producer not in types:
|
||||||
|
report_error(f"valueType: <{q_name}> has no matching <v8:Type>{producer}</v8:Type> in this valueType")
|
||||||
|
vt_ok = False
|
||||||
|
|
||||||
|
if vt_checked > 0 and vt_ok:
|
||||||
|
report_ok(f"{vt_checked} valueType block(s): structure and qualifiers OK")
|
||||||
|
|
||||||
|
if stopped:
|
||||||
|
finalize()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── 17. value content checks ──────────────────────────────────
|
||||||
|
# Catches literal placeholders ('_') and empty strings in DesignTimeValue refs
|
||||||
|
# that XDTO would reject at db-load-xml.
|
||||||
|
|
||||||
|
value_nodes = find_all(root, "//s:value[@xsi:type]") + find_all(root, "//dcscor:value[@xsi:type]")
|
||||||
|
v_checked = 0
|
||||||
|
v_ok = True
|
||||||
|
for vn in value_nodes:
|
||||||
|
if vn is None:
|
||||||
|
continue
|
||||||
|
v_checked += 1
|
||||||
|
xsi_type = vn.get(XSI_TYPE) or ''
|
||||||
|
text = vn.text or ''
|
||||||
|
if xsi_type == 'dcscor:DesignTimeValue':
|
||||||
|
stripped = text.strip()
|
||||||
|
if not stripped or stripped == '_':
|
||||||
|
report_error(f"<value xsi:type=\"dcscor:DesignTimeValue\">{text}</value> — DesignTimeValue must be a reference path (e.g. Перечисление.X.Y), not '{text}'")
|
||||||
|
v_ok = False
|
||||||
|
elif not _re_vt.match(r'^[A-Za-zА-Яа-яЁё]+\.[A-Za-zА-Яа-яЁё0-9_]+', stripped):
|
||||||
|
report_warn(f"<value xsi:type=\"dcscor:DesignTimeValue\">{text}</value> — doesn't look like a typical ref path")
|
||||||
|
|
||||||
|
if v_checked > 0 and v_ok:
|
||||||
|
report_ok(f"{v_checked} <value> element(s) with xsi:type: content OK")
|
||||||
|
|
||||||
|
if stopped:
|
||||||
|
finalize()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# ── Final output ──────────────────────────────────────────────
|
# ── Final output ──────────────────────────────────────────────
|
||||||
|
|
||||||
finalize()
|
finalize()
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ SCRIPT
|
|||||||
# 2b. Execute without video recording (for debugging/testing)
|
# 2b. Execute without video recording (for debugging/testing)
|
||||||
cat script.js | node $RUN exec - --no-record
|
cat script.js | node $RUN exec - --no-record
|
||||||
|
|
||||||
|
# 2c. Override exec HTTP timeout (default 30 min). Use for long scripts
|
||||||
|
# such as multi-block recordings + addNarration.
|
||||||
|
cat script.js | node $RUN exec - --timeout-min=120
|
||||||
|
cat script.js | node $RUN exec - --timeout=7200000
|
||||||
|
WEB_TEST_EXEC_TIMEOUT_MS=7200000 node $RUN exec script.js
|
||||||
|
|
||||||
# 3. Screenshot
|
# 3. Screenshot
|
||||||
node $RUN shot result.png
|
node $RUN shot result.png
|
||||||
|
|
||||||
@@ -159,7 +165,7 @@ const form = await getFormState();
|
|||||||
|
|
||||||
### Reading data
|
### Reading data
|
||||||
|
|
||||||
#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset }`
|
#### `readTable({ maxRows?, offset?, table? })` → `{ columns, rows, total, shown, offset, hasMore }`
|
||||||
Read actual grid data with pagination. Each row is `{ columnName: value }`.
|
Read actual grid data with pagination. Each row is `{ columnName: value }`.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
@@ -168,6 +174,12 @@ Read actual grid data with pagination. Each row is `{ columnName: value }`.
|
|||||||
| `offset` | 0 | Skip first N rows |
|
| `offset` | 0 | Skip first N rows |
|
||||||
| `table` | — | Grid name from `tables[]` (for multi-grid forms) |
|
| `table` | — | Grid name from `tables[]` (for multi-grid forms) |
|
||||||
|
|
||||||
|
**Picture columns.** Cells that render an icon (status/stage marks, the "ЭДО" mark, the attached-files paperclip) read as `'pic:<N>'` (`N` = icon frame/state) when shown, `''` when absent — so presence is truthy and icons differ by index. Icon-only columns (no header text) still appear, named by their tooltip or `'(picture)'`. These values are read-only — filter/select rows by a text column, not by `'pic:N'`.
|
||||||
|
```js
|
||||||
|
if (t.rows[0]['Присоединенные файлы']) { /* has an attached file */ }
|
||||||
|
t.rows[0]['ЭДО'] === 'pic:1'; // connected to 1С-ЭДО ('pic:0' = not)
|
||||||
|
```
|
||||||
|
|
||||||
Special row fields:
|
Special row fields:
|
||||||
- `_kind: 'group'` — hierarchical group row
|
- `_kind: 'group'` — hierarchical group row
|
||||||
- `_kind: 'parent'` — parent row in hierarchy
|
- `_kind: 'parent'` — parent row in hierarchy
|
||||||
@@ -177,10 +189,22 @@ Special row fields:
|
|||||||
- `hierarchical: true` — list has groups (on result object)
|
- `hierarchical: true` — list has groups (on result object)
|
||||||
- `viewMode: 'tree'` — tree view active (on result object)
|
- `viewMode: 'tree'` — tree view active (on result object)
|
||||||
|
|
||||||
|
**`total` is misleading for long lists.** 1С virtualizes both dynamic lists and form tabular sections — the DOM holds only a window of visible rows. `total` / `shown` count what's *loaded right now*, not the size of the underlying collection. Use **`hasMore`** to know if there's more data outside the window:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const t = await readTable();
|
||||||
|
// t.hasMore = { above: false, below: true } ← form tabular section, scrollbar visible
|
||||||
|
// t.hasMore = { below: true } ← dynamic list (catalog/journal/register)
|
||||||
|
// t.hasMore = { below: false } ← everything visible / end of list reached
|
||||||
|
```
|
||||||
|
|
||||||
|
- `hasMore.below` — always present. `true` ⇒ scrolling down (PageDown / `clickElement` with `scroll:true`) will reveal more rows.
|
||||||
|
- `hasMore.above` — usually present too. Detected via the dynamic-list page-turn buttons (#vertButtonScroll) or the tabular-section scrollbar. Absent only for rare grids that have neither widget — treat absence as unknown.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const t = await readTable({ maxRows: 50 });
|
const t = await readTable({ maxRows: 50 });
|
||||||
console.log('Columns:', t.columns);
|
console.log('Columns:', t.columns);
|
||||||
console.log('Rows:', t.rows.length, 'of', t.total);
|
console.log('Loaded:', t.shown, 'rows; more below:', t.hasMore.below);
|
||||||
// Pagination:
|
// Pagination:
|
||||||
const page2 = await readTable({ maxRows: 50, offset: 50 });
|
const page2 = await readTable({ maxRows: 50, offset: 50 });
|
||||||
```
|
```
|
||||||
@@ -211,7 +235,9 @@ Sections + all open tabs.
|
|||||||
|
|
||||||
### Actions
|
### Actions
|
||||||
|
|
||||||
#### `clickElement(text, { dblclick?, table?, expand?, modifier? })` → form state
|
**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `focused`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`.
|
||||||
|
|
||||||
|
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
|
||||||
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
||||||
|
|
||||||
- `table` — scope button search to a specific grid's command panel (by name from `tables[]`):
|
- `table` — scope button search to a specific grid's command panel (by name from `tables[]`):
|
||||||
@@ -233,6 +259,11 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
|||||||
await clickElement('ИСУ ФХД'); // select row
|
await clickElement('ИСУ ФХД'); // select row
|
||||||
await clickElement('ИСУ ФХД', { expand: true }); // expand/collapse
|
await clickElement('ИСУ ФХД', { expand: true }); // expand/collapse
|
||||||
```
|
```
|
||||||
|
- **Focus a field** (last resort, when no `table` given): if `text` matches no clickable control but matches a form field's name/label, clicks the input to focus it **without changing its value**. Returns `focused: { field, id, ok }` (`ok: false` if the field couldn't take focus). Use it to drive focus-dependent keys:
|
||||||
|
```js
|
||||||
|
await clickElement('Контрагент'); // focus the reference field
|
||||||
|
await getPage().keyboard.press('F4'); // open its selection form
|
||||||
|
```
|
||||||
- **Multi-select rows** with `modifier: 'ctrl'` (add to selection) or `modifier: 'shift'` (select range):
|
- **Multi-select rows** with `modifier: 'ctrl'` (add to selection) or `modifier: 'shift'` (select range):
|
||||||
```js
|
```js
|
||||||
await clickElement('Номенклатура 1'); // select first row
|
await clickElement('Номенклатура 1'); // select first row
|
||||||
@@ -242,26 +273,32 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
|||||||
const t = await readTable();
|
const t = await readTable();
|
||||||
t.rows.filter(r => r._selected); // rows with _selected: true
|
t.rows.filter(r => r._selected); // rows with _selected: true
|
||||||
```
|
```
|
||||||
- **SpreadsheetDocument cells** (report drill-down): first argument can be `{ row, column }` object to click a cell in a rendered report. Coordinates match `readSpreadsheet()` output:
|
- **Cell click by (row, column)** — first argument as `{ row, column }`. Routes: spreadsheet on form → spreadsheet drill-down; otherwise → grid cell. Pass `table: 'GridName'` to force a specific grid when both are present.
|
||||||
|
|
||||||
|
Spreadsheet report drill-down:
|
||||||
```js
|
```js
|
||||||
const report = await readSpreadsheet();
|
const report = await readSpreadsheet();
|
||||||
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000', ... }
|
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000', ... }
|
||||||
|
await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); // by index
|
||||||
// By data row index + column header name
|
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); // by filter
|
||||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); // totals row
|
||||||
|
await clickElement('150 000', { dblclick: true }); // fallback: by text
|
||||||
// By cell value filter (fuzzy match)
|
|
||||||
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true });
|
|
||||||
|
|
||||||
// Totals row
|
|
||||||
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true });
|
|
||||||
```
|
```
|
||||||
Text search also works as fallback — searches inside spreadsheet iframes:
|
|
||||||
|
Form grid cell (catalog list, journal, table part). Off-viewport columns auto-scroll horizontally (works around frozen columns). Use `scroll: true | number` for filter-based rows outside the current DOM window:
|
||||||
```js
|
```js
|
||||||
await clickElement('150 000', { dblclick: true }); // finds cell by text in report
|
await clickElement({ row: 0, column: 'Количество' }, { table: 'Товары', dblclick: true });
|
||||||
|
await clickElement({ row: { 'Номенклатура': 'Бумага' }, column: 'Цена' }, { table: 'Товары' });
|
||||||
|
await clickElement({ row: { 'Номер': '0000-000601' }, column: 'Сумма' },
|
||||||
|
{ table: 'Реализации', scroll: true }); // PageDown loop, max 50
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `fillFields({ name: value })` → `{ filled, form }`
|
Gotchas:
|
||||||
|
- `row: <number>` is the index in the **current DOM window**, not absolute — 1С virtualizes long lists. `row: 0` is the topmost loaded row after any prior scroll. For arbitrary rows in a long list use `row: { col: val }` + `scroll: true`.
|
||||||
|
- `scroll: true` walks **down only** (PageDown). For going up first press `Home` via `getPage().keyboard` or narrow with `filterList`.
|
||||||
|
- First matching row wins on duplicate filter matches — refine the filter to disambiguate.
|
||||||
|
|
||||||
|
#### `fillFields({ name: value })` → form state with `filled`
|
||||||
Fill form fields by label (fuzzy match). Auto-detects field type.
|
Fill form fields by label (fuzzy match). Auto-detects field type.
|
||||||
|
|
||||||
| Value | Field type | Method |
|
| Value | Field type | Method |
|
||||||
@@ -280,8 +317,7 @@ await fillFields({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns `{ filled: [{ field, ok, value, method }], form: {...} }`.
|
Returns form state with `filled: [{ field, ok: true, value, method }]` (method: `clear`|`toggle`|`radio`|`paste`|`dropdown`|`form`|`typeahead`). **Throws on any per-field failure** with a detailed message listing problematic fields and available options — if the call returned, all fields were filled, no per-item check needed.
|
||||||
Method is one of: `'clear'` | `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | `'typeahead'`
|
|
||||||
|
|
||||||
#### `selectValue(field, search, opts?)` → form state with `selected`
|
#### `selectValue(field, search, opts?)` → form state with `selected`
|
||||||
Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. Pass empty `search` (`''` or `null`) to clear the field (Shift+F4).
|
Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. Pass empty `search` (`''` or `null`) to clear the field (Shift+F4).
|
||||||
@@ -304,14 +340,19 @@ await selectValue('Документ', '0000-000601', { type: 'Реализаци
|
|||||||
|
|
||||||
Also supports DCS labels — auto-enables the paired checkbox.
|
Also supports DCS labels — auto-enables the paired checkbox.
|
||||||
|
|
||||||
#### `fillTableRow(fields, opts)` → form state
|
#### `fillTableRow(fields, opts)` → form state with `filled` (+ optional `notFilled`)
|
||||||
Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4).
|
Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4).
|
||||||
|
|
||||||
|
Returns form state with `filled: [{ field, ok, ...}]`. Items are `{ field, ok: true, method, value }` on success (method: `direct`|`paste`|`dropdown`|`form`|`type-direct`|`skip`|`clear`|`toggle`) or `{ field, ok: false, error, message }` on per-field failure. Unmatched fields → `notFilled: [...]`.
|
||||||
|
|
||||||
|
**Unlike `fillFields`, `fillTableRow` does NOT throw on per-field failures** — errors appear as `ok: false` items in `filled[]` so the caller can react selectively (e.g. retry one cell while the rest of the row stays filled). Check via `r.filled.filter(f => !f.ok)`. Error codes: `composite_type`/`type_required`/`type_dialog_failed` (retry with `{value, type}`); `column_not_found` (check column name via `readTable`); `no_selection_form`/`no_selection_after_type` (retry or fall back to `selectValue`); `not_found`/`no_match`/`ambiguous` (refine search text); `still_open` (picked a group — pick a leaf row). Soft validation errors from 1C (`balloon`, `modal`) still throw via the exec-wrapper.
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `tab` | Switch to tab before filling |
|
| `tab` | Switch to tab before filling |
|
||||||
| `add` | Add new row before filling |
|
| `add` | Add new row before filling |
|
||||||
| `row` | Edit existing row by 0-based index |
|
| `row` | Edit existing row: 0-based index, **or** a `{ col: value }` filter (one or more columns) to locate the row by its cell values |
|
||||||
|
| `scroll` | With a `row` filter — scan beyond the current DOM window (`true` = up to 50 PageDowns, number = limit) |
|
||||||
| `table` | Grid name from `tables[]` (for multi-grid forms) |
|
| `table` | Grid name from `tables[]` (for multi-grid forms) |
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -320,11 +361,14 @@ await fillTableRow(
|
|||||||
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
|
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
|
||||||
{ tab: 'Товары', add: true }
|
{ tab: 'Товары', add: true }
|
||||||
);
|
);
|
||||||
// Edit existing row:
|
// Edit existing row by index:
|
||||||
await fillTableRow(
|
await fillTableRow(
|
||||||
{ 'Количество': '20' },
|
{ 'Количество': '20' },
|
||||||
{ tab: 'Товары', row: 0 }
|
{ tab: 'Товары', row: 0 }
|
||||||
);
|
);
|
||||||
|
// Edit existing row located by cell values (одна или несколько колонок):
|
||||||
|
await fillTableRow({ 'Цена': '120' }, { table: 'Товары', row: { 'Номенклатура': 'Бумага' } });
|
||||||
|
await fillTableRow({ 'Сумма': '500' }, { row: { 'Номер': '0000-000601', 'Дата': '29.12.2016' }, scroll: true });
|
||||||
// Multi-grid form — add row to specific table:
|
// Multi-grid form — add row to specific table:
|
||||||
await fillTableRow(
|
await fillTableRow(
|
||||||
{ 'Объект': 'БДДС' },
|
{ 'Объект': 'БДДС' },
|
||||||
@@ -525,7 +569,11 @@ On error (auto-screenshot taken):
|
|||||||
- **Headed mode** — 1C requires visible browser, no headless
|
- **Headed mode** — 1C requires visible browser, no headless
|
||||||
- **Startup time** — 1C loads 30-60s on initial connect (built into `start`)
|
- **Startup time** — 1C loads 30-60s on initial connect (built into `start`)
|
||||||
- **Fuzzy matching** — all name lookups: exact > startsWith > includes
|
- **Fuzzy matching** — all name lookups: exact > startsWith > includes
|
||||||
- **Clipboard paste** — all text fields filled via Ctrl+V (triggers 1C events properly)
|
- **Clipboard paste** — all text fields filled via Ctrl+V (triggers 1C events properly). The OS clipboard is automatically saved before each action and restored after, so a local user's clipboard survives a test run. Opt out with `--no-preserve-clipboard` (any command), `WEB_TEST_PRESERVE_CLIPBOARD=0` env, or `preserveClipboard: false` in `webtest.config.mjs`
|
||||||
- **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues
|
- **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues
|
||||||
- **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally
|
- **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally
|
||||||
- **Section panel display** — `navigateSection()` works with any panel position (side, top) but requires "Picture and text" or "Text" display mode. Icon-only mode is not supported — API cannot read section names from icons alone
|
- **Section panel display** — `navigateSection()` works with any panel position (side, top) but requires "Picture and text" or "Text" display mode. Icon-only mode is not supported — API cannot read section names from icons alone
|
||||||
|
|
||||||
|
## Regression suites
|
||||||
|
|
||||||
|
When the user asks to cover a 1C solution with automated regression — multi-file test suites with assertions, hooks, tags, retries, Allure/JUnit reports, multi-user process tests — switch to the `test` mode. See [regress.md](regress.md) for authoring discipline, recon flow (metadata + live walkthrough via `exec`), per-application folder layout, ready-to-paste templates, and failure triage. Default to ad-hoc `run`/`exec` for single-script automation — `test` is the specialised mode for project-wide coverage.
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
# Regression suite authoring
|
||||||
|
|
||||||
|
Use this when the user asks to cover a 1C solution with automated regression tests, build out a test suite, or run an existing suite and analyse failures. For ad-hoc single-script automation, stay with the `run`/`exec` modes from SKILL.md instead.
|
||||||
|
|
||||||
|
The runner is the same `run.mjs`. The mode is `test`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node $RUN test <dir|file>... [flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Positional args are test paths (files and/or dirs, multiple allowed). URL is NOT positional — it comes from `webtest.config.mjs`; override with `--url=<url>`.
|
||||||
|
|
||||||
|
Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
|
||||||
|
|
||||||
|
## When to choose `test` over `exec`
|
||||||
|
|
||||||
|
| Goal | Mode |
|
||||||
|
|------|------|
|
||||||
|
| Explore a form, prototype a single step, debug one selector | `exec` (interactive session) |
|
||||||
|
| Reproduce a bug as a failing test before fixing it | `test` |
|
||||||
|
| Cover a feature so future changes are checked automatically | `test` |
|
||||||
|
| Run the project's regression on a new build | `test` |
|
||||||
|
| Generate a screencast walkthrough | `exec` with `startRecording` |
|
||||||
|
|
||||||
|
Don't write a `.test.mjs` for a one-shot user request. Don't drive a regression suite through chained `exec` calls.
|
||||||
|
|
||||||
|
## Before writing tests — recon
|
||||||
|
|
||||||
|
Two layers, in order.
|
||||||
|
|
||||||
|
**1. Static recon — metadata.** Never invent identifiers. For every metadata object the user mentions, run the matching info skill first: `/meta-info` (attributes/tabular sections), `/form-info` (form layout), `/skd-info` (DCS), `/mxl-info` (templates), `/role-info` (rights), `/subsystem-info` (composition / command interface). If the user names objects you can't find — stop and ask.
|
||||||
|
|
||||||
|
**2. Live recon — interactive walkthrough.** For any non-trivial scenario, walk the path live in `exec` mode before transcribing it. Metadata tells you what exists; the live walkthrough tells you what actually happens. Capture from `getFormState()`: exact button names (`'Провести и закрыть'`, not `'Сохранить'`), table section names for multi-grid forms, required fields, places where a real async wait is needed. Then transcribe the working sequence into `*.test.mjs`, wrapping logical chunks in `step('...', async () => { ... })`.
|
||||||
|
|
||||||
|
The mechanics of `exec` / `getFormState` / `fillFields` / `clickElement` are in [SKILL.md](SKILL.md) — read it before recon if you haven't already.
|
||||||
|
|
||||||
|
When live recon is overkill: trivial reads (`navigateSection` + `readTable` + assert non-empty), or scenarios you've already proven once in this session. When it's essential: confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, user-customised forms.
|
||||||
|
|
||||||
|
## Suite layout
|
||||||
|
|
||||||
|
**Each application gets its own subfolder under `tests/`.** A single repo may host several independent suites side by side — they must not share `_hooks.mjs` or `webtest.config.mjs`, because each suite restores a different DB, publishes to a different URL, and ships its own test data.
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
<app-name>/ # application regression — one per solution
|
||||||
|
_hooks.mjs
|
||||||
|
webtest.config.mjs
|
||||||
|
_allure/ # optional static Allure config
|
||||||
|
01-login/
|
||||||
|
02-counterparties/
|
||||||
|
...
|
||||||
|
<another-app>/ # second solution, fully isolated
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order — discovery walks recursively and sorts files by full relative path; entries starting with `_` or `.` are skipped (so `_hooks.mjs`, `_allure/` won't be picked up as tests).
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/<app-name>/
|
||||||
|
01-login/
|
||||||
|
01-open-base.test.mjs
|
||||||
|
02-section-navigation.test.mjs
|
||||||
|
02-counterparties/
|
||||||
|
01-create.test.mjs
|
||||||
|
02-edit-phone.test.mjs
|
||||||
|
03-goods-receipt/
|
||||||
|
01-fill.test.mjs
|
||||||
|
02-post.test.mjs
|
||||||
|
05-approval-process/
|
||||||
|
01-end-to-end.test.mjs # multi-user
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported — only the application-root copies are loaded.
|
||||||
|
|
||||||
|
## Test file anatomy
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const name = 'Создание контрагента'; // required
|
||||||
|
export const tags = ['catalog', 'create']; // optional, used for filtering + Allure
|
||||||
|
export const timeout = 60000; // optional, default 30000
|
||||||
|
// export const skip = 'pending fix #123'; // optional: true | string
|
||||||
|
// export const only = true; // debug-only — never commit
|
||||||
|
// export const context = 'manager'; // optional, single non-default context
|
||||||
|
// export const contexts = ['clerk', 'manager']; // optional, multi-user test
|
||||||
|
// export const severity = 'critical'; // optional, overrides config severity
|
||||||
|
|
||||||
|
export async function setup(ctx) {
|
||||||
|
// per-test prep — runs before default. Skip if not needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teardown(ctx) {
|
||||||
|
// per-test cleanup — runs after default, always (even on failure).
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function(ctx) {
|
||||||
|
const { navigateSection, openCommand, clickElement, fillFields,
|
||||||
|
readTable, closeForm, getFormState,
|
||||||
|
assert, step, log } = ctx;
|
||||||
|
|
||||||
|
await step('Открыть список контрагентов', async () => {
|
||||||
|
await navigateSection('Продажи');
|
||||||
|
await openCommand('Контрагенты');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Создать нового контрагента', async () => {
|
||||||
|
await clickElement('Создать');
|
||||||
|
await fillFields({ 'Наименование': 'Тест ' + Date.now() });
|
||||||
|
await clickElement('Записать и закрыть');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Убедиться, что элемент появился в списке', async () => {
|
||||||
|
const t = await readTable();
|
||||||
|
assert.tableHasRow(t, r => r['Наименование']?.startsWith('Тест '));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step names — in Russian, descriptive.** Step labels surface in the console output, in JSON/JUnit, and as Allure step nodes. Russian-speaking QA reads them. Use a full action phrase (`'Создать нового контрагента'`), not a tag (`'create'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`.
|
||||||
|
|
||||||
|
## `ctx` contract
|
||||||
|
|
||||||
|
The runner injects every `browser.mjs` export into `ctx` (all 1C action functions auto-detect platform errors — see SKILL.md), plus the test utilities below.
|
||||||
|
|
||||||
|
### Test utilities
|
||||||
|
|
||||||
|
```js
|
||||||
|
step(name, fn) // async wrapper. Records start/stop. Nested calls supported.
|
||||||
|
// On throw: marks the step failed, re-throws.
|
||||||
|
// On screenshot='every-step': captures after fn().
|
||||||
|
log(...args) // adds a line to ctx.testInfo's output (goes into JSON / Allure
|
||||||
|
// attachment). Use instead of console.log inside tests.
|
||||||
|
assert.* // see "Assertions" below
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ctx.testInfo` (always set, read-only)
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
name, // 'Навигация по разделам' (with params substituted)
|
||||||
|
file, // '01-navigation.test.mjs' (basename)
|
||||||
|
filePath, // relative path inside testDir
|
||||||
|
tags, // ['nav', 'smoke']
|
||||||
|
timeout, // ms
|
||||||
|
attempt, // 1..maxAttempts (1-based)
|
||||||
|
maxAttempts, // 1 + retry
|
||||||
|
param, // { ... } | undefined (only when export const params is set)
|
||||||
|
contexts: { // mirrors config.contexts; includes custom fields like displayName
|
||||||
|
clerk: { url, isolation, displayName, ... },
|
||||||
|
manager: { ... },
|
||||||
|
},
|
||||||
|
primaryContext, // 'clerk' — name of the context active at test entry
|
||||||
|
// (= t.context for single, t.contexts[0] for multi)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ctx.testResult` (only in `afterEach`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
status, // 'passed' | 'failed'
|
||||||
|
duration, // ms
|
||||||
|
attempts, // attempts actually executed
|
||||||
|
error, // { message, step?, screenshot? } | null
|
||||||
|
steps, // array of step results (each: { name, start, stop, status, error?, steps[] })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context shape
|
||||||
|
|
||||||
|
- **Single-context (default or `export const context = 'manager'`):** all API on `ctx` top-level — `ctx.clickElement(...)`, `ctx.getFormState()`, etc.
|
||||||
|
- **Multi-context (`export const contexts = ['clerk', 'manager']`):** each name is its own scoped namespace — `ctx.clerk.clickElement(...)`, `ctx.manager.fillFields(...)`. `step`, `assert`, `log`, `testInfo` stay top-level. Scoped methods auto-switch the active page before each call.
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
All on `ctx.assert`. Throw `AssertionError` with `.message`, `.actual`, `.expected`. No dependencies.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// generic
|
||||||
|
assert.ok(value, msg?) // truthy
|
||||||
|
assert.equal(actual, expected, msg?) // ===
|
||||||
|
assert.notEqual(actual, expected, msg?) // !==
|
||||||
|
assert.deepEqual(actual, expected, msg?) // JSON-compare
|
||||||
|
assert.includes(haystack, needle, msg?) // string.includes / array.includes
|
||||||
|
assert.match(string, regex, msg?) // regex.test(string)
|
||||||
|
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
||||||
|
|
||||||
|
// 1C-specific — operate on getFormState() / readTable() output
|
||||||
|
assert.formHasField(state, 'Контрагент', msg?) // state.fields[name] exists
|
||||||
|
assert.formTitle(state, expected, msg?) // state.title includes expected
|
||||||
|
assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool
|
||||||
|
// object form: { 'Наименование': 'Тест' }
|
||||||
|
// fn form: r => r['Сумма'] > 100
|
||||||
|
assert.tableRowCount(table, expected, msg?) // table.rows.length === expected
|
||||||
|
assert.noErrors(state, msg?) // !state.errors
|
||||||
|
```
|
||||||
|
|
||||||
|
Beyond these, just use plain JS (`throw new Error(...)`) — there's no custom matcher extension API. The 1C-specific helpers are the ones worth preferring over hand-rolled equivalents because their error messages name the actual fields/rows present, which speeds up triage.
|
||||||
|
|
||||||
|
## webtest.config.mjs
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
// Single-context shorthand:
|
||||||
|
url: 'http://localhost:9191/myapp/ru_RU',
|
||||||
|
|
||||||
|
// OR multi-context:
|
||||||
|
// contexts: {
|
||||||
|
// clerk: { url: 'http://localhost:9191/myapp-clerk/ru_RU', displayName: 'Кладовщик' },
|
||||||
|
// manager: { url: 'http://localhost:9191/myapp-manager/ru_RU', displayName: 'Менеджер' },
|
||||||
|
// },
|
||||||
|
// defaultContext: 'clerk',
|
||||||
|
|
||||||
|
timeout: 30000,
|
||||||
|
retries: 0,
|
||||||
|
screenshot: 'on-failure', // 'every-step' | 'off'
|
||||||
|
record: false,
|
||||||
|
|
||||||
|
// Severity → tags mapping for Allure. Each tag at most one bucket.
|
||||||
|
severity: {
|
||||||
|
critical: ['smoke', 'crud'],
|
||||||
|
minor: ['recording'],
|
||||||
|
},
|
||||||
|
defaultSeverity: 'normal',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI flags override config. Use latin context IDs + Russian `displayName` for ergonomics — `ctx.testInfo.contexts.clerk.displayName` is friendlier than mixed-case Cyrillic keys.
|
||||||
|
|
||||||
|
## _hooks.mjs
|
||||||
|
|
||||||
|
Two layers. Infra hooks run without a browser; testlevel hooks receive `ctx`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
// Infra — runs once around the whole suite.
|
||||||
|
export async function prepare({ hookArgs, log, config }) {
|
||||||
|
// hookArgs: everything after `--` on the CLI, as a string[]. Parse yourself.
|
||||||
|
const force = hookArgs.includes('--rebuild-stand');
|
||||||
|
const dataArg = hookArgs.find(a => a.startsWith('--data='))?.slice('--data='.length);
|
||||||
|
log('preparing stand, force=', force, 'data=', dataArg);
|
||||||
|
// Idempotent hash-locks on inputs (config sources, EPF spec, DB dump) keep
|
||||||
|
// warm starts to a liveness probe.
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanup({ log, config }) { /* optional */ }
|
||||||
|
|
||||||
|
// Testlevel — runs with browser ctx.
|
||||||
|
export async function beforeAll(ctx) { /* once after first context opens */ }
|
||||||
|
export async function afterAll(ctx) { /* once before final teardown */ }
|
||||||
|
export async function beforeEach(ctx) { /* ctx.testInfo is set */ }
|
||||||
|
export async function afterEach(ctx) { /* ctx.testInfo + ctx.testResult set */ }
|
||||||
|
|
||||||
|
// Per-context — runs whenever a context is created/closed.
|
||||||
|
export async function afterOpenContext(ctx, name, spec) { /* spec = config.contexts[name] */ }
|
||||||
|
export async function beforeCloseContext(ctx, name, spec) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it in `afterEach`.
|
||||||
|
|
||||||
|
Pass hook args after `--`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node $RUN test tests/<app-name>/ --bail -- --rebuild-stand --data=demo
|
||||||
|
└─runner─┘ └────── hookArgs ─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where to put data setup:**
|
||||||
|
- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks).
|
||||||
|
- Test-specific seed data → per-test `setup`.
|
||||||
|
- Shared session-wide warmup → `beforeAll`.
|
||||||
|
|
||||||
|
## Ready-to-paste patterns
|
||||||
|
|
||||||
|
A minimal CRUD shape is in *Test file anatomy* above — use it as the rhythm for catalog/document tests, swapping in the right section/command/fields. The patterns below cover what's specific to the regression engine, not the browser API (those live in SKILL.md).
|
||||||
|
|
||||||
|
### DCS report
|
||||||
|
|
||||||
|
```js
|
||||||
|
await openCommand('Остатки товаров');
|
||||||
|
// Reset user settings — 1C persists them between sessions.
|
||||||
|
await clickElement('Ещё');
|
||||||
|
await clickElement('Установить стандартные настройки');
|
||||||
|
|
||||||
|
await selectValue('Номенклатура', 'Товар 02'); // auto-enables the filter checkbox
|
||||||
|
await clickElement('Сформировать');
|
||||||
|
await wait(3);
|
||||||
|
const r = await readSpreadsheet();
|
||||||
|
assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма']);
|
||||||
|
assert.ok(r.data.length >= 1);
|
||||||
|
assert.ok(r.totals?.['Сумма']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-user process
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const contexts = ['clerk', 'manager'];
|
||||||
|
|
||||||
|
export default async function({ clerk, manager, step, assert }) {
|
||||||
|
await step('Кладовщик создаёт накладную', async () => {
|
||||||
|
await clerk.navigateSection('Склад');
|
||||||
|
await clerk.openCommand('Приходные накладные');
|
||||||
|
await clerk.clickElement('Создать');
|
||||||
|
await clerk.fillFields({ 'Контрагент': 'ООО Север' });
|
||||||
|
await clerk.clickElement('Записать');
|
||||||
|
});
|
||||||
|
await step('Менеджер утверждает накладную', async () => {
|
||||||
|
await manager.navigateSection('Согласование');
|
||||||
|
await manager.openCommand('На утверждении');
|
||||||
|
await manager.clickElement('ООО Север', { dblclick: true });
|
||||||
|
await manager.clickElement('Утвердить');
|
||||||
|
});
|
||||||
|
await step('Кладовщик видит новый статус', async () => {
|
||||||
|
const s = await clerk.getFormState();
|
||||||
|
assert.equal(s.fields['Статус']?.value, 'Утверждён');
|
||||||
|
});
|
||||||
|
await step('Освободить сессию кладовщика', async () => {
|
||||||
|
await manager.closeContext('clerk'); // free a 1C license for the next test
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state.
|
||||||
|
|
||||||
|
### Failing-test repro
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const name = 'Bug #123: накладная без контрагента не должна проводиться';
|
||||||
|
export const tags = ['bug', 'validation'];
|
||||||
|
|
||||||
|
export default async function({ openCommand, clickElement, getFormState, assert, step }) {
|
||||||
|
await openCommand('Приходные накладные');
|
||||||
|
await clickElement('Создать');
|
||||||
|
await clickElement('Провести');
|
||||||
|
const s = await getFormState();
|
||||||
|
assert.ok(s.errorModal || s.fields['Контрагент']?.required,
|
||||||
|
'Должна быть ошибка валидации или поле помечено обязательным');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Write it red first, hand it to the user, fix the underlying issue, re-run green.
|
||||||
|
|
||||||
|
### Parameterised test
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const name = 'Заполнение поля {type}';
|
||||||
|
export const params = [
|
||||||
|
{ type: 'String', field: 'Наименование', value: 'Тест' },
|
||||||
|
{ type: 'Number', field: 'Цена', value: '100.50' },
|
||||||
|
{ type: 'Date', field: 'ДатаПоступления', value: '01.01.2024' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
||||||
|
await fillFields({ [field]: value });
|
||||||
|
const state = await getFormState();
|
||||||
|
assert.equal(state.fields[field]?.value, String(value));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `params` entry becomes its own test in the report. `{key}` placeholders in `name` get substituted; without placeholders, a `[index]` suffix is added. `ctx.testInfo.param` carries the current row.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node $RUN test tests/<app-name>/ # full app suite
|
||||||
|
node $RUN test tests/<app-name>/03-goods-receipt/ # one feature folder
|
||||||
|
node $RUN test tests/<app-name>/02-counterparties/01-create.test.mjs # one file
|
||||||
|
node $RUN test tests/<app-name>/02-x.test.mjs tests/<app-name>/05-y.test.mjs # several files
|
||||||
|
node $RUN test tests/<app-name>/ --tags=smoke # by tag (intersection)
|
||||||
|
node $RUN test tests/<app-name>/ --grep='накладн' # by name regex
|
||||||
|
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
|
||||||
|
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
|
||||||
|
node $RUN test tests/<app-name>/ --report=- # machine JSON to stdout, progress to stderr
|
||||||
|
node $RUN test tests/<app-name>/ -- --rebuild-stand # after `--` → hookArgs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.)
|
||||||
|
|
||||||
|
### Allure static config — `_allure/`
|
||||||
|
|
||||||
|
The runner copies `<testDir>/_allure/` into the report directory before generating Allure output. Drop in `categories.json` (regex-based failure classification — useful for 1C-specific buckets: license pool exhaustion, platform exceptions, runner timeouts, assertion failures), `environment.properties` (optional, often emitted dynamically by `prepare()`), `executor.json` (CI metadata, skip locally). The underscore prefix keeps the directory out of test discovery.
|
||||||
|
|
||||||
|
## Severity guidance
|
||||||
|
|
||||||
|
When the user doesn't dictate, default to:
|
||||||
|
|
||||||
|
| Test kind | Severity |
|
||||||
|
|-----------|----------|
|
||||||
|
| Login + section navigation, basic CRUD on covered entities | `critical` (also tag `smoke`) |
|
||||||
|
| Documents posting, report generation, end-to-end processes | `critical` |
|
||||||
|
| Field-level edge cases, formatting, optional flows | `normal` |
|
||||||
|
| Cosmetic / recording / non-functional | `minor` |
|
||||||
|
| Reserved for show-stopper protections | `blocker` (use sparingly) |
|
||||||
|
|
||||||
|
Don't promote everything to `critical` — it loses signal in the Allure dashboard.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- **Sleeps as a substitute for assertions.** `wait(5)` after `openCommand` is fine; `wait(30)` because something flakes is a bug — wait on `getFormState` instead.
|
||||||
|
- **Retry as a substitute for understanding.** "Not found" twice means the data isn't there or the label is wrong. Don't loop.
|
||||||
|
- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by a unique marker (`Date.now()` suffix) instead.
|
||||||
|
- **Hand-writing reset code in `afterEach`.** The runner already closes forms and dismisses errors after the hook.
|
||||||
|
- **Cross-test state assumptions.** Each test must start from the desktop and seed its own data. Order-of-execution coupling is a regression-suite trap.
|
||||||
|
- **`tags: ['smoke']` on a 90-second test.** Smoke means fast.
|
||||||
|
- **Skipping recon** because "I know what this catalog looks like." The project's customisation almost certainly differs from stock.
|
||||||
|
|
||||||
|
(General browser-API anti-patterns — raw DOM, `clickElement('Закрыть')` instead of `closeForm()` — live in SKILL.md.)
|
||||||
|
|
||||||
|
## After a run — failure triage
|
||||||
|
|
||||||
|
1. Scan the JSON or Allure summary for `failed`.
|
||||||
|
2. For each failure, read `error.message` + `error.step` + screenshot.
|
||||||
|
3. If `error.onecError.stack` is present — it's a 1C exception, look at the platform trace.
|
||||||
|
4. Classify:
|
||||||
|
- **Test bug** — selector wrong, expectation wrong, race with no anchor → fix the test.
|
||||||
|
- **Application bug** — actual misbehaviour reproduced → report to the user with the failing step name and the platform stack.
|
||||||
|
- **Stand flake** — Apache timeout, login form not loading, license shortage → fix the hook idempotency or session-cleanup logic, not the test.
|
||||||
|
5. After fixes, re-run only the affected files before the full suite.
|
||||||
|
|
||||||
|
Report back to the user with the classification, not raw failure dumps.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- Browser API: [SKILL.md](SKILL.md)
|
||||||
|
- Video and narration: [recording.md](recording.md)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
// web-test cli/commands/exec v1.0 — send script to running server
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import http from 'http';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { out, die, readStdin } from '../util.mjs';
|
||||||
|
import { loadSession } from '../session.mjs';
|
||||||
|
|
||||||
|
export async function cmdExec(fileOrDash, flags = {}) {
|
||||||
|
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|-> [--no-record]');
|
||||||
|
|
||||||
|
const code = fileOrDash === '-'
|
||||||
|
? await readStdin()
|
||||||
|
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||||
|
|
||||||
|
const sess = loadSession();
|
||||||
|
const headers = {};
|
||||||
|
if (flags.noRecord) headers['x-no-record'] = '1';
|
||||||
|
const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000;
|
||||||
|
const result = await new Promise((resolveP, reject) => {
|
||||||
|
const req = http.request({
|
||||||
|
hostname: '127.0.0.1', port: sess.port, path: '/exec',
|
||||||
|
method: 'POST', timeout: timeoutMs, headers,
|
||||||
|
}, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => { try { resolveP(JSON.parse(data)); } catch { reject(new Error(data)); } });
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); });
|
||||||
|
req.write(code);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
out(result);
|
||||||
|
if (!result.ok) process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server)
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import * as browser from '../../browser.mjs';
|
||||||
|
import { out, die, readStdin } from '../util.mjs';
|
||||||
|
import { executeScript } from '../exec-context.mjs';
|
||||||
|
|
||||||
|
export async function cmdRun(url, fileOrDash) {
|
||||||
|
if (!url || !fileOrDash) die('Usage: node src/run.mjs run <url> <file|->');
|
||||||
|
|
||||||
|
const code = fileOrDash === '-'
|
||||||
|
? await readStdin()
|
||||||
|
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||||
|
|
||||||
|
await browser.connect(url);
|
||||||
|
const result = await executeScript(code);
|
||||||
|
await browser.disconnect();
|
||||||
|
|
||||||
|
out(result);
|
||||||
|
if (!result.ok) process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// web-test cli/commands/shot v1.0 — take screenshot via server
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import { out, die } from '../util.mjs';
|
||||||
|
import { loadSession } from '../session.mjs';
|
||||||
|
|
||||||
|
export async function cmdShot(file) {
|
||||||
|
const sess = loadSession();
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.text();
|
||||||
|
die(`Screenshot failed: ${err}`);
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
const outFile = file || 'shot.png';
|
||||||
|
writeFileSync(outFile, buf);
|
||||||
|
out({ ok: true, file: outFile });
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// web-test cli/commands/start v1.0
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import http from 'http';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import * as browser from '../../browser.mjs';
|
||||||
|
import { out, die } from '../util.mjs';
|
||||||
|
import { SESSION_FILE, cleanup } from '../session.mjs';
|
||||||
|
import { handleRequest } from '../server.mjs';
|
||||||
|
|
||||||
|
export async function cmdStart(url) {
|
||||||
|
if (!url) die('Usage: node src/run.mjs start <url>');
|
||||||
|
|
||||||
|
const state = await browser.connect(url);
|
||||||
|
|
||||||
|
const httpServer = http.createServer(handleRequest);
|
||||||
|
httpServer.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = httpServer.address().port;
|
||||||
|
const session = {
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
|
||||||
|
out({ ok: true, message: 'Browser ready', port, ...state });
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await browser.disconnect();
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// web-test cli/commands/status v1.0 — check session
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { out } from '../util.mjs';
|
||||||
|
import { SESSION_FILE } from '../session.mjs';
|
||||||
|
|
||||||
|
export function cmdStatus() {
|
||||||
|
if (!existsSync(SESSION_FILE)) {
|
||||||
|
out({ ok: false, message: 'No active session' });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
||||||
|
out({ ok: true, ...sess });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// web-test cli/commands/stop v1.0 — send stop to server
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { out } from '../util.mjs';
|
||||||
|
import { loadSession, cleanup } from '../session.mjs';
|
||||||
|
|
||||||
|
export async function cmdStop() {
|
||||||
|
const sess = loadSession();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' });
|
||||||
|
const result = await resp.json();
|
||||||
|
out(result);
|
||||||
|
} catch {
|
||||||
|
// Server may have already exited before responding
|
||||||
|
out({ ok: true, message: 'Stopped' });
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
// web-test cli/commands/test v1.3 — regression test runner
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { resolve, dirname, basename, relative } from 'path';
|
||||||
|
import * as browser from '../../browser.mjs';
|
||||||
|
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs';
|
||||||
|
import { buildContext, buildScopedContext } from '../exec-context.mjs';
|
||||||
|
import { createAssertions } from '../test-runner/assertions.mjs';
|
||||||
|
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
||||||
|
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
||||||
|
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
||||||
|
|
||||||
|
export async function cmdTest(rawArgs) {
|
||||||
|
// Split off everything after `--` — those args belong to user-defined hooks
|
||||||
|
// (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs
|
||||||
|
// via the hookArgs field; the runner does not interpret them").
|
||||||
|
const sepIdx = rawArgs.indexOf('--');
|
||||||
|
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
|
||||||
|
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
|
||||||
|
|
||||||
|
// Parse flags
|
||||||
|
const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
||||||
|
let tags = null, grep = null, urlFlag = null;
|
||||||
|
const positional = [];
|
||||||
|
for (const a of ownArgs) {
|
||||||
|
if (a.startsWith('--tags=')) tags = a.slice(7).split(',');
|
||||||
|
else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i');
|
||||||
|
else if (a.startsWith('--url=')) urlFlag = a.slice(6);
|
||||||
|
else if (a === '--bail') opts.bail = true;
|
||||||
|
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
|
||||||
|
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
|
||||||
|
else if (a.startsWith('--report=')) opts.report = a.slice(9);
|
||||||
|
else if (a.startsWith('--format=')) opts.format = a.slice(9);
|
||||||
|
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
|
||||||
|
else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13);
|
||||||
|
else if (a === '--record') opts.record = true;
|
||||||
|
else if (!a.startsWith('--')) positional.push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positional args are ALWAYS test paths (one or many). URL comes from --url= or config
|
||||||
|
// (see webtest.config.mjs). This matches pytest/jest/playwright; a positional that looks
|
||||||
|
// like a URL is a mistake → fail fast with a hint instead of feeding it to page.goto().
|
||||||
|
const isUrl = (s) => /^https?:\/\//i.test(s);
|
||||||
|
let url = urlFlag || null;
|
||||||
|
const testPaths = [...positional];
|
||||||
|
if (testPaths.length === 0) {
|
||||||
|
die('Usage: node run.mjs test <dir|file>... [--url=URL] [--tags=...] [--grep=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
|
||||||
|
}
|
||||||
|
for (const p of testPaths) {
|
||||||
|
if (existsSync(resolve(p))) continue;
|
||||||
|
if (isUrl(p)) {
|
||||||
|
die(`"${p}" looks like a URL — use --url=<url>; positional args are test paths.`);
|
||||||
|
}
|
||||||
|
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config if exists. config (webtest.config.mjs) and hooks (_hooks.mjs) resolve from
|
||||||
|
// the FIRST path's directory — list paths from the same suite folder.
|
||||||
|
const firstPath = resolve(testPaths[0]);
|
||||||
|
const isFile = firstPath.endsWith('.test.mjs');
|
||||||
|
const testDir = isFile ? dirname(firstPath) : firstPath;
|
||||||
|
const configPath = resolve(testDir, 'webtest.config.mjs');
|
||||||
|
let config = {};
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
||||||
|
config = mod.default || {};
|
||||||
|
}
|
||||||
|
const severityIndex = buildSeverityIndex(config);
|
||||||
|
|
||||||
|
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
|
||||||
|
const contextSpecs = {};
|
||||||
|
let defaultContextName = 'default';
|
||||||
|
const defaultIsolation = config.isolation || 'tab';
|
||||||
|
if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) {
|
||||||
|
for (const [n, spec] of Object.entries(config.contexts)) {
|
||||||
|
contextSpecs[n] = { ...spec };
|
||||||
|
}
|
||||||
|
defaultContextName = config.defaultContext || Object.keys(config.contexts)[0];
|
||||||
|
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
|
||||||
|
} else {
|
||||||
|
const fallbackUrl = url || config.url;
|
||||||
|
if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
|
||||||
|
contextSpecs.default = { url: fallbackUrl };
|
||||||
|
}
|
||||||
|
if (!contextSpecs[defaultContextName]) {
|
||||||
|
die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||||
|
}
|
||||||
|
if (!url) url = contextSpecs[defaultContextName].url;
|
||||||
|
|
||||||
|
// Apply config defaults (CLI flags override)
|
||||||
|
if (!tags && config.tags) tags = config.tags;
|
||||||
|
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
|
||||||
|
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
|
||||||
|
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
|
||||||
|
browser.setPreserveClipboard(false);
|
||||||
|
}
|
||||||
|
opts.record = opts.record || !!config.record;
|
||||||
|
opts.screenshot = opts.screenshot || config.screenshot || 'on-failure';
|
||||||
|
if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
|
||||||
|
die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`);
|
||||||
|
}
|
||||||
|
if (!['json', 'allure', 'junit'].includes(opts.format)) {
|
||||||
|
die(`Invalid --format=${opts.format} (expected json|allure|junit)`);
|
||||||
|
}
|
||||||
|
if (opts.format === 'junit' && !opts.report) {
|
||||||
|
die('--format=junit requires --report=path.xml');
|
||||||
|
}
|
||||||
|
// `--report=-` means "machine report to stdout" (Unix `-` convention).
|
||||||
|
// Only meaningful for streamable formats (json/junit); allure is a directory.
|
||||||
|
const reportToStdout = opts.report === '-';
|
||||||
|
if (reportToStdout && opts.format === 'allure') {
|
||||||
|
die('--report=- (stdout) is not valid with --format=allure: allure emits a directory of files, not a single stream. Use --report-dir=<dir> instead.');
|
||||||
|
}
|
||||||
|
const reportDir = opts.reportDir
|
||||||
|
? resolve(opts.reportDir)
|
||||||
|
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
|
||||||
|
if (opts.screenshot !== 'off') {
|
||||||
|
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover test files
|
||||||
|
const testFiles = discoverTests(testPaths);
|
||||||
|
if (!testFiles.length) die(`No *.test.mjs files found in ${testPaths.join(', ')}`);
|
||||||
|
|
||||||
|
// Import and filter tests
|
||||||
|
const tests = [];
|
||||||
|
let hasOnly = false;
|
||||||
|
for (const file of testFiles) {
|
||||||
|
const mod = await import('file:///' + file.replace(/\\/g, '/'));
|
||||||
|
const base = {
|
||||||
|
file: relative(testDir, file).replace(/\\/g, '/'),
|
||||||
|
name: mod.name || basename(file, '.test.mjs'),
|
||||||
|
tags: mod.tags || [],
|
||||||
|
timeout: mod.timeout || opts.timeout,
|
||||||
|
skip: mod.skip || false,
|
||||||
|
only: mod.only || false,
|
||||||
|
setup: mod.setup,
|
||||||
|
teardown: mod.teardown,
|
||||||
|
fn: mod.default,
|
||||||
|
param: undefined,
|
||||||
|
context: mod.context || null,
|
||||||
|
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
|
||||||
|
severity: typeof mod.severity === 'string' ? mod.severity : null,
|
||||||
|
};
|
||||||
|
if (base.only) hasOnly = true;
|
||||||
|
if (Array.isArray(mod.params) && mod.params.length) {
|
||||||
|
for (let i = 0; i < mod.params.length; i++) {
|
||||||
|
const p = mod.params[i];
|
||||||
|
const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`;
|
||||||
|
tests.push({ ...base, name, param: p });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tests.push(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
const filtered = tests.filter(t => {
|
||||||
|
if (hasOnly && !t.only) return false;
|
||||||
|
if (tags && !tags.some(tag => t.tags.includes(tag))) return false;
|
||||||
|
if (grep && !grep.test(t.name)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load hooks
|
||||||
|
const hooksPath = resolve(testDir, '_hooks.mjs');
|
||||||
|
let hooks = {};
|
||||||
|
if (existsSync(hooksPath)) {
|
||||||
|
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable report goes to stdout (test-runner convention: jest/pytest/playwright).
|
||||||
|
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
|
||||||
|
const W = reportToStdout ? process.stderr : process.stdout;
|
||||||
|
W.write(`\nweb-test -- ${url}\n`);
|
||||||
|
W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
|
||||||
|
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const results = [];
|
||||||
|
let passCount = 0, failCount = 0, skipCount = 0;
|
||||||
|
|
||||||
|
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
|
||||||
|
const hookEnv = { hookArgs, log: hookLog, config };
|
||||||
|
if (hooks.prepare) await hooks.prepare(hookEnv);
|
||||||
|
|
||||||
|
// Lazy context creation
|
||||||
|
async function ensureContext(name) {
|
||||||
|
if (browser.hasContext(name)) return;
|
||||||
|
const spec = contextSpecs[name];
|
||||||
|
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||||
|
await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
|
||||||
|
if (hooks.afterOpenContext && hookCtx) {
|
||||||
|
try { await hooks.afterOpenContext(hookCtx, name, spec); }
|
||||||
|
catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hookCtx = null;
|
||||||
|
|
||||||
|
function wrapCloseContextHook(target) {
|
||||||
|
const orig = target.closeContext;
|
||||||
|
if (typeof orig !== 'function') return;
|
||||||
|
target.closeContext = async (name) => {
|
||||||
|
if (hooks.beforeCloseContext) {
|
||||||
|
try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); }
|
||||||
|
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
return await orig(name);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect: create default context up front
|
||||||
|
await ensureContext(defaultContextName);
|
||||||
|
|
||||||
|
const ctx = buildContext({ noRecord: false });
|
||||||
|
ctx.assert = createAssertions();
|
||||||
|
ctx.log = (...a) => { /* per-test, overridden below */ };
|
||||||
|
wrapCloseContextHook(ctx);
|
||||||
|
hookCtx = ctx;
|
||||||
|
|
||||||
|
// Default context was created BEFORE hookCtx existed → fire afterOpenContext now.
|
||||||
|
if (hooks.afterOpenContext) {
|
||||||
|
try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); }
|
||||||
|
catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hooks.beforeAll) await hooks.beforeAll(ctx);
|
||||||
|
|
||||||
|
let testIdx = 0;
|
||||||
|
for (const t of filtered) {
|
||||||
|
testIdx++;
|
||||||
|
const declaredContexts = t.contexts && t.contexts.length
|
||||||
|
? t.contexts
|
||||||
|
: [t.context || defaultContextName];
|
||||||
|
|
||||||
|
if (t.skip) {
|
||||||
|
const reason = typeof t.skip === 'string' ? t.skip : '';
|
||||||
|
W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
|
||||||
|
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
||||||
|
skipCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testContextNames = declaredContexts;
|
||||||
|
try {
|
||||||
|
for (const cn of testContextNames) await ensureContext(cn);
|
||||||
|
await browser.setActiveContext(testContextNames[0]);
|
||||||
|
} catch (e) {
|
||||||
|
W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
|
||||||
|
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
||||||
|
failCount++;
|
||||||
|
if (opts.bail) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = null;
|
||||||
|
let testResult = null;
|
||||||
|
const maxAttempts = 1 + opts.retry;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
const output = [];
|
||||||
|
let steps = [];
|
||||||
|
let currentSteps = steps;
|
||||||
|
let stepIdx = 0;
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
ctx.testInfo = {
|
||||||
|
name: t.name,
|
||||||
|
file: basename(t.file),
|
||||||
|
filePath: t.file,
|
||||||
|
tags: t.tags,
|
||||||
|
timeout: t.timeout,
|
||||||
|
attempt,
|
||||||
|
maxAttempts,
|
||||||
|
param: t.param,
|
||||||
|
contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])),
|
||||||
|
primaryContext: testContextNames[0],
|
||||||
|
};
|
||||||
|
ctx.testResult = null;
|
||||||
|
|
||||||
|
let videoFile = null;
|
||||||
|
if (opts.record) {
|
||||||
|
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
|
||||||
|
try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.log = (...a) => output.push(a.map(String).join(' '));
|
||||||
|
ctx.step = async (name, fn) => {
|
||||||
|
const s = { name, start: Date.now(), status: 'passed', steps: [] };
|
||||||
|
currentSteps.push(s);
|
||||||
|
const prev = currentSteps;
|
||||||
|
currentSteps = s.steps;
|
||||||
|
stepIdx++;
|
||||||
|
const myIdx = stepIdx;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
s.status = 'failed';
|
||||||
|
s.error = e.message;
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
s.stop = Date.now();
|
||||||
|
currentSteps = prev;
|
||||||
|
if (opts.screenshot === 'every-step' && s.status === 'passed') {
|
||||||
|
try {
|
||||||
|
const slug = slugify(name);
|
||||||
|
const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`);
|
||||||
|
const png = await browser.screenshot();
|
||||||
|
writeFileSync(file, png);
|
||||||
|
s.screenshot = file;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopedKeys = [];
|
||||||
|
if (t.contexts && t.contexts.length) {
|
||||||
|
for (const cn of t.contexts) {
|
||||||
|
ctx[cn] = buildScopedContext(cn);
|
||||||
|
wrapCloseContextHook(ctx[cn]);
|
||||||
|
scopedKeys.push(cn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (hooks.beforeEach) await hooks.beforeEach(ctx);
|
||||||
|
if (t.setup) await t.setup(ctx);
|
||||||
|
|
||||||
|
let timeoutTimer;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
t.fn(ctx, t.param),
|
||||||
|
new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout); }),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
// Clear the guard timer — otherwise it stays armed in the event loop and,
|
||||||
|
// since the success path never calls process.exit(), node can't exit until
|
||||||
|
// it fires (up to `timeout` ms after the last test finished).
|
||||||
|
clearTimeout(timeoutTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
||||||
|
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
|
||||||
|
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
||||||
|
for (const cn of testContextNames) {
|
||||||
|
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
||||||
|
}
|
||||||
|
for (const k of scopedKeys) delete ctx[k];
|
||||||
|
|
||||||
|
if (videoFile) {
|
||||||
|
try { await browser.stopRecording(); } catch {}
|
||||||
|
}
|
||||||
|
const dur = elapsed(t0);
|
||||||
|
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
||||||
|
lastError = null;
|
||||||
|
break;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
|
||||||
|
let shotFile = e.onecError?.screenshot;
|
||||||
|
if (!shotFile && opts.screenshot !== 'off') {
|
||||||
|
try {
|
||||||
|
const png = await browser.screenshot();
|
||||||
|
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||||
|
writeFileSync(shotFile, png);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
||||||
|
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError };
|
||||||
|
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
|
||||||
|
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
||||||
|
for (const cn of testContextNames) {
|
||||||
|
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
||||||
|
}
|
||||||
|
for (const k of scopedKeys) delete ctx[k];
|
||||||
|
|
||||||
|
if (videoFile) {
|
||||||
|
try { await browser.stopRecording(); } catch {}
|
||||||
|
}
|
||||||
|
lastError = errInfo;
|
||||||
|
const dur = elapsed(t0);
|
||||||
|
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(testResult);
|
||||||
|
|
||||||
|
if (testResult.status === 'passed') {
|
||||||
|
passCount++;
|
||||||
|
W.write(` ✓ ${t.name} (${testResult.duration}s)\n`);
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
W.write(` ✗ ${t.name} (${testResult.duration}s)\n`);
|
||||||
|
printSteps(W, testResult.steps, ' ');
|
||||||
|
if (lastError?.message) W.write(` ${lastError.message}\n`);
|
||||||
|
if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.bail && testResult.status === 'failed') break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// Per-context teardown
|
||||||
|
try {
|
||||||
|
const remaining = browser.listContexts();
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
const survivor = remaining[0];
|
||||||
|
try { await browser.setActiveContext(survivor); } catch {}
|
||||||
|
for (let i = remaining.length - 1; i >= 1; i--) {
|
||||||
|
const name = remaining[i];
|
||||||
|
if (hooks.beforeCloseContext && hookCtx) {
|
||||||
|
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||||
|
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
try { await browser.closeContext(name); }
|
||||||
|
catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
if (hooks.beforeCloseContext && hookCtx) {
|
||||||
|
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
|
||||||
|
catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
|
||||||
|
}
|
||||||
|
try { await browser.disconnect(); } catch {}
|
||||||
|
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishedAt = new Date().toISOString();
|
||||||
|
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||||
|
|
||||||
|
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
runner: 'web-test', url, startedAt, finishedAt,
|
||||||
|
duration: totalDuration,
|
||||||
|
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
||||||
|
tests: results,
|
||||||
|
};
|
||||||
|
if (opts.format === 'allure') {
|
||||||
|
writeAllure(results, reportDir, severityIndex);
|
||||||
|
syncAllureExtras(testDir, reportDir);
|
||||||
|
} else if (opts.format === 'junit') {
|
||||||
|
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
||||||
|
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
||||||
|
} else if (reportToStdout) {
|
||||||
|
out(report);
|
||||||
|
} else if (opts.report) {
|
||||||
|
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failCount > 0) process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// web-test cli/exec-context v1.0 — buildContext + executeScript для run/exec/test
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import * as browser from '../browser.mjs';
|
||||||
|
import { elapsed } from './util.mjs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a per-context wrapper: same shape as buildContext output, but every call
|
||||||
|
* is prefixed with `setActiveContext(name)` so the test can interleave actions
|
||||||
|
* across contexts (`ctx.a.click(...); ctx.b.click(...)`).
|
||||||
|
*/
|
||||||
|
export function buildScopedContext(name) {
|
||||||
|
const inner = buildContext({ noRecord: false });
|
||||||
|
const scoped = {};
|
||||||
|
for (const [k, v] of Object.entries(inner)) {
|
||||||
|
if (typeof v === 'function') {
|
||||||
|
scoped[k] = async (...args) => {
|
||||||
|
await browser.setActiveContext(name);
|
||||||
|
return v(...args);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
scoped[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scoped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildContext({ noRecord = false } = {}) {
|
||||||
|
const ctx = {};
|
||||||
|
for (const [k, v] of Object.entries(browser)) {
|
||||||
|
if (k !== 'default') ctx[k] = v;
|
||||||
|
}
|
||||||
|
ctx.writeFileSync = writeFileSync;
|
||||||
|
ctx.readFileSync = readFileSync;
|
||||||
|
|
||||||
|
// --no-record: stub recording/narration functions to return safe defaults
|
||||||
|
if (noRecord) {
|
||||||
|
const noop = async () => {};
|
||||||
|
ctx.startRecording = noop;
|
||||||
|
ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
|
||||||
|
ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
|
||||||
|
for (const fn of ['showCaption', 'hideCaption']) {
|
||||||
|
ctx[fn] = noop;
|
||||||
|
}
|
||||||
|
ctx.isRecording = () => false;
|
||||||
|
ctx.getCaptions = () => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap action functions to auto-detect 1C errors (modal, balloon)
|
||||||
|
// and stop execution immediately with diagnostic info
|
||||||
|
const ACTION_FNS = [
|
||||||
|
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
|
||||||
|
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
|
||||||
|
'closeForm', 'filterList', 'unfilterList'
|
||||||
|
];
|
||||||
|
for (const name of ACTION_FNS) {
|
||||||
|
if (typeof ctx[name] !== 'function') continue;
|
||||||
|
const orig = ctx[name];
|
||||||
|
ctx[name] = async (...args) => {
|
||||||
|
const result = await orig(...args);
|
||||||
|
const errors = result?.errors;
|
||||||
|
if (errors?.modal || errors?.balloon) {
|
||||||
|
// Screenshot while the error modal is still visible (before fetchErrorStack closes it)
|
||||||
|
let errorShot;
|
||||||
|
try {
|
||||||
|
const png = await ctx.screenshot();
|
||||||
|
errorShot = ERROR_SHOT_PATH;
|
||||||
|
writeFileSync(errorShot, png);
|
||||||
|
} catch {}
|
||||||
|
// Try to fetch call stack for modal errors before throwing
|
||||||
|
let stack = null;
|
||||||
|
if (errors?.modal && typeof ctx.fetchErrorStack === 'function') {
|
||||||
|
try {
|
||||||
|
stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
|
||||||
|
} catch { /* don't fail if stack fetch fails */ }
|
||||||
|
}
|
||||||
|
const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeScript(code, { noRecord } = {}) {
|
||||||
|
const output = [];
|
||||||
|
const origLog = console.log;
|
||||||
|
const origErr = console.error;
|
||||||
|
console.log = (...a) => output.push(a.map(String).join(' '));
|
||||||
|
console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' '));
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const ctx = buildContext({ noRecord });
|
||||||
|
|
||||||
|
// Normalize Windows backslash paths to prevent JS parse errors
|
||||||
|
// (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence")
|
||||||
|
code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/'));
|
||||||
|
|
||||||
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||||
|
const fn = new AsyncFunction(...Object.keys(ctx), code);
|
||||||
|
await fn(...Object.values(ctx));
|
||||||
|
|
||||||
|
console.log = origLog;
|
||||||
|
console.error = origErr;
|
||||||
|
return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) };
|
||||||
|
} catch (e) {
|
||||||
|
console.log = origLog;
|
||||||
|
console.error = origErr;
|
||||||
|
|
||||||
|
// Auto-stop recording if active (prevents "Already recording" on next exec)
|
||||||
|
if (browser.isRecording()) {
|
||||||
|
try { await browser.stopRecording(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error screenshot (skip if already taken before fetchErrorStack closed the modal)
|
||||||
|
let shotFile = e.onecError?.screenshot;
|
||||||
|
if (!shotFile) {
|
||||||
|
try {
|
||||||
|
const png = await browser.screenshot();
|
||||||
|
shotFile = ERROR_SHOT_PATH;
|
||||||
|
writeFileSync(shotFile, png);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) };
|
||||||
|
|
||||||
|
// Enrich with 1C error context if available
|
||||||
|
if (e.onecError) {
|
||||||
|
result.step = e.onecError.step;
|
||||||
|
result.stepArgs = e.onecError.args;
|
||||||
|
result.onecErrors = e.onecError.errors;
|
||||||
|
result.formState = e.onecError.formState;
|
||||||
|
if (e.onecError.stack) result.stack = e.onecError.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// web-test cli/server v1.0 — HTTP server для exec/shot/stop/status в процессе start
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import * as browser from '../browser.mjs';
|
||||||
|
import { json, readBody } from './util.mjs';
|
||||||
|
import { cleanup } from './session.mjs';
|
||||||
|
import { executeScript } from './exec-context.mjs';
|
||||||
|
|
||||||
|
export async function handleRequest(req, res) {
|
||||||
|
try {
|
||||||
|
if (req.method === 'POST' && req.url === '/exec') {
|
||||||
|
const code = await readBody(req);
|
||||||
|
const noRecord = req.headers['x-no-record'] === '1';
|
||||||
|
const result = await executeScript(code, { noRecord });
|
||||||
|
json(res, result);
|
||||||
|
|
||||||
|
} else if (req.method === 'GET' && req.url === '/shot') {
|
||||||
|
const png = await browser.screenshot();
|
||||||
|
res.writeHead(200, { 'Content-Type': 'image/png' });
|
||||||
|
res.end(png);
|
||||||
|
|
||||||
|
} else if (req.method === 'POST' && req.url === '/stop') {
|
||||||
|
json(res, { ok: true, message: 'Stopping' });
|
||||||
|
await browser.disconnect();
|
||||||
|
cleanup();
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} else if (req.method === 'GET' && req.url === '/status') {
|
||||||
|
json(res, { ok: true, connected: browser.isConnected() });
|
||||||
|
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
json(res, { ok: false, error: e.message }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// web-test cli/session v1.0 — session-file helpers for HTTP-server mode
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { die } from './util.mjs';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const SESSION_FILE = resolve(__dirname, '..', '..', '.browser-session.json');
|
||||||
|
|
||||||
|
export function loadSession() {
|
||||||
|
if (!existsSync(SESSION_FILE)) {
|
||||||
|
die('No active session. Run: node src/run.mjs start <url>');
|
||||||
|
}
|
||||||
|
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanup() {
|
||||||
|
try { unlinkSync(SESSION_FILE); } catch {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// web-test cli/test-runner/assertions v1.0 — ctx.assert API
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
export function createAssertions() {
|
||||||
|
class AssertionError extends Error {
|
||||||
|
constructor(msg, actual, expected) {
|
||||||
|
super(msg);
|
||||||
|
this.name = 'AssertionError';
|
||||||
|
this.actual = actual;
|
||||||
|
this.expected = expected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok(value, msg) {
|
||||||
|
if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true);
|
||||||
|
},
|
||||||
|
equal(actual, expected, msg) {
|
||||||
|
if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected);
|
||||||
|
},
|
||||||
|
notEqual(actual, expected, msg) {
|
||||||
|
if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected);
|
||||||
|
},
|
||||||
|
deepEqual(actual, expected, msg) {
|
||||||
|
const a = JSON.stringify(actual), b = JSON.stringify(expected);
|
||||||
|
if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected);
|
||||||
|
},
|
||||||
|
includes(haystack, needle, msg) {
|
||||||
|
const h = Array.isArray(haystack) ? haystack : String(haystack);
|
||||||
|
if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle);
|
||||||
|
},
|
||||||
|
match(string, regex, msg) {
|
||||||
|
if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex);
|
||||||
|
},
|
||||||
|
async throws(fn, msg) {
|
||||||
|
try { await fn(); } catch { return; }
|
||||||
|
throw new AssertionError(msg || 'Expected function to throw');
|
||||||
|
},
|
||||||
|
// 1C-specific
|
||||||
|
formHasField(state, fieldName, msg) {
|
||||||
|
if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName);
|
||||||
|
},
|
||||||
|
formTitle(state, expected, msg) {
|
||||||
|
if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected);
|
||||||
|
},
|
||||||
|
tableHasRow(table, predicate, msg) {
|
||||||
|
const rows = table?.rows || [];
|
||||||
|
let found;
|
||||||
|
if (typeof predicate === 'function') {
|
||||||
|
found = rows.some(predicate);
|
||||||
|
} else {
|
||||||
|
found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v));
|
||||||
|
}
|
||||||
|
if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate);
|
||||||
|
},
|
||||||
|
tableRowCount(table, expected, msg) {
|
||||||
|
const actual = table?.rows?.length ?? 0;
|
||||||
|
if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected);
|
||||||
|
},
|
||||||
|
noErrors(state, msg) {
|
||||||
|
if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// web-test cli/test-runner/discover v1.1 — test file discovery + state reset between tests
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { existsSync, readdirSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
// Accepts a single path or an array of paths (files and/or dirs). Each .test.mjs file is
|
||||||
|
// taken directly; each directory is walked recursively (skipping _ / . prefixes). Results
|
||||||
|
// are deduped and sorted — sorting preserves the numeric-prefix order the suite relies on
|
||||||
|
// (00-, 01-, …) even when paths are listed out of order.
|
||||||
|
export function discoverTests(testPaths) {
|
||||||
|
const paths = Array.isArray(testPaths) ? testPaths : [testPaths];
|
||||||
|
const files = [];
|
||||||
|
function walk(dir) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
||||||
|
const full = resolve(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(full);
|
||||||
|
else if (entry.name.endsWith('.test.mjs')) files.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of paths) {
|
||||||
|
const full = resolve(p);
|
||||||
|
if (full.endsWith('.test.mjs')) {
|
||||||
|
if (existsSync(full)) files.push(full);
|
||||||
|
} else if (existsSync(full)) {
|
||||||
|
walk(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(files)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetState(ctx) {
|
||||||
|
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
try {
|
||||||
|
const state = await ctx.getFormState();
|
||||||
|
// form === null means no form open (desktop). form === 0 is a real background form
|
||||||
|
// 1C exposes in some states — must still close it to fully reset.
|
||||||
|
if (state.form == null) break;
|
||||||
|
await ctx.closeForm({ save: false });
|
||||||
|
} catch { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// web-test cli/test-runner/reporters v1.0 — Allure/JUnit writers + extras sync
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { writeFileSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs';
|
||||||
|
import { resolve, dirname, basename, relative } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { xmlEscape } from '../util.mjs';
|
||||||
|
import { resolveSeverity } from './severity.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy any files from `<testDir>/_allure/` into `reportDir`. Convention for
|
||||||
|
* Allure customization that doesn't fit inside per-test JSON:
|
||||||
|
* - `categories.json` — failure classification (regex → bucket)
|
||||||
|
* - `environment.properties` — values shown in the Environment widget
|
||||||
|
* - `executor.json` — CI/CD metadata
|
||||||
|
* Underscored folder mirrors `_hooks.mjs` convention (infra, not a test).
|
||||||
|
* Silent if folder absent.
|
||||||
|
*/
|
||||||
|
export function syncAllureExtras(testDir, reportDir) {
|
||||||
|
const extrasDir = resolve(testDir, '_allure');
|
||||||
|
if (!existsSync(extrasDir)) return;
|
||||||
|
try {
|
||||||
|
if (!statSync(extrasDir).isDirectory()) return;
|
||||||
|
} catch { return; }
|
||||||
|
for (const entry of readdirSync(extrasDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAllure(results, reportDir, severityIndex) {
|
||||||
|
for (const tr of results) {
|
||||||
|
if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
|
||||||
|
const uuid = randomUUID();
|
||||||
|
const suite = dirname(tr.file);
|
||||||
|
const suiteLabel = (suite && suite !== '.') ? suite : 'root';
|
||||||
|
const severity = resolveSeverity(tr, severityIndex);
|
||||||
|
const out = {
|
||||||
|
uuid,
|
||||||
|
name: tr.name,
|
||||||
|
fullName: tr.file,
|
||||||
|
status: tr.status,
|
||||||
|
stage: 'finished',
|
||||||
|
start: tr.start,
|
||||||
|
stop: tr.stop,
|
||||||
|
labels: [
|
||||||
|
...(tr.tags || []).map(t => ({ name: 'tag', value: t })),
|
||||||
|
{ name: 'suite', value: suiteLabel },
|
||||||
|
{ name: 'severity', value: severity },
|
||||||
|
],
|
||||||
|
steps: (tr.steps || []).map(allureStep),
|
||||||
|
attachments: [
|
||||||
|
...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
|
||||||
|
...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (tr.status === 'failed' && tr.error) {
|
||||||
|
const traceParts = [];
|
||||||
|
if (tr.output) traceParts.push(tr.output);
|
||||||
|
const onecStack = tr.error.onecError?.stack?.raw;
|
||||||
|
if (onecStack) {
|
||||||
|
if (traceParts.length) traceParts.push('\n--- 1C stack ---\n');
|
||||||
|
traceParts.push(onecStack);
|
||||||
|
}
|
||||||
|
out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') };
|
||||||
|
}
|
||||||
|
writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allureStep(s) {
|
||||||
|
const out = {
|
||||||
|
name: s.name,
|
||||||
|
status: s.status,
|
||||||
|
stage: 'finished',
|
||||||
|
start: s.start,
|
||||||
|
stop: s.stop,
|
||||||
|
steps: (s.steps || []).map(allureStep),
|
||||||
|
};
|
||||||
|
if (s.screenshot) {
|
||||||
|
out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }];
|
||||||
|
}
|
||||||
|
if (s.status === 'failed' && s.error) {
|
||||||
|
out.statusDetails = { message: s.error, trace: s.error };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJUnit(report, testDir) {
|
||||||
|
const { summary, duration, tests } = report;
|
||||||
|
const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.';
|
||||||
|
const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
|
||||||
|
lines.push(`<testsuites name="web-test" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
||||||
|
lines.push(` <testsuite name="${xmlEscape(suiteName)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
||||||
|
for (const t of tests) {
|
||||||
|
const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`;
|
||||||
|
if (t.status === 'passed') {
|
||||||
|
lines.push(` <testcase ${attrs}/>`);
|
||||||
|
} else if (t.status === 'skipped') {
|
||||||
|
lines.push(` <testcase ${attrs}><skipped/></testcase>`);
|
||||||
|
} else {
|
||||||
|
lines.push(` <testcase ${attrs}>`);
|
||||||
|
const msg = t.error?.message || '';
|
||||||
|
const trace = t.output || '';
|
||||||
|
lines.push(` <failure message="${xmlEscape(msg)}">${xmlEscape(trace)}</failure>`);
|
||||||
|
if (t.screenshot) lines.push(` <system-out>screenshot: ${xmlEscape(t.screenshot)}</system-out>`);
|
||||||
|
lines.push(` </testcase>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(` </testsuite>`);
|
||||||
|
lines.push(`</testsuites>`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// web-test cli/test-runner/severity v1.0 — Allure severity policy resolver
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { die } from '../util.mjs';
|
||||||
|
|
||||||
|
export const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 };
|
||||||
|
export const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate config.severity (inverted map: severity → [tags]) at config load time.
|
||||||
|
* Returns:
|
||||||
|
* - tagToSeverity: Map<tag, severity> (precomputed lookup for the resolver)
|
||||||
|
* - defaultSeverity: string (validated, defaults to 'normal')
|
||||||
|
* Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets.
|
||||||
|
*/
|
||||||
|
export function buildSeverityIndex(config) {
|
||||||
|
const tagToSeverity = new Map();
|
||||||
|
const sev = config.severity || {};
|
||||||
|
if (typeof sev !== 'object' || Array.isArray(sev)) {
|
||||||
|
die(`config.severity must be an object, got ${typeof sev}`);
|
||||||
|
}
|
||||||
|
for (const [level, tags] of Object.entries(sev)) {
|
||||||
|
if (!SEVERITY_LEVELS.includes(level)) {
|
||||||
|
die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(tags)) {
|
||||||
|
die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`);
|
||||||
|
}
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tagToSeverity.has(tag)) {
|
||||||
|
die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`);
|
||||||
|
}
|
||||||
|
tagToSeverity.set(tag, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const def = config.defaultSeverity || 'normal';
|
||||||
|
if (!SEVERITY_LEVELS.includes(def)) {
|
||||||
|
die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||||
|
}
|
||||||
|
return { tagToSeverity, defaultSeverity: def };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a test's severity. Precedence:
|
||||||
|
* 1. explicit `export const severity` from the test module
|
||||||
|
* 2. max-rank severity found among tags (either standard severity name, or mapped via config)
|
||||||
|
* 3. defaultSeverity from config (or 'normal' if not set)
|
||||||
|
* Returns one of SEVERITY_LEVELS.
|
||||||
|
*/
|
||||||
|
export function resolveSeverity(t, severityIndex) {
|
||||||
|
if (t.severity) {
|
||||||
|
if (!SEVERITY_LEVELS.includes(t.severity)) {
|
||||||
|
return severityIndex.defaultSeverity;
|
||||||
|
}
|
||||||
|
return t.severity;
|
||||||
|
}
|
||||||
|
let best = null;
|
||||||
|
for (const tag of t.tags || []) {
|
||||||
|
let candidate = null;
|
||||||
|
if (SEVERITY_LEVELS.includes(tag)) candidate = tag;
|
||||||
|
else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag);
|
||||||
|
if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best || severityIndex.defaultSeverity;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// web-test cli/util v1.2 — generic helpers for CLI commands
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
export function out(obj) {
|
||||||
|
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function die(msg) {
|
||||||
|
process.stderr.write(msg + '\n');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(res, obj, status = 200) {
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(obj, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
return Buffer.concat(chunks).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readStdin() {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||||
|
return Buffer.concat(chunks).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsed(t0) {
|
||||||
|
return Math.round((Date.now() - t0) / 100) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsed2(start, stop) {
|
||||||
|
return Math.round(((stop || Date.now()) - start) / 100) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(s) {
|
||||||
|
return String(s).trim()
|
||||||
|
.replace(/[\s/\\:*?"<>|]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 60) || 'step';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = Math.round((seconds - m * 60) * 10) / 10;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function xmlEscape(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpolate(template, params) {
|
||||||
|
return String(template).replace(/\{(\w+)\}/g, (_, key) =>
|
||||||
|
params[key] !== undefined ? String(params[key]) : `{${key}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printSteps(W, steps, indent) {
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const s = steps[i];
|
||||||
|
const last = i === steps.length - 1;
|
||||||
|
const prefix = last ? '└' : '├';
|
||||||
|
const mark = s.status === 'failed' ? '✗ ' : '';
|
||||||
|
W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`);
|
||||||
|
if (s.error && s.status === 'failed') {
|
||||||
|
W.write(`${indent} ${s.error}\n`);
|
||||||
|
}
|
||||||
|
if (s.steps.length) printSteps(W, s.steps, indent + ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usage() {
|
||||||
|
die(`Usage: node run.mjs <command> [args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
start <url> Launch browser and connect to 1C web client
|
||||||
|
run <url> <file|-> Autonomous: connect, execute script, disconnect
|
||||||
|
exec <file|-> [options] Execute script (file path or - for stdin)
|
||||||
|
shot [file] Take screenshot (default: shot.png)
|
||||||
|
stop Logout and close browser
|
||||||
|
status Check session status
|
||||||
|
test <dir|file>... Run regression tests (*.test.mjs); accepts multiple paths
|
||||||
|
|
||||||
|
Options for exec:
|
||||||
|
--no-record Skip video recording (record() becomes no-op)
|
||||||
|
|
||||||
|
Global options (any command):
|
||||||
|
--no-preserve-clipboard Don't save/restore OS clipboard around action calls.
|
||||||
|
Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally).
|
||||||
|
|
||||||
|
Options for test:
|
||||||
|
--url=URL Override the base URL (default: from webtest.config.mjs)
|
||||||
|
--tags=smoke,crud Filter tests by tags
|
||||||
|
--grep=pattern Filter tests by name (regex)
|
||||||
|
--bail Stop on first failure
|
||||||
|
--retry=N Retry failed tests N times
|
||||||
|
--timeout=ms Per-test timeout (default: 30000)
|
||||||
|
--report=path Write machine report (JSON/JUnit) to file
|
||||||
|
--report=- Write machine report to stdout (progress moves to stderr)
|
||||||
|
--report-dir=path Directory for screenshots and other artifacts
|
||||||
|
--screenshot=mode on-failure (default) | every-step | off
|
||||||
|
--format=fmt json (default) | allure | junit
|
||||||
|
--record Record video for each test (mp4 in report-dir)
|
||||||
|
-- <hook-args...> Everything after \`--\` is forwarded to _hooks.mjs
|
||||||
|
prepare/cleanup as hookArgs (runner does not parse it).
|
||||||
|
Example: ... tests/web-test/ -- --rebuild-stand`);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
|||||||
|
// web-test dom shared v1.0 — embedded JS function constants
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
/**
|
||||||
|
* Shared function strings embedded into page.evaluate() generators.
|
||||||
|
* Не экспортируются наружу через dom.mjs facade — внутренняя кухня.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
|
||||||
|
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
|
||||||
|
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
|
||||||
|
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
|
||||||
|
const all = document.querySelectorAll('#modalSurface');
|
||||||
|
for (const el of all) { if (el.offsetWidth > 0) return true; }
|
||||||
|
return false;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
||||||
|
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
||||||
|
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
||||||
|
function detectForm() {
|
||||||
|
const counts = {};
|
||||||
|
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const m = el.id.match(/^form(\\d+)_/);
|
||||||
|
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
||||||
|
});
|
||||||
|
const nums = Object.keys(counts).map(Number);
|
||||||
|
if (!nums.length) return null;
|
||||||
|
const candidates = nums.filter(n => n > 0);
|
||||||
|
if (!candidates.length) return nums[0];
|
||||||
|
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
|
||||||
|
if (hasVisibleModal()) {
|
||||||
|
const maxForm = Math.max(...candidates);
|
||||||
|
if (counts[maxForm] >= 1) return maxForm;
|
||||||
|
}
|
||||||
|
return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
|
||||||
|
* Works even when the open-windows tab bar is hidden. */
|
||||||
|
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
|
||||||
|
function detectForms() {
|
||||||
|
const counts = {};
|
||||||
|
document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const m = el.id.match(/^form(\\d+)_/);
|
||||||
|
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
||||||
|
});
|
||||||
|
const nums = Object.keys(counts).map(Number);
|
||||||
|
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
|
||||||
|
export const READ_FORM_FN = `function readForm(p) {
|
||||||
|
const result = {};
|
||||||
|
const fields = [];
|
||||||
|
const buttons = [];
|
||||||
|
const formTabs = [];
|
||||||
|
const texts = [];
|
||||||
|
const hyperlinks = [];
|
||||||
|
// Normalize non-breaking spaces to regular spaces
|
||||||
|
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
|
||||||
|
|
||||||
|
// Fields (inputs)
|
||||||
|
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text')
|
||||||
|
|| document.getElementById(p + name + '#title_div');
|
||||||
|
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||||
|
const actions = [];
|
||||||
|
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
|
||||||
|
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
|
||||||
|
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
|
||||||
|
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
|
||||||
|
const field = { name, value: el.value || '' };
|
||||||
|
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
|
||||||
|
if (!field.value) {
|
||||||
|
const labelEl = document.getElementById(p + name);
|
||||||
|
if (labelEl) {
|
||||||
|
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
|
||||||
|
.map(c => nbsp(c.innerText?.trim() || ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (chipTexts.length) field.value = chipTexts.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label && label !== name) field.label = label;
|
||||||
|
if (el.readOnly) field.readonly = true;
|
||||||
|
if (el.disabled) field.disabled = true;
|
||||||
|
if (el.type && el.type !== 'text') field.type = el.type;
|
||||||
|
if (document.activeElement === el) field.focused = true;
|
||||||
|
if (actions.length) field.actions = actions;
|
||||||
|
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||||
|
fields.push(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Textareas
|
||||||
|
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text')
|
||||||
|
|| document.getElementById(p + name + '#title_div');
|
||||||
|
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||||
|
const field = { name, value: el.value || '', type: 'textarea' };
|
||||||
|
if (label && label !== name) field.label = label;
|
||||||
|
if (el.readOnly) field.readonly = true;
|
||||||
|
if (el.disabled) field.disabled = true;
|
||||||
|
if (document.activeElement === el) field.focused = true;
|
||||||
|
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||||
|
fields.push(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkboxes
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '');
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text');
|
||||||
|
const label = nbsp(titleEl?.innerText?.trim() || '');
|
||||||
|
const field = {
|
||||||
|
name,
|
||||||
|
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
|
||||||
|
type: 'checkbox'
|
||||||
|
};
|
||||||
|
if (label && label !== name) field.label = label;
|
||||||
|
fields.push(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
|
||||||
|
const radioGroups = {};
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const id = el.id.replace(p, '');
|
||||||
|
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
||||||
|
if (m) {
|
||||||
|
// Options 1, 2, ... have explicit #N#radio suffix
|
||||||
|
const [, groupName, idx] = m;
|
||||||
|
if (!radioGroups[groupName]) radioGroups[groupName] = [];
|
||||||
|
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
|
||||||
|
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
|
||||||
|
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
|
||||||
|
} else if (!id.includes('#')) {
|
||||||
|
// Base element = option 0 (no #0#radio suffix)
|
||||||
|
if (!radioGroups[id]) radioGroups[id] = [];
|
||||||
|
const labelEl = document.getElementById(p + id + '#0#radio_text');
|
||||||
|
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
|
||||||
|
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const [name, options] of Object.entries(radioGroups)) {
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text');
|
||||||
|
const label = titleEl?.innerText?.trim() || '';
|
||||||
|
const selected = options.find(o => o.selected);
|
||||||
|
const field = {
|
||||||
|
name,
|
||||||
|
value: selected?.label || '',
|
||||||
|
type: 'radio',
|
||||||
|
options: options.map(o => o.label)
|
||||||
|
};
|
||||||
|
if (label && label !== name) field.label = label;
|
||||||
|
fields.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons (a.press)
|
||||||
|
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const idName = el.id.replace(p, '');
|
||||||
|
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
||||||
|
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
||||||
|
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
|
||||||
|
if (!text && !el.classList.contains('pressCommand')) return;
|
||||||
|
const btn = { name: text || idName };
|
||||||
|
if (el.classList.contains('pressDefault')) btn.default = true;
|
||||||
|
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
||||||
|
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
|
||||||
|
if (!text) {
|
||||||
|
const tip = nbsp(el.title || el.parentElement?.title || '');
|
||||||
|
if (tip) btn.tooltip = tip;
|
||||||
|
}
|
||||||
|
buttons.push(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frame buttons
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const text = nbsp(el.innerText?.trim() || '');
|
||||||
|
const idName = el.id?.replace(p, '') || '';
|
||||||
|
if (!text && !idName) return;
|
||||||
|
buttons.push({ name: text || idName, frame: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tumbler items
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const text = el.innerText?.trim();
|
||||||
|
const idName = el.id?.replace(p, '') || '';
|
||||||
|
buttons.push({ name: text || idName, tumbler: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tabs — scoped to form by checking ancestor IDs
|
||||||
|
document.querySelectorAll('[data-content]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
let node = el.parentElement;
|
||||||
|
let inForm = false;
|
||||||
|
while (node) {
|
||||||
|
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
if (!inForm) return;
|
||||||
|
const tab = { name: el.dataset.content };
|
||||||
|
if (el.classList.contains('select')) tab.active = true;
|
||||||
|
formTabs.push(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static texts and hyperlinks
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '');
|
||||||
|
if (name.endsWith('_div') || name.includes('#title')) return;
|
||||||
|
const text = el.innerText?.trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (el.classList.contains('staticTextHyper')) {
|
||||||
|
hyperlinks.push({ name: text });
|
||||||
|
} else {
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text');
|
||||||
|
const label = titleEl?.innerText?.trim() || '';
|
||||||
|
const entry = { name, value: text };
|
||||||
|
if (label) entry.label = label;
|
||||||
|
texts.push(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tables/grids — collect ALL visible grids
|
||||||
|
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||||
|
if (allGrids.length > 0) {
|
||||||
|
const tables = allGrids.map(grid => {
|
||||||
|
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
const columns = [];
|
||||||
|
if (head) {
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
[...headLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const textEl = box.querySelector('.gridBoxText');
|
||||||
|
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||||
|
if (text) {
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height });
|
||||||
|
} else {
|
||||||
|
// Unnamed column — check if data cells contain checkboxes
|
||||||
|
const firstLine = body?.querySelector('.gridLine');
|
||||||
|
if (firstLine) {
|
||||||
|
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
||||||
|
const idx = visibleHeaders.indexOf(box);
|
||||||
|
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
|
||||||
|
if (cells[idx]?.querySelector('.checkbox')) {
|
||||||
|
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
|
||||||
|
const firstLine = body?.querySelector('.gridLine');
|
||||||
|
if (firstLine && columns.length > 0) {
|
||||||
|
const xGrp = new Map();
|
||||||
|
columns.forEach(c => {
|
||||||
|
const k = Math.round(c.x) + ':' + Math.round(c.right);
|
||||||
|
if (!xGrp.has(k)) xGrp.set(k, []);
|
||||||
|
xGrp.get(k).push(c);
|
||||||
|
});
|
||||||
|
for (const [k, hdrs] of xGrp) {
|
||||||
|
if (hdrs.length !== 1) continue;
|
||||||
|
let cnt = 0;
|
||||||
|
[...firstLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
const cx = r.x + r.width / 2;
|
||||||
|
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
|
||||||
|
});
|
||||||
|
if (cnt > 1) {
|
||||||
|
const base = hdrs[0];
|
||||||
|
const baseIdx = columns.indexOf(base);
|
||||||
|
columns.splice(baseIdx, 1);
|
||||||
|
for (let si = 0; si < cnt; si++) {
|
||||||
|
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const colNames = columns.map(c => c.text);
|
||||||
|
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||||
|
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_div')
|
||||||
|
|| document.getElementById(p + 'Группа' + name + '#title_div');
|
||||||
|
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
|
||||||
|
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
|
||||||
|
});
|
||||||
|
result.tables = tables;
|
||||||
|
// Backward compat: table = first grid summary
|
||||||
|
const first = tables[0];
|
||||||
|
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active filters (train badges above grid: *СостояниеПросмотра)
|
||||||
|
const filters = [];
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const titleEl = el.querySelector('.trainName');
|
||||||
|
const valueEl = el.querySelector('.trainTitle');
|
||||||
|
if (!titleEl && !valueEl) return;
|
||||||
|
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
|
||||||
|
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
|
||||||
|
if (field || value) filters.push({ field, value });
|
||||||
|
});
|
||||||
|
// Also check search field value
|
||||||
|
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||||
|
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||||||
|
if (searchInput?.value) {
|
||||||
|
filters.push({ type: 'search', value: searchInput.value });
|
||||||
|
}
|
||||||
|
if (filters.length) result.filters = filters;
|
||||||
|
|
||||||
|
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
|
||||||
|
const navigation = [];
|
||||||
|
const formEl = document.querySelector('[id^="' + p + '"]');
|
||||||
|
if (formEl) {
|
||||||
|
let pageEl = formEl.parentElement;
|
||||||
|
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
||||||
|
if (pageEl) {
|
||||||
|
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const nameEl = el.querySelector('.navigationItemName');
|
||||||
|
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||||
|
if (!text) return;
|
||||||
|
const nav = { name: text };
|
||||||
|
if (el.classList.contains('select')) nav.active = true;
|
||||||
|
navigation.push(nav);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iframes
|
||||||
|
let iframeCount = 0;
|
||||||
|
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
|
||||||
|
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
|
||||||
|
});
|
||||||
|
if (iframeCount) result.iframes = iframeCount;
|
||||||
|
|
||||||
|
if (fields.length) result.fields = fields;
|
||||||
|
if (buttons.length) result.buttons = buttons;
|
||||||
|
if (formTabs.length) result.tabs = formTabs;
|
||||||
|
if (navigation.length) result.navigation = navigation;
|
||||||
|
if (texts.length) result.texts = texts;
|
||||||
|
if (hyperlinks.length) result.hyperlinks = hyperlinks;
|
||||||
|
|
||||||
|
// Group DCS report settings into readable format
|
||||||
|
if (result.fields) {
|
||||||
|
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
|
||||||
|
const dcsGroups = {};
|
||||||
|
const dcsNames = new Set();
|
||||||
|
for (const f of result.fields) {
|
||||||
|
const m = f.name.match(dcsRe);
|
||||||
|
if (!m) continue;
|
||||||
|
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
|
||||||
|
dcsGroups[m[1]][m[3]] = f;
|
||||||
|
dcsNames.add(f.name);
|
||||||
|
}
|
||||||
|
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
|
||||||
|
if (dcsEntries.length) {
|
||||||
|
result.reportSettings = dcsEntries.map(([, g]) => {
|
||||||
|
const cb = g['Использование'];
|
||||||
|
const val = g['Значение'];
|
||||||
|
if (!cb && !val) return null;
|
||||||
|
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
|
||||||
|
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
|
||||||
|
const s = { name: label, enabled: cb ? !!cb.value : true };
|
||||||
|
if (val) {
|
||||||
|
s.value = val.value || '';
|
||||||
|
if (val.actions && val.actions.length) s.actions = val.actions;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}).filter(Boolean);
|
||||||
|
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
|
||||||
|
if (!result.fields.length) delete result.fields;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}`;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the `#editDropDown` autocomplete popup.
|
||||||
|
*
|
||||||
|
* Returns `{ visible: false }` when EDD is absent/hidden, or
|
||||||
|
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
|
||||||
|
* for `page.mouse.click(x, y)`.
|
||||||
|
*
|
||||||
|
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
|
||||||
|
* from DLB — prefer `clickEddItemViaDispatchScript` for those cases.
|
||||||
|
*/
|
||||||
|
export function readEddScript() {
|
||||||
|
return `(() => {
|
||||||
|
const edd = document.getElementById('editDropDown');
|
||||||
|
if (!edd || edd.offsetWidth === 0) return { visible: false };
|
||||||
|
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||||
|
return {
|
||||||
|
visible: true,
|
||||||
|
items: eddTexts.map(el => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||||
|
})
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the EDD popup currently visible? Returns boolean.
|
||||||
|
* Lighter than `readEddScript` when only presence matters.
|
||||||
|
*/
|
||||||
|
export function isEddVisibleScript() {
|
||||||
|
return `(() => {
|
||||||
|
const edd = document.getElementById('editDropDown');
|
||||||
|
return !!(edd && edd.offsetWidth > 0);
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click an EDD item by name via `dispatchEvent` — bypasses `div.surface`
|
||||||
|
* overlays from DLB that intercept `page.mouse.click`.
|
||||||
|
*
|
||||||
|
* Matching is fuzzy: exact (with optional `(suffix)` strip) → includes,
|
||||||
|
* normalizes ё/е and NBSP.
|
||||||
|
*
|
||||||
|
* Returns the clicked item's innerText (trimmed), or `null` when no match.
|
||||||
|
*/
|
||||||
|
export function clickEddItemViaDispatchScript(itemName) {
|
||||||
|
return `(() => {
|
||||||
|
const edd = document.getElementById('editDropDown');
|
||||||
|
if (!edd || edd.offsetWidth === 0) return null;
|
||||||
|
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||||
|
const target = ny(${JSON.stringify(itemName.toLowerCase())});
|
||||||
|
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||||
|
function clickEl(el) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||||
|
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||||
|
el.dispatchEvent(new MouseEvent('click', opts));
|
||||||
|
return el.innerText.trim();
|
||||||
|
}
|
||||||
|
// Pass 1: exact match (prefer over partial)
|
||||||
|
for (const el of items) {
|
||||||
|
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||||
|
if (t === target) return clickEl(el);
|
||||||
|
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
|
||||||
|
if (stripped === target) return clickEl(el);
|
||||||
|
}
|
||||||
|
// Pass 2: partial match
|
||||||
|
for (const el of items) {
|
||||||
|
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||||
|
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "Показать все" / "Show all" link in the EDD footer via
|
||||||
|
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
|
||||||
|
* to scanning for span/div/a with the literal text.
|
||||||
|
*
|
||||||
|
* Returns boolean — whether the link was found and clicked.
|
||||||
|
*/
|
||||||
|
export function clickShowAllInEddScript() {
|
||||||
|
return `(() => {
|
||||||
|
const edd = document.getElementById('editDropDown');
|
||||||
|
if (!edd || edd.offsetWidth === 0) return false;
|
||||||
|
let el = edd.querySelector('.eddBottom .hyperlink');
|
||||||
|
if (!el || el.offsetWidth === 0) {
|
||||||
|
const candidates = [...edd.querySelectorAll('span, div, a')]
|
||||||
|
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||||||
|
el = candidates.find(e => {
|
||||||
|
const t = (e.innerText?.trim() || '').toLowerCase();
|
||||||
|
return t === 'показать все' || t === 'show all';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!el) return false;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||||
|
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||||
|
el.dispatchEvent(new MouseEvent('click', opts));
|
||||||
|
return true;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// web-test dom/edit-state v1.1 — focus and popup detection inside the 1C web client
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
|
||||||
|
* Returns boolean.
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA.
|
||||||
|
*/
|
||||||
|
export function isInputFocusedScript({ allowTextarea = false } = {}) {
|
||||||
|
const cond = allowTextarea
|
||||||
|
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
|
||||||
|
: `f.tagName === 'INPUT'`;
|
||||||
|
return `(() => {
|
||||||
|
const f = document.activeElement;
|
||||||
|
return !!(f && (${cond}));
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
|
||||||
|
* Used to verify grid edit-mode (active cell editor).
|
||||||
|
*
|
||||||
|
* @param {string} [gridSelector] — when given, only `true` if the focused input
|
||||||
|
* is inside that specific grid. Without it — any `.grid` ancestor counts.
|
||||||
|
*
|
||||||
|
* Returns boolean.
|
||||||
|
*/
|
||||||
|
export function isInputFocusedInGridScript(gridSelector) {
|
||||||
|
const sel = gridSelector ? JSON.stringify(gridSelector) : 'null';
|
||||||
|
return `(() => {
|
||||||
|
const f = document.activeElement;
|
||||||
|
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||||||
|
const sel = ${sel};
|
||||||
|
if (sel) {
|
||||||
|
const grid = document.querySelector(sel);
|
||||||
|
return !!(grid && grid.contains(f));
|
||||||
|
}
|
||||||
|
let n = f;
|
||||||
|
while (n) {
|
||||||
|
if (n.classList?.contains('grid')) return true;
|
||||||
|
n = n.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
|
||||||
|
* Returns `'calculator' | 'calendar' | null`.
|
||||||
|
*
|
||||||
|
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
|
||||||
|
*/
|
||||||
|
export function findOpenPopupScript() {
|
||||||
|
return `(() => {
|
||||||
|
const calc = document.querySelector('.calculate');
|
||||||
|
if (calc && calc.offsetWidth > 0) return 'calculator';
|
||||||
|
const cal = document.querySelector('.frameCalendar');
|
||||||
|
if (cal && cal.offsetWidth > 0) return 'calendar';
|
||||||
|
return null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
|
||||||
|
// open detailed error dialog, read textarea, close cleanup dialogs.
|
||||||
|
|
||||||
|
/** Find OpenReport link coordinates on the error modal for given formNum. */
|
||||||
|
export function getOpenReportCoordsScript(formNum) {
|
||||||
|
return `(() => {
|
||||||
|
const el = document.getElementById('form${formNum}_OpenReport#text');
|
||||||
|
if (!el || el.offsetWidth <= 2) return null;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
|
||||||
|
export function isErrorDetailLinkVisibleScript() {
|
||||||
|
return `(() => {
|
||||||
|
const links = document.querySelectorAll('a, [class*="hyper"], span');
|
||||||
|
for (const el of links) {
|
||||||
|
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
|
||||||
|
export function readLargestVisibleTextareaScript() {
|
||||||
|
return `(() => {
|
||||||
|
let best = null;
|
||||||
|
document.querySelectorAll('textarea').forEach(ta => {
|
||||||
|
if (ta.offsetWidth > 0 && ta.value.length > 0) {
|
||||||
|
if (!best || ta.value.length > best.value.length) best = ta;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return best?.value || null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
|
||||||
|
export function clickTopCloudOkButtonScript() {
|
||||||
|
return `(() => {
|
||||||
|
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||||
|
.filter(w => w.offsetWidth > 0)
|
||||||
|
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
|
||||||
|
for (const w of psWins) {
|
||||||
|
const ok = w.querySelector('button.webBtn, .pressDefault');
|
||||||
|
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
|
||||||
|
export function clickReportCloseButtonScript() {
|
||||||
|
return `(() => {
|
||||||
|
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||||
|
.filter(w => w.offsetWidth > 0);
|
||||||
|
for (const w of psWins) {
|
||||||
|
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
||||||
|
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
|
||||||
|
}
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for validation errors / diagnostics after an action.
|
||||||
|
* Detects three patterns:
|
||||||
|
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
|
||||||
|
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
|
||||||
|
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
|
||||||
|
* Returns { balloon, messages[], modal } or null if no errors.
|
||||||
|
*/
|
||||||
|
export function checkErrorsScript() {
|
||||||
|
return `(() => {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
// 1. Inline balloon tooltip
|
||||||
|
const balloon = document.querySelector('.balloon');
|
||||||
|
if (balloon && balloon.offsetWidth > 0) {
|
||||||
|
const msg = balloon.querySelector('.balloonMessage');
|
||||||
|
const title = balloon.querySelector('.balloonTitle');
|
||||||
|
if (msg) {
|
||||||
|
result.balloon = {
|
||||||
|
title: title?.innerText?.trim() || 'Ошибка',
|
||||||
|
message: msg.innerText?.trim() || ''
|
||||||
|
};
|
||||||
|
// Count navigation arrows to indicate total errors
|
||||||
|
const fwd = balloon.querySelector('.balloonJumpFwd');
|
||||||
|
const back = balloon.querySelector('.balloonJumpBack');
|
||||||
|
const fwdDisabled = fwd?.classList.contains('disabled');
|
||||||
|
const backDisabled = back?.classList.contains('disabled');
|
||||||
|
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
|
||||||
|
if (back && !backDisabled) result.balloon.hasPrev = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
|
||||||
|
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
|
||||||
|
for (const msgPanel of msgPanels) {
|
||||||
|
const msgs = [];
|
||||||
|
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
|
||||||
|
if (line.offsetWidth === 0) return;
|
||||||
|
const textEl = line.querySelector('.gridBoxText');
|
||||||
|
const text = (textEl || line).innerText?.trim();
|
||||||
|
if (text) msgs.push(text);
|
||||||
|
});
|
||||||
|
if (msgs.length > 0) { result.messages = msgs; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
|
||||||
|
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
|
||||||
|
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
|
||||||
|
// so we always scan for small forms with button patterns, regardless of modalSurface state
|
||||||
|
const formButtons = {};
|
||||||
|
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
|
||||||
|
if (btn.offsetWidth === 0) return;
|
||||||
|
const container = btn.closest('[id$="_container"]');
|
||||||
|
const m = container?.id?.match(/^form(\\d+)_/);
|
||||||
|
if (!m) return;
|
||||||
|
const fn = m[1];
|
||||||
|
if (!formButtons[fn]) formButtons[fn] = [];
|
||||||
|
formButtons[fn].push(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||||
|
const p = 'form' + fn + '_';
|
||||||
|
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||||
|
if (elCount > 100) continue; // Skip large content forms
|
||||||
|
if (buttons.length > 1) {
|
||||||
|
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
|
||||||
|
// Must have a Message element — real 1C confirmations always have form{N}_Message.
|
||||||
|
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
|
||||||
|
const msgEl = document.getElementById(p + 'Message');
|
||||||
|
if (!msgEl || msgEl.offsetWidth === 0) continue;
|
||||||
|
const message = msgEl.innerText?.trim() || '';
|
||||||
|
const btnNames = buttons.map(el => {
|
||||||
|
const b = { name: el.innerText?.trim() || '' };
|
||||||
|
if (el.classList.contains('pressDefault')) b.default = true;
|
||||||
|
return b;
|
||||||
|
}).filter(b => b.name);
|
||||||
|
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-button modal: error dialog with pressDefault + staticText
|
||||||
|
// Skip forms with input fields — those are data entry forms (e.g. register record),
|
||||||
|
// not error dialogs. Real error modals only have staticText + buttons.
|
||||||
|
if (!result.confirmation) {
|
||||||
|
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||||
|
const p = 'form' + fn + '_';
|
||||||
|
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||||
|
if (elCount > 100) continue;
|
||||||
|
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
|
||||||
|
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
|
||||||
|
if (hasInputs) continue;
|
||||||
|
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
|
||||||
|
.filter(el => el.offsetWidth > 0)
|
||||||
|
.map(el => el.innerText?.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (texts.length > 0) {
|
||||||
|
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
|
||||||
|
// Check if OpenReport link is available (platform exceptions have visible link text)
|
||||||
|
const reportLink = document.getElementById(p + 'OpenReport#text');
|
||||||
|
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
|
||||||
|
result.modal.hasReport = true;
|
||||||
|
}
|
||||||
|
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
|
||||||
|
const addInfo = document.getElementById(p + 'AdditionalInfo');
|
||||||
|
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
|
||||||
|
const srvText = document.getElementById(p + 'ServerText');
|
||||||
|
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
|
||||||
|
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
|
||||||
|
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
|
||||||
|
if (stateWins.length) {
|
||||||
|
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
|
||||||
|
if (texts.length) result.stateText = texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first grid cell on the form and return its center coords.
|
||||||
|
* Used as a fallback target for Alt+F when there's no search input.
|
||||||
|
*
|
||||||
|
* Returns `{ x, y } | null`.
|
||||||
|
*/
|
||||||
|
export function findFirstGridCellCoordsScript(formNum) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${formNum}_';
|
||||||
|
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.find(g => g.offsetWidth > 0);
|
||||||
|
if (!grid) return null;
|
||||||
|
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||||
|
if (!cells.length) return null;
|
||||||
|
const r = cells[0].getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the grid cell of the first row in the column whose header text matches `field`
|
||||||
|
* (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP).
|
||||||
|
*
|
||||||
|
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
|
||||||
|
* so the caller can use DLB to switch FieldSelector after opening the dialog.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - `{ x, y, needDlb? } ` — coords to click (advanced search target)
|
||||||
|
* - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
|
||||||
|
*/
|
||||||
|
export function findColumnFirstCellCoordsScript(formNum, field) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${formNum}_';
|
||||||
|
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.find(g => g.offsetWidth > 0);
|
||||||
|
if (!grid) return { error: 'no_grid' };
|
||||||
|
const targetField = ${JSON.stringify(field)};
|
||||||
|
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
|
||||||
|
let colIndex = -1;
|
||||||
|
let startsWithIdx = -1;
|
||||||
|
let includesIdx = -1;
|
||||||
|
for (let i = 0; i < headers.length; i++) {
|
||||||
|
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
|
||||||
|
if (!t) continue;
|
||||||
|
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||||
|
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
|
||||||
|
if (tl === fl) { colIndex = i; break; }
|
||||||
|
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
|
||||||
|
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
|
||||||
|
}
|
||||||
|
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
|
||||||
|
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||||
|
if (!rows.length) return { error: 'no_rows' };
|
||||||
|
if (colIndex < 0) {
|
||||||
|
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||||
|
if (!cells.length) return { error: 'no_cells' };
|
||||||
|
const r = cells[0].getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
|
||||||
|
}
|
||||||
|
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||||
|
if (colIndex >= cells.length) return { error: 'cell_not_found' };
|
||||||
|
const r = cells[colIndex].getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
|
||||||
|
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
|
||||||
|
*/
|
||||||
|
export function readFieldSelectorInfoScript(dialogForm) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||||
|
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||||
|
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||||
|
const dlb = document.getElementById(p + 'FieldSelector_DLB');
|
||||||
|
return {
|
||||||
|
current: fsInput?.value?.trim() || '',
|
||||||
|
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
|
||||||
|
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes,
|
||||||
|
* normalizes ё/е and NBSP).
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - `{ x, y, name }` — coords + matched name to click
|
||||||
|
* - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names
|
||||||
|
*/
|
||||||
|
export function pickFieldInSelectorDropdownScript(field) {
|
||||||
|
return `(() => {
|
||||||
|
const edd = document.getElementById('editDropDown');
|
||||||
|
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
|
||||||
|
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||||
|
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||||
|
const items = [...edd.querySelectorAll('div')].filter(el =>
|
||||||
|
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
|
||||||
|
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|
||||||
|
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
|
||||||
|
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
|
||||||
|
const r = match.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read advanced search dialog state — FieldSelector value, Pattern input id+value,
|
||||||
|
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
|
||||||
|
*
|
||||||
|
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
|
||||||
|
*/
|
||||||
|
export function readFilterDialogInfoScript(dialogForm) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||||
|
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||||
|
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||||
|
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||||
|
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||||||
|
const ptLabel = ptInput?.closest('label');
|
||||||
|
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
|
||||||
|
const isDate = btns.some(c => c.includes('iCalendB'));
|
||||||
|
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
|
||||||
|
return {
|
||||||
|
fieldSelector: fsInput?.value?.trim() || '',
|
||||||
|
patternValue: ptInput?.value?.trim() || '',
|
||||||
|
patternId: ptInput?.id || '',
|
||||||
|
isDate,
|
||||||
|
isRef
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the × close button on the filter badge whose title matches `field`
|
||||||
|
* (exact → includes; normalizes ё/е and NBSP).
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - `{ x, y, field }` — coords + actual field title from the badge
|
||||||
|
* - `{ error, available }` — `'not_found'` with list of available badge titles
|
||||||
|
*/
|
||||||
|
export function findFilterBadgeCloseScript(formNum, field) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${formNum}_';
|
||||||
|
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
|
||||||
|
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||||
|
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||||
|
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
|
||||||
|
for (const item of items) {
|
||||||
|
const titleEl = item.querySelector('.trainName');
|
||||||
|
const title = ny(norm(titleEl?.innerText).toLowerCase());
|
||||||
|
if (title === target || title.includes(target)) {
|
||||||
|
const close = item.querySelector('.trainClose');
|
||||||
|
if (close) {
|
||||||
|
const r = close.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
|
||||||
|
return { error: 'not_found', available };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
|
||||||
|
* Returns `{ x, y } | null`.
|
||||||
|
*/
|
||||||
|
export function findFirstFilterBadgeCloseScript(formNum) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${formNum}_';
|
||||||
|
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
|
||||||
|
.find(el => el.offsetWidth > 0);
|
||||||
|
if (!item) return null;
|
||||||
|
const close = item.querySelector('.trainClose');
|
||||||
|
if (!close) return null;
|
||||||
|
const r = close.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined: detect form + read form + read open tabs.
|
||||||
|
* Single evaluate call instead of 3. Used by browser.getFormState().
|
||||||
|
*/
|
||||||
|
export function getFormStateScript() {
|
||||||
|
return `(() => {
|
||||||
|
${DETECT_FORM_FN}
|
||||||
|
${DETECT_FORMS_FN}
|
||||||
|
${READ_FORM_FN}
|
||||||
|
const formNum = detectForm();
|
||||||
|
const meta = detectForms();
|
||||||
|
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
|
||||||
|
const p = 'form' + formNum + '_';
|
||||||
|
const formData = readForm(p);
|
||||||
|
// Open tabs bar (present only when tab panel is enabled in 1C settings)
|
||||||
|
const openTabs = [];
|
||||||
|
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
||||||
|
const text = el.innerText?.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const entry = { name: text };
|
||||||
|
if (el.classList.contains('select')) entry.active = true;
|
||||||
|
openTabs.push(entry);
|
||||||
|
});
|
||||||
|
const activeTab = openTabs.find(t => t.active)?.name || null;
|
||||||
|
const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData };
|
||||||
|
if (meta.modal) result.modal = true;
|
||||||
|
if (openTabs.length) result.openTabs = openTabs;
|
||||||
|
return result;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
// web-test dom/forms v1.6 — form detection, content read, click-target/field-button resolution
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { DETECT_FORM_FN, READ_FORM_FN } from './_shared.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the active form number.
|
||||||
|
* Picks the form with the most visible elements (excluding form0 = home page).
|
||||||
|
*/
|
||||||
|
export function detectFormScript() {
|
||||||
|
return `(() => {
|
||||||
|
${DETECT_FORM_FN}
|
||||||
|
return detectForm();
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read full form state for a given form number.
|
||||||
|
* Uses shared READ_FORM_FN.
|
||||||
|
*/
|
||||||
|
export function readFormScript(formNum) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
${READ_FORM_FN}
|
||||||
|
return readForm(${JSON.stringify(p)});
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a clickable element on the current form (button, hyperlink, tab, frame button).
|
||||||
|
* Returns { id, kind, name } for Playwright page.click(), or { error, available }.
|
||||||
|
* Supports synonym matching: visible text AND internal name from DOM ID.
|
||||||
|
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
||||||
|
*/
|
||||||
|
export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const tableName = ${JSON.stringify(tableName || '')};
|
||||||
|
const gridSelector = ${JSON.stringify(gridSelector || '')};
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
// Buttons (a.press)
|
||||||
|
[...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||||
|
const idName = el.id.replace(p, '');
|
||||||
|
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
||||||
|
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
||||||
|
const text = norm(span?.textContent) || norm(el.innerText);
|
||||||
|
if (!text && !el.classList.contains('pressCommand')) return;
|
||||||
|
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
|
||||||
|
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
|
||||||
|
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
|
||||||
|
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
|
||||||
|
items.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hyperlinks (staticTextHyper)
|
||||||
|
[...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||||
|
const idName = el.id.replace(p, '');
|
||||||
|
const text = norm(el.innerText);
|
||||||
|
items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frame buttons
|
||||||
|
[...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||||
|
const text = norm(el.innerText);
|
||||||
|
const idName = el.id.replace(p, '');
|
||||||
|
if (!text && !idName) return;
|
||||||
|
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tumbler items (toggle switch segments)
|
||||||
|
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||||
|
const idName = el.id.replace(p, '');
|
||||||
|
const text = norm(el.innerText);
|
||||||
|
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Checkboxes (div.checkbox) — match by label or internal name
|
||||||
|
[...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||||
|
const idName = el.id.replace(p, '');
|
||||||
|
const titleEl = document.getElementById(p + idName + '#title_text');
|
||||||
|
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||||
|
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tabs (scoped to form)
|
||||||
|
[...document.querySelectorAll('[data-content]')].filter(el => {
|
||||||
|
if (el.offsetWidth === 0) return false;
|
||||||
|
let node = el.parentElement;
|
||||||
|
while (node) {
|
||||||
|
if (node.id && node.id.startsWith(p)) return true;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}).forEach(el => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab',
|
||||||
|
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation panel items (FormNavigationPanel) — in parent page{N}
|
||||||
|
const formEl = document.querySelector('[id^="' + p + '"]');
|
||||||
|
if (formEl) {
|
||||||
|
let pageEl = formEl.parentElement;
|
||||||
|
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
||||||
|
if (pageEl) {
|
||||||
|
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const nameEl = el.querySelector('.navigationItemName');
|
||||||
|
const text = norm(nameEl?.innerText || '');
|
||||||
|
if (!text) return;
|
||||||
|
items.push({ id: el.id, name: text, label: '', kind: 'navigation' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When table is specified, scope button search to grid's parent container
|
||||||
|
if (gridSelector) {
|
||||||
|
const gridEl = document.querySelector(gridSelector);
|
||||||
|
if (gridEl) {
|
||||||
|
// Find parent container that has id with formPrefix and contains the grid
|
||||||
|
let container = gridEl.parentElement;
|
||||||
|
while (container && container !== document.body) {
|
||||||
|
if (container.id && container.id.startsWith(p)) break;
|
||||||
|
container = container.parentElement;
|
||||||
|
}
|
||||||
|
// Filter items to those inside the container
|
||||||
|
const containerItems = container && container !== document.body
|
||||||
|
? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); })
|
||||||
|
: [];
|
||||||
|
// Try fuzzy match within container first
|
||||||
|
let cf = containerItems.find(i => i.name.toLowerCase() === target);
|
||||||
|
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
|
||||||
|
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
|
||||||
|
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||||
|
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
|
||||||
|
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
|
||||||
|
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
|
||||||
|
if (gridName) {
|
||||||
|
const prefixItems = items.filter(i => i.label && i.label.includes(gridName));
|
||||||
|
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
|
||||||
|
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||||
|
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
|
||||||
|
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall through to unscoped search
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip
|
||||||
|
// Skip includes() for short strings (< 4 chars) to avoid false positives
|
||||||
|
// e.g. "Да" matching "КомандаУстановитьВсе"
|
||||||
|
let found = items.find(i => i.name.toLowerCase() === target);
|
||||||
|
if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target);
|
||||||
|
if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target);
|
||||||
|
if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target));
|
||||||
|
if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target));
|
||||||
|
if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target));
|
||||||
|
if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||||
|
if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target));
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
const res = { id: found.id, kind: found.kind, name: found.name };
|
||||||
|
if (found.x != null) { res.x = found.x; res.y = found.y; }
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid rows — fallback: search in table rows (for hierarchical/tree navigation)
|
||||||
|
// Search ALL visible grids (or specific grid when table parameter is set)
|
||||||
|
let grids;
|
||||||
|
if (gridSelector) {
|
||||||
|
const g = document.querySelector(gridSelector);
|
||||||
|
grids = g ? [g] : [];
|
||||||
|
} else {
|
||||||
|
grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0);
|
||||||
|
}
|
||||||
|
for (const grid of grids) {
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) continue;
|
||||||
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
|
for (const line of lines) {
|
||||||
|
const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
||||||
|
const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean);
|
||||||
|
const firstCell = rowTexts[0]?.toLowerCase() || '';
|
||||||
|
const rowText = rowTexts.join(' ').toLowerCase();
|
||||||
|
if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) {
|
||||||
|
const imgBox = line.querySelector('.gridBoxImg');
|
||||||
|
const isGroup = imgBox?.querySelector('.gridListH') !== null;
|
||||||
|
const isParent = imgBox?.querySelector('.gridListV') !== null;
|
||||||
|
const isTreeNode = line.querySelector('.gridBoxTree') !== null;
|
||||||
|
const hasChildren = line.querySelector('[tree="true"]') !== null;
|
||||||
|
let kind;
|
||||||
|
if (isGroup) kind = 'gridGroup';
|
||||||
|
else if (isParent) kind = 'gridParent';
|
||||||
|
else if (isTreeNode && hasChildren) kind = 'gridTreeNode';
|
||||||
|
else kind = 'gridRow';
|
||||||
|
const r = line.getBoundingClientRect();
|
||||||
|
return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id,
|
||||||
|
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form input fields — LAST resort: focus a field by name/label without changing its value.
|
||||||
|
// Only when no table scope is given ("если нет уточнения таблицы"): grid cells are handled elsewhere.
|
||||||
|
// Reached only after every clickable target (button/link/tab/nav/grid row) failed to match,
|
||||||
|
// so collisions between a field name and a real control are unlikely.
|
||||||
|
const fields = [];
|
||||||
|
if (!tableName) {
|
||||||
|
[...document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]')].forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
// Skip inputs inside a grid — those are table cells, not form fields.
|
||||||
|
let n = el.parentElement; let inGrid = false;
|
||||||
|
while (n) { if (n.classList && n.classList.contains('grid')) { inGrid = true; break; } n = n.parentElement; }
|
||||||
|
if (inGrid) return;
|
||||||
|
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||||
|
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
|
||||||
|
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||||
|
fields.push({ id: el.id, name: idName, label });
|
||||||
|
});
|
||||||
|
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
|
||||||
|
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
|
||||||
|
if (!ff) ff = fields.find(f => f.label && f.label.toLowerCase().startsWith(target));
|
||||||
|
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
|
||||||
|
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||||
|
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
|
||||||
|
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
|
||||||
|
for (const f of fields) { const nm = f.label || f.name; if (nm && !available.includes(nm)) available.push(nm); }
|
||||||
|
return { error: 'not_found', available };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name.
|
||||||
|
* Returns { fieldName, buttonId, buttonType } or { error, available }.
|
||||||
|
*/
|
||||||
|
export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))};
|
||||||
|
const suffix = ${JSON.stringify(buttonSuffix)};
|
||||||
|
const allFields = [];
|
||||||
|
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text')
|
||||||
|
|| document.getElementById(p + name + '#title_div');
|
||||||
|
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
|
allFields.push({ name, label });
|
||||||
|
});
|
||||||
|
// Also collect checkboxes for DCS pair matching
|
||||||
|
const allCheckboxes = [];
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '');
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text');
|
||||||
|
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
|
allCheckboxes.push({ inputId: el.id, name, label });
|
||||||
|
});
|
||||||
|
// Build DCS pairs: checkbox label → paired value field
|
||||||
|
const dcsPairs = {};
|
||||||
|
for (const f of [...allFields, ...allCheckboxes]) {
|
||||||
|
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
||||||
|
dcsPairs[m[1]][m[2]] = f;
|
||||||
|
}
|
||||||
|
let found = allFields.find(f => f.name.toLowerCase() === target);
|
||||||
|
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
||||||
|
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
||||||
|
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||||
|
// DCS pair: match checkbox or value label → resolve to paired value field
|
||||||
|
let dcsCheckbox = null;
|
||||||
|
if (!found) {
|
||||||
|
for (const pair of Object.values(dcsPairs)) {
|
||||||
|
const cb = pair['Использование'];
|
||||||
|
const val = pair['Значение'];
|
||||||
|
if (!cb || !val) continue;
|
||||||
|
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
||||||
|
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
||||||
|
found = val;
|
||||||
|
dcsCheckbox = cb;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) };
|
||||||
|
}
|
||||||
|
const btnId = p + found.name + '_' + suffix;
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
if (!btn || btn.offsetWidth === 0) {
|
||||||
|
return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name };
|
||||||
|
}
|
||||||
|
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
|
||||||
|
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
|
||||||
|
return result;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve field names to element IDs for Playwright page.fill().
|
||||||
|
* Returns [{ field, inputId, name, label }] or [{ field, error, available }].
|
||||||
|
* Supports synonym matching: internal name AND visible label.
|
||||||
|
* Fuzzy order: exact name -> exact label -> includes name -> includes label.
|
||||||
|
*/
|
||||||
|
export function resolveFieldsScript(formNum, fields) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const fieldNames = ${JSON.stringify(Object.keys(fields))};
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Build field map with name + label for synonym matching
|
||||||
|
const allFields = [];
|
||||||
|
document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text')
|
||||||
|
|| document.getElementById(p + name + '#title_div');
|
||||||
|
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
|
const last = { inputId: el.id, name, label };
|
||||||
|
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
|
||||||
|
const cbEl = document.getElementById(p + name + '_CB');
|
||||||
|
if (cbEl?.offsetWidth > 0) {
|
||||||
|
last.hasPick = true;
|
||||||
|
if (cbEl.classList.contains('iCalendB')) last.isDate = true;
|
||||||
|
else if (cbEl.classList.contains('iCalcB')) last.isCalc = true;
|
||||||
|
}
|
||||||
|
allFields.push(last);
|
||||||
|
});
|
||||||
|
// Checkboxes
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const name = el.id.replace(p, '');
|
||||||
|
const titleEl = document.getElementById(p + name + '#title_text');
|
||||||
|
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
|
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
|
||||||
|
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
|
||||||
|
});
|
||||||
|
// Radio button groups — base element = option 0, others are #N#radio
|
||||||
|
const radioSeen = new Set();
|
||||||
|
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const id = el.id.replace(p, '');
|
||||||
|
// Skip if already processed or if it's a sub-element (#N#radio)
|
||||||
|
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
||||||
|
const groupName = m ? m[1] : (!id.includes('#') ? id : null);
|
||||||
|
if (!groupName || radioSeen.has(groupName)) return;
|
||||||
|
radioSeen.add(groupName);
|
||||||
|
const titleEl = document.getElementById(p + groupName + '#title_text');
|
||||||
|
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
|
// Collect options: option 0 is the base element, options 1+ have #N#radio
|
||||||
|
const options = [];
|
||||||
|
// Option 0: base element
|
||||||
|
const base = document.getElementById(p + groupName);
|
||||||
|
if (base && base.classList.contains('radio') && base.offsetWidth > 0) {
|
||||||
|
const textEl = document.getElementById(p + groupName + '#0#radio_text');
|
||||||
|
options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') });
|
||||||
|
}
|
||||||
|
// Options 1+
|
||||||
|
for (let i = 1; i < 20; i++) {
|
||||||
|
const opt = document.getElementById(p + groupName + '#' + i + '#radio');
|
||||||
|
if (!opt || opt.offsetWidth === 0) break;
|
||||||
|
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
|
||||||
|
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
|
||||||
|
}
|
||||||
|
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build DCS pairs: checkbox label → paired value field
|
||||||
|
const dcsPairs = {};
|
||||||
|
for (const f of allFields) {
|
||||||
|
const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {};
|
||||||
|
dcsPairs[m[1]][m[2]] = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fieldName of fieldNames) {
|
||||||
|
const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
|
// Fuzzy: exact name -> exact label -> includes name -> includes label
|
||||||
|
let found = allFields.find(f => f.name.toLowerCase() === target);
|
||||||
|
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target);
|
||||||
|
if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target));
|
||||||
|
if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||||
|
// DCS pair: match checkbox or value label → resolve to paired value field
|
||||||
|
if (!found) {
|
||||||
|
for (const pair of Object.values(dcsPairs)) {
|
||||||
|
const cb = pair['Использование'];
|
||||||
|
const val = pair['Значение'];
|
||||||
|
if (!cb || !val) continue;
|
||||||
|
const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase();
|
||||||
|
if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) {
|
||||||
|
found = val;
|
||||||
|
found._dcsCheckbox = cb;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
|
||||||
|
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
|
||||||
|
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
|
||||||
|
if (found.hasSelect) entry.hasSelect = true;
|
||||||
|
if (found.hasPick) entry.hasPick = true;
|
||||||
|
if (found.isDate) entry.isDate = true;
|
||||||
|
if (found.isCalc) entry.isCalc = true;
|
||||||
|
if (found._dcsCheckbox) {
|
||||||
|
entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked };
|
||||||
|
delete found._dcsCheckbox;
|
||||||
|
}
|
||||||
|
results.push(entry);
|
||||||
|
} else {
|
||||||
|
const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name);
|
||||||
|
results.push({ field: fieldName, error: 'not_found', available });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a new form opened above `prevFormNum`. Two modes:
|
||||||
|
* default (broad) — counts any visible `[id]` element; finds dialogs whose
|
||||||
|
* `a.press` buttons have empty IDs. Used by selectValue / fillTableRow.
|
||||||
|
* `{ strict: true }` — only counts visible interactive elements
|
||||||
|
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
||||||
|
*
|
||||||
|
* Returns the highest new form number or `null`.
|
||||||
|
*/
|
||||||
|
export function detectNewFormScript(prevFormNum, { strict = false } = {}) {
|
||||||
|
const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]';
|
||||||
|
const visibleCheck = strict
|
||||||
|
? 'el.offsetWidth === 0'
|
||||||
|
: 'el.offsetWidth === 0 && el.offsetHeight === 0';
|
||||||
|
return `(() => {
|
||||||
|
const forms = {};
|
||||||
|
document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => {
|
||||||
|
if (${visibleCheck}) return;
|
||||||
|
const m = el.id.match(/^form(\\d+)_/);
|
||||||
|
if (m) forms[m[1]] = true;
|
||||||
|
});
|
||||||
|
const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum});
|
||||||
|
return nums.length > 0 ? Math.max(...nums) : null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id).
|
||||||
|
* Returns `{ id, value } | null`.
|
||||||
|
*/
|
||||||
|
export function findSearchInputScript(formNum) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${formNum}_';
|
||||||
|
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||||
|
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||||||
|
return el ? { id: el.id, value: el.value || '' } : null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a visible `a.press` button by its exact innerText (after trim).
|
||||||
|
* Returns `{ x, y } | null` for `page.mouse.click(x, y)`.
|
||||||
|
*
|
||||||
|
* Used for modal dialog buttons (Найти, OK) where page.click may be blocked.
|
||||||
|
*/
|
||||||
|
export function findNamedButtonScript(buttonText) {
|
||||||
|
return `(() => {
|
||||||
|
const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0);
|
||||||
|
const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)});
|
||||||
|
if (!btn) return null;
|
||||||
|
const r = btn.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.)
|
||||||
|
* on a search/filter dialog.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - `{ already: true }` — the group is disabled OR the radio is already selected
|
||||||
|
* - `{ x, y } | null` — coords to click, or null if radio not present
|
||||||
|
*/
|
||||||
|
export function findCompareTypeRadioScript(dialogForm, radioIndex) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||||
|
const group = document.getElementById(p + 'CompareType');
|
||||||
|
if (group && group.classList.contains('disabled')) return { already: true };
|
||||||
|
const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio');
|
||||||
|
if (!el || el.offsetWidth === 0) return null;
|
||||||
|
if (el.classList.contains('select')) return { already: true };
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is any element of `form{dialogForm}_` currently visible?
|
||||||
|
* Used to poll dialog dismissal after Escape.
|
||||||
|
*/
|
||||||
|
export function isFormVisibleScript(dialogForm) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${dialogForm}_';
|
||||||
|
return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0);
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the Pattern input id on a search/filter dialog. Returns `id | null`.
|
||||||
|
*/
|
||||||
|
export function findPatternInputIdScript(dialogForm) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${dialogForm}_';
|
||||||
|
const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||||
|
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||||||
|
return el ? el.id : null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the given form a type selection dialog ("Выбор типа данных")?
|
||||||
|
*
|
||||||
|
* Detection signals (any one is sufficient):
|
||||||
|
* - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK")
|
||||||
|
* - `form{N}_ValueList` grid exists (specific to type/value list dialogs)
|
||||||
|
* - window title contains "Выбор типа" on a visible `.toplineBoxTitle`
|
||||||
|
*
|
||||||
|
* Returns boolean.
|
||||||
|
*/
|
||||||
|
export function isTypeDialogScript(formNum) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form' + ${formNum} + '_';
|
||||||
|
const hasOK = !!document.getElementById(p + 'OK');
|
||||||
|
const hasValueList = !!document.getElementById(p + 'ValueList');
|
||||||
|
const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')]
|
||||||
|
.some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || ''));
|
||||||
|
return hasOK || hasValueList || hasTitle;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "Показать все" / "Show all" link inside the "нет в списке"
|
||||||
|
* cloud popup via `dispatchEvent`. Returns boolean — whether clicked.
|
||||||
|
*/
|
||||||
|
export function clickShowAllInNotInListCloudScript() {
|
||||||
|
return `(() => {
|
||||||
|
for (const el of document.querySelectorAll('div')) {
|
||||||
|
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
if (s.position !== 'absolute' && s.position !== 'fixed') continue;
|
||||||
|
if ((parseInt(s.zIndex) || 0) < 100) continue;
|
||||||
|
if (!(el.innerText || '').includes('нет в списке')) continue;
|
||||||
|
const links = [...el.querySelectorAll('a, span, div')]
|
||||||
|
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||||||
|
const showAll = links.find(e => {
|
||||||
|
const t = (e.innerText?.trim() || '').toLowerCase();
|
||||||
|
return t === 'показать все' || t === 'show all';
|
||||||
|
});
|
||||||
|
if (showAll) {
|
||||||
|
const r = showAll.getBoundingClientRect();
|
||||||
|
const opts = { bubbles:true, cancelable:true,
|
||||||
|
clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||||
|
showAll.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||||
|
showAll.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||||
|
showAll.dispatchEvent(new MouseEvent('click', opts));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div
|
||||||
|
* (absolute/fixed, high z-index) whose text contains "нет в списке".
|
||||||
|
* Returns boolean.
|
||||||
|
*/
|
||||||
|
export function isNotInListCloudVisibleScript() {
|
||||||
|
return `(() => {
|
||||||
|
const divs = document.querySelectorAll('div');
|
||||||
|
for (const el of divs) {
|
||||||
|
if (el.offsetWidth === 0 || el.offsetHeight === 0) continue;
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
||||||
|
const z = parseInt(style.zIndex) || 0;
|
||||||
|
if (z < 100) continue;
|
||||||
|
if ((el.innerText || '').includes('нет в списке')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible.
|
||||||
|
* Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number.
|
||||||
|
* Returns the form number or `null`.
|
||||||
|
*/
|
||||||
|
export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) {
|
||||||
|
return `(() => {
|
||||||
|
for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) {
|
||||||
|
const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)});
|
||||||
|
if (btn && btn.offsetWidth > 0) return n;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`.
|
||||||
|
*
|
||||||
|
* `typeNorm` should already be lowercased, NBSP-normalized, ё→е normalized (use `normYo`).
|
||||||
|
*
|
||||||
|
* Returns `{ visible: string[], matches: Array<{ text, x, y }> }`.
|
||||||
|
*/
|
||||||
|
export function readTypeDialogVisibleRowsScript(formNum, typeNorm) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = document.getElementById('form${formNum}_ValueList');
|
||||||
|
if (!grid) return { visible: [], matches: [] };
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return { visible: [], matches: [] };
|
||||||
|
const lines = body.querySelectorAll('.gridLine');
|
||||||
|
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim();
|
||||||
|
const typeNorm = ${JSON.stringify(typeNorm)};
|
||||||
|
const visible = [];
|
||||||
|
const matches = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const text = norm(line.innerText);
|
||||||
|
if (!text) continue;
|
||||||
|
visible.push(text);
|
||||||
|
if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) {
|
||||||
|
const r = line.getBoundingClientRect();
|
||||||
|
matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { visible, matches };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations)
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||||
|
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||||
|
// the page (this matches the implicit "current grid" used by row-fill).
|
||||||
|
|
||||||
|
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
||||||
|
function gridResolver(gridSelector) {
|
||||||
|
return gridSelector
|
||||||
|
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||||
|
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the grid's column header texts paired with their `colindex` attribute,
|
||||||
|
* fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in
|
||||||
|
* left-to-right colindex order.
|
||||||
|
*
|
||||||
|
* Keys that don't match a column get colindex `999` (pushed to the end);
|
||||||
|
* caller is expected to preserve their original relative order.
|
||||||
|
*
|
||||||
|
* Returns `string[] | null` (null when no grid or no head).
|
||||||
|
*/
|
||||||
|
export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return null;
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
if (!head) return null;
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
const cols = [];
|
||||||
|
[...headLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase();
|
||||||
|
const ci = parseInt(box.getAttribute('colindex') || '-1');
|
||||||
|
if (t) cols.push({ text: t, colindex: ci });
|
||||||
|
});
|
||||||
|
const keys = ${JSON.stringify(fieldKeys)};
|
||||||
|
const mapped = keys.map(k => {
|
||||||
|
const exact = cols.find(c => c.text === k);
|
||||||
|
if (exact) return { key: k, colindex: exact.colindex };
|
||||||
|
const inc = cols.find(c => c.text.includes(k) || k.includes(c.text));
|
||||||
|
return { key: k, colindex: inc ? inc.colindex : 999 };
|
||||||
|
});
|
||||||
|
mapped.sort((a, b) => a.colindex - b.colindex);
|
||||||
|
return mapped.map(m => m.key);
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve cell coords for row `row` by matching the first column whose header
|
||||||
|
* fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second
|
||||||
|
* visible (non-`.gridBoxComp`) box when no header matches.
|
||||||
|
*
|
||||||
|
* Returns one of:
|
||||||
|
* - `{ x, y, currentText }` — coords + cell text
|
||||||
|
* - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }`
|
||||||
|
* - `{ error: 'row_out_of_range', total }`
|
||||||
|
*/
|
||||||
|
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return { error: 'no_grid' };
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!head || !body) return { error: 'no_grid_body' };
|
||||||
|
|
||||||
|
// Read column headers to find target colindex
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
const cols = [];
|
||||||
|
[...headLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const t = box.querySelector('.gridBoxText');
|
||||||
|
const ci = box.getAttribute('colindex');
|
||||||
|
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||||
|
});
|
||||||
|
|
||||||
|
const keys = ${JSON.stringify(fieldKeys)};
|
||||||
|
let targetColindex = null;
|
||||||
|
for (const key of keys) {
|
||||||
|
const exact = cols.find(c => c.text === key);
|
||||||
|
if (exact) { targetColindex = exact.colindex; break; }
|
||||||
|
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
|
||||||
|
if (inc) { targetColindex = inc.colindex; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...body.querySelectorAll('.gridLine')];
|
||||||
|
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||||
|
const line = rows[${row}];
|
||||||
|
|
||||||
|
// Find body cell by colindex (reliable across merged headers)
|
||||||
|
let box = null;
|
||||||
|
if (targetColindex != null) {
|
||||||
|
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||||||
|
}
|
||||||
|
// Fallback: second visible box (skip checkbox/N column)
|
||||||
|
if (!box) {
|
||||||
|
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||||
|
box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||||||
|
}
|
||||||
|
if (!box) return { error: 'no_cell' };
|
||||||
|
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
const cell = box.querySelector('.gridBoxText') || box;
|
||||||
|
const r = cell.getBoundingClientRect();
|
||||||
|
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra
|
||||||
|
* "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches
|
||||||
|
* key "ГруппаКонтрагентов").
|
||||||
|
*
|
||||||
|
* Returns `{ x, y, currentText } | null`.
|
||||||
|
*/
|
||||||
|
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return null;
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!head || !body) return null;
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
const cols = [];
|
||||||
|
[...headLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const t = box.querySelector('.gridBoxText');
|
||||||
|
const ci = box.getAttribute('colindex');
|
||||||
|
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() });
|
||||||
|
});
|
||||||
|
const kl = ${JSON.stringify(key.toLowerCase())};
|
||||||
|
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
|
||||||
|
let targetColindex = null;
|
||||||
|
const exact = cols.find(c => c.text === kl);
|
||||||
|
if (exact) targetColindex = exact.colindex;
|
||||||
|
else {
|
||||||
|
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|
||||||
|
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
|
||||||
|
if (inc) targetColindex = inc.colindex;
|
||||||
|
}
|
||||||
|
if (targetColindex == null) return null;
|
||||||
|
const rows = [...body.querySelectorAll('.gridLine')];
|
||||||
|
if (${row} >= rows.length) return null;
|
||||||
|
const line = rows[${row}];
|
||||||
|
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
||||||
|
if (!box) return null;
|
||||||
|
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
const cell = box.querySelector('.gridBoxText') || box;
|
||||||
|
const r = cell.getBoundingClientRect();
|
||||||
|
const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing
|
||||||
|
* a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for
|
||||||
|
* direct click).
|
||||||
|
*
|
||||||
|
* Returns `null` when there's no cell, or the cell isn't a checkbox cell.
|
||||||
|
*/
|
||||||
|
export function findCheckboxAtPointScript(x, y) {
|
||||||
|
return `(() => {
|
||||||
|
const el = document.elementFromPoint(${x}, ${y});
|
||||||
|
const cell = el?.closest('.gridBox');
|
||||||
|
if (!cell) return null;
|
||||||
|
const chk = cell.querySelector('.checkbox');
|
||||||
|
if (!chk) return null;
|
||||||
|
const r = chk.getBoundingClientRect();
|
||||||
|
return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row
|
||||||
|
* OTHER than `row` (used to commit an edit by clicking off the edited row —
|
||||||
|
* Escape would cancel in tree grids).
|
||||||
|
*
|
||||||
|
* For `row === 0`, targets row 1; otherwise targets row 0.
|
||||||
|
*
|
||||||
|
* Returns `{ x, y } | null` (null when there's no other row).
|
||||||
|
*/
|
||||||
|
export function findRowCommitClickCoordsScript(gridSelector, row) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return null;
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return null;
|
||||||
|
const rows = [...body.querySelectorAll('.gridLine')];
|
||||||
|
const otherIdx = ${row} === 0 ? 1 : 0;
|
||||||
|
const other = rows[otherIdx];
|
||||||
|
if (!other) return null;
|
||||||
|
const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||||
|
const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0];
|
||||||
|
if (!box) return null;
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or
|
||||||
|
* `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics:
|
||||||
|
* - `{ inEdit: true }` — good
|
||||||
|
* - `{ inEdit: false, tag: 'DIV' }` — active element wasn't INPUT
|
||||||
|
* - `{ inEdit: false, hint: 'input not inside grid' }` — input but no grid ancestor
|
||||||
|
*/
|
||||||
|
export function getGridEditCheckScript() {
|
||||||
|
return `(() => {
|
||||||
|
const f = document.activeElement;
|
||||||
|
if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName };
|
||||||
|
let node = f;
|
||||||
|
while (node) {
|
||||||
|
if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true };
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return { inEdit: false, hint: 'input not inside grid' };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the currently focused element if it's an editable grid cell (INPUT or
|
||||||
|
* TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by
|
||||||
|
* matching x-overlap of the input's bounding rect against header boxes.
|
||||||
|
*
|
||||||
|
* Returns one of:
|
||||||
|
* - `{ tag: 'INPUT', id, fullName, headerText }` — editable cell
|
||||||
|
* - `{ tag: 'DIV' | 'BODY' | ... }` — focused but not an editable cell
|
||||||
|
* - `{ tag: 'none' }` — nothing focused
|
||||||
|
*
|
||||||
|
* `fullName` strips both `form{N}_` prefix and `_i{M}` suffix.
|
||||||
|
*/
|
||||||
|
export function readActiveGridCellScript() {
|
||||||
|
return `(() => {
|
||||||
|
const f = document.activeElement;
|
||||||
|
if (!f) return { tag: 'none' };
|
||||||
|
if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') {
|
||||||
|
const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })();
|
||||||
|
if (inGrid) {
|
||||||
|
let headerText = '';
|
||||||
|
let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement;
|
||||||
|
if (grid) {
|
||||||
|
const fr = f.getBoundingClientRect();
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
const hl = head?.querySelector('.gridLine') || head;
|
||||||
|
if (hl) for (const h of hl.children) {
|
||||||
|
if (h.offsetWidth === 0) continue;
|
||||||
|
const hr = h.getBoundingClientRect();
|
||||||
|
if (fr.x >= hr.x && fr.x < hr.x + hr.width) {
|
||||||
|
const t = h.querySelector('.gridBoxText');
|
||||||
|
headerText = (t || h).innerText?.trim() || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
|
||||||
|
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
|
||||||
|
let buttonKind = null;
|
||||||
|
const base = f.id.replace(/_i\\d+$/, '');
|
||||||
|
const dlb = document.getElementById(base + '_DLB');
|
||||||
|
const cb = document.getElementById(base + '_CB');
|
||||||
|
if (dlb && dlb.offsetWidth > 0) buttonKind = 'ref';
|
||||||
|
else if (cb && cb.offsetWidth > 0) {
|
||||||
|
if (cb.classList.contains('iCalcB')) buttonKind = 'calc';
|
||||||
|
else if (cb.classList.contains('iCalendB')) buttonKind = 'date';
|
||||||
|
else buttonKind = 'choice';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: 'INPUT', id: f.id,
|
||||||
|
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
|
||||||
|
headerText, buttonKind
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tag: f.tagName || 'none' };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return center coords of the element with the given id.
|
||||||
|
* Returns `{ x, y } | null`.
|
||||||
|
*/
|
||||||
|
export function getElementCenterCoordsByIdScript(elementId) {
|
||||||
|
return `(() => {
|
||||||
|
const el = document.getElementById(${JSON.stringify(elementId)});
|
||||||
|
if (!el) return null;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
// web-test dom/grid v1.9 — grid resolution + table reading + edit-time helpers
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a specific grid by semantic name (table parameter).
|
||||||
|
* Cascade: exact gridName match → gridName contains → column contains.
|
||||||
|
* Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }.
|
||||||
|
*/
|
||||||
|
export function resolveGridScript(formNum, tableName) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))};
|
||||||
|
const norm = s => (s || '').replace(/ё/gi, 'е');
|
||||||
|
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||||
|
if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' };
|
||||||
|
const infos = allGrids.map((g, idx) => {
|
||||||
|
const gridId = g.id || '';
|
||||||
|
const gridName = gridId.replace(p, '');
|
||||||
|
const head = g.querySelector('.gridHead');
|
||||||
|
const columns = [];
|
||||||
|
if (head) {
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
[...headLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const textEl = box.querySelector('.gridBoxText');
|
||||||
|
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||||
|
if (text) columns.push(text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Visual label from group title element
|
||||||
|
const titleEl = document.getElementById(p + gridName + '#title_div')
|
||||||
|
|| document.getElementById(p + 'Группа' + gridName + '#title_div');
|
||||||
|
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : '';
|
||||||
|
return { idx, gridId, gridName, label, columns, el: g };
|
||||||
|
});
|
||||||
|
// 1. Exact gridName match (case-insensitive)
|
||||||
|
let found = infos.find(i => norm(i.gridName).toLowerCase() === target);
|
||||||
|
// 2. Exact label match
|
||||||
|
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target);
|
||||||
|
// 3. gridName contains target
|
||||||
|
if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target));
|
||||||
|
// 4. Label contains target
|
||||||
|
if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target));
|
||||||
|
// 5. Any column contains target
|
||||||
|
if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target)));
|
||||||
|
if (found) {
|
||||||
|
return {
|
||||||
|
gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null,
|
||||||
|
gridId: found.gridId,
|
||||||
|
gridName: found.gridName,
|
||||||
|
gridIndex: found.idx,
|
||||||
|
columns: found.columns
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: 'not_found',
|
||||||
|
message: 'Table "' + ${JSON.stringify(tableName)} + '" not found',
|
||||||
|
available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns }))
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read table/grid data with pagination.
|
||||||
|
* Parses grid.innerText — \n separates rows, \t separates cells.
|
||||||
|
* First row = column headers.
|
||||||
|
* Returns { name, columns[], rows[{col:val}], total, offset, shown }.
|
||||||
|
*/
|
||||||
|
export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const grid = ${gridSelector
|
||||||
|
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||||
|
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||||
|
if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' };
|
||||||
|
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||||
|
|
||||||
|
// Detect a "picture value" cell: a sprite from a picture collection
|
||||||
|
// (.gridBoxImg .dIB with background-image .../pictureCollection/picture/<id>?...&gx=<N>).
|
||||||
|
// Excludes decorative tree/group markers (gridListH/gridListV/[tree]/gridBoxTree).
|
||||||
|
// Returns { gx } — the sprite frame index that encodes the cell state, or null.
|
||||||
|
function picInfo(cell) {
|
||||||
|
if (!cell) return null;
|
||||||
|
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
||||||
|
const dib = cell.querySelector('.gridBoxImg .dIB');
|
||||||
|
if (!dib) return null;
|
||||||
|
const bg = dib.style.backgroundImage || '';
|
||||||
|
if (!bg.includes('pictureCollection/picture/')) return null;
|
||||||
|
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||||
|
return { gx: m ? m[1] : '0' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!head || !body) {
|
||||||
|
// Fallback: innerText-based (for non-standard grids)
|
||||||
|
const gText = grid.innerText?.trim() || '';
|
||||||
|
const lines = gText.split('\\n').filter(Boolean);
|
||||||
|
return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0,
|
||||||
|
hint: 'Grid has no gridHead/gridBody structure' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract column headers with X-coordinates for alignment
|
||||||
|
const columns = [];
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
[...headLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const textEl = box.querySelector('.gridBoxText');
|
||||||
|
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||||
|
if (!text) {
|
||||||
|
// Unnamed column — check if data cells contain checkboxes or pictures.
|
||||||
|
// Picture columns have no header text (only an icon + a title tooltip); 1С
|
||||||
|
// doesn't expose the technical column name in the DOM, so we name them by
|
||||||
|
// the header's title attribute, falling back to '(picture)'.
|
||||||
|
const firstLine = body?.querySelector('.gridLine');
|
||||||
|
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
||||||
|
const idx = visibleHeaders.indexOf(box);
|
||||||
|
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
if (cells[idx]?.querySelector('.checkbox')) {
|
||||||
|
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||||
|
} else if (picInfo(box) || picInfo(cells[idx])) {
|
||||||
|
let title = (box.getAttribute('title') || '').trim() || '(picture)';
|
||||||
|
// Disambiguate duplicate picture-column names with a numeric suffix.
|
||||||
|
if (columns.some(c => c.text === title)) {
|
||||||
|
let n = 2;
|
||||||
|
while (columns.some(c => c.text === title + ' ' + n)) n++;
|
||||||
|
title = title + ' ' + n;
|
||||||
|
}
|
||||||
|
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multi-row grid support: detect stacked/merged headers.
|
||||||
|
// Group headers by X-range. For each group, count data sub-rows from first line.
|
||||||
|
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
|
||||||
|
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
|
||||||
|
const xGroups = new Map();
|
||||||
|
columns.forEach(c => {
|
||||||
|
const key = Math.round(c.x) + ':' + Math.round(c.right);
|
||||||
|
if (!xGroups.has(key)) xGroups.set(key, []);
|
||||||
|
xGroups.get(key).push(c);
|
||||||
|
});
|
||||||
|
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
|
||||||
|
|
||||||
|
const firstDataLine = body?.querySelector('.gridLine');
|
||||||
|
const subRowMap = new Map();
|
||||||
|
if (firstDataLine) {
|
||||||
|
[...firstDataLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
const cx = r.x + r.width / 2;
|
||||||
|
for (const [key, hdrs] of xGroups) {
|
||||||
|
const h0 = hdrs[0];
|
||||||
|
if (cx >= h0.x && cx < h0.right) {
|
||||||
|
if (!subRowMap.has(key)) subRowMap.set(key, []);
|
||||||
|
subRowMap.get(key).push({ y: r.y });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const multiRowGroups = new Map();
|
||||||
|
for (const [key, hdrs] of xGroups) {
|
||||||
|
const subs = subRowMap.get(key);
|
||||||
|
if (!subs || subs.length <= 1) continue;
|
||||||
|
if (hdrs.length >= 2) {
|
||||||
|
multiRowGroups.set(key, hdrs);
|
||||||
|
} else if (hdrs.length === 1 && subs.length > 1) {
|
||||||
|
const base = hdrs[0];
|
||||||
|
const baseIdx = columns.indexOf(base);
|
||||||
|
columns.splice(baseIdx, 1);
|
||||||
|
const expanded = [];
|
||||||
|
for (let si = 0; si < subs.length; si++) {
|
||||||
|
const numbered = {
|
||||||
|
text: base.text + ' ' + (si + 1),
|
||||||
|
x: base.x, w: base.w, right: base.right,
|
||||||
|
y: base.y + si, h: base.h / subs.length, _subIdx: si
|
||||||
|
};
|
||||||
|
columns.splice(baseIdx + si, 0, numbered);
|
||||||
|
expanded.push(numbered);
|
||||||
|
}
|
||||||
|
multiRowGroups.set(key, expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchColumn(cellX, cellW, cellY) {
|
||||||
|
const cx = cellX + cellW / 2;
|
||||||
|
for (const [key, hdrs] of multiRowGroups) {
|
||||||
|
const h0 = hdrs[0];
|
||||||
|
if (cx >= h0.x && cx < h0.right) {
|
||||||
|
const subs = subRowMap.get(key);
|
||||||
|
if (subs) {
|
||||||
|
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
|
||||||
|
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
|
||||||
|
}
|
||||||
|
let best = hdrs[0], bestDist = Infinity;
|
||||||
|
for (const h of hdrs) {
|
||||||
|
const dist = Math.abs(cellY - h.y);
|
||||||
|
if (dist < bestDist) { bestDist = dist; best = h; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return columns.find(c => cx >= c.x && cx < c.right);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data rows from gridBody
|
||||||
|
const allLines = body.querySelectorAll('.gridLine');
|
||||||
|
const total = allLines.length;
|
||||||
|
const rows = [];
|
||||||
|
const end = Math.min(${offset} + ${maxRows}, total);
|
||||||
|
for (let i = ${offset}; i < end; i++) {
|
||||||
|
const line = allLines[i];
|
||||||
|
if (!line) break;
|
||||||
|
const row = {};
|
||||||
|
columns.forEach(c => { row[c.text] = ''; });
|
||||||
|
[...line.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const textEl = box.querySelector('.gridBoxText');
|
||||||
|
const chk = box.querySelector('.checkbox');
|
||||||
|
let val;
|
||||||
|
if (chk) {
|
||||||
|
val = chk.classList.contains('select') ? 'true' : 'false';
|
||||||
|
} else {
|
||||||
|
val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||||
|
if (!val) {
|
||||||
|
// Empty text → maybe a picture cell. 'pic:<gx>' encodes the sprite frame
|
||||||
|
// (state). Absent picture stays '' (truthy check distinguishes presence).
|
||||||
|
const pic = picInfo(box);
|
||||||
|
if (pic) val = 'pic:' + pic.gx;
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Match cell to column by X+Y overlap (multi-row aware)
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
const col = matchColumn(r.x, r.width, r.y);
|
||||||
|
if (col) {
|
||||||
|
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Detect row kind: group (gridListH), parent/up (gridListV), or element
|
||||||
|
const imgBox = line.querySelector('.gridBoxImg');
|
||||||
|
if (imgBox) {
|
||||||
|
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
||||||
|
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
||||||
|
}
|
||||||
|
// Tree mode: detect expand/collapse state and indent level
|
||||||
|
const treeBox = line.querySelector('.gridBoxTree');
|
||||||
|
if (treeBox) {
|
||||||
|
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
||||||
|
if (treeIcon) {
|
||||||
|
const bg = treeIcon.style.backgroundImage || '';
|
||||||
|
row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed';
|
||||||
|
}
|
||||||
|
row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0;
|
||||||
|
}
|
||||||
|
// Selection state: selRow = selected row in grid
|
||||||
|
if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true;
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
const isTree = !!body.querySelector('.gridBoxTree');
|
||||||
|
const hasGroups = rows.some(r => r._kind === 'group');
|
||||||
|
// Virtualization-aware hasMore signal. Three sources in priority order:
|
||||||
|
// 1. Dynamic-list turn buttons (#vertButtonScroll_<gridId>, sibling of grid).
|
||||||
|
// Buttons carry data-home/data-up (above) and data-down/data-end (below);
|
||||||
|
// class "disabled" on a direction means nothing to show there.
|
||||||
|
// 2. Tabular-section scrollbar (#vertScroll_<gridId>, class scrollV) —
|
||||||
|
// track-back/track-next pixel heights tell us above/below precisely.
|
||||||
|
// 3. Fallback: scrollHeight>clientHeight for "below"; "above" unknown.
|
||||||
|
let hasMore;
|
||||||
|
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
||||||
|
if (turnsBox && turnsBox.offsetHeight > 0) {
|
||||||
|
const upBtns = turnsBox.querySelectorAll('[data-home], [data-up]');
|
||||||
|
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
||||||
|
hasMore = {
|
||||||
|
above: [...upBtns].some(b => !b.classList.contains('disabled')),
|
||||||
|
below: [...dnBtns].some(b => !b.classList.contains('disabled')),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const vsId = 'vertScroll_' + grid.id;
|
||||||
|
const vs = document.getElementById(vsId);
|
||||||
|
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||||
|
const back = vs.querySelector('[data-track-back]')?.offsetHeight ?? 0;
|
||||||
|
const next = vs.querySelector('[data-track-next]')?.offsetHeight ?? 0;
|
||||||
|
hasMore = { above: back > 0, below: next > 0 };
|
||||||
|
} else {
|
||||||
|
hasMore = { below: body.scrollHeight > body.clientHeight };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
||||||
|
if (isTree) result.viewMode = 'tree';
|
||||||
|
if (hasGroups) result.hierarchical = true;
|
||||||
|
return result;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Edit-time grid helpers (for fillTableRow / row-fill) ────────────────────
|
||||||
|
//
|
||||||
|
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||||
|
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||||
|
// the page (this matches the implicit "current grid" used by row-fill).
|
||||||
|
|
||||||
|
/** Inline JS fragment that resolves the target grid into `const grid`. */
|
||||||
|
function gridResolver(gridSelector) {
|
||||||
|
return gridSelector
|
||||||
|
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||||
|
: `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find center coords of a target row for click-select (used by deleteTableRow).
|
||||||
|
* Picks the second visible gridBox container in the row (skips row-number/checkbox col).
|
||||||
|
*
|
||||||
|
* Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`.
|
||||||
|
*/
|
||||||
|
export function findDeleteRowCoordsScript(gridSelector, row) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return { error: 'no_grid' };
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return { error: 'no_grid_body' };
|
||||||
|
const rows = [...body.querySelectorAll('.gridLine')];
|
||||||
|
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||||
|
const line = rows[${row}];
|
||||||
|
// Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes
|
||||||
|
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||||
|
// Skip first column (row number / checkbox) — pick second visible box
|
||||||
|
const box = boxes.length > 1 ? boxes[1] : boxes[0];
|
||||||
|
if (!box) return { error: 'no_cell' };
|
||||||
|
const cell = box.querySelector('.gridBoxText') || box;
|
||||||
|
const r = cell.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count `.gridLine` rows in the body of the target grid.
|
||||||
|
* Returns the row count, or `0` when grid/body absent.
|
||||||
|
*/
|
||||||
|
export function countGridRowsScript(gridSelector) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
const body = grid?.querySelector('.gridBody');
|
||||||
|
return body ? body.querySelectorAll('.gridLine').length : 0;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the target grid a tree grid? (presence of `.gridBoxTree`)
|
||||||
|
* Returns boolean.
|
||||||
|
*/
|
||||||
|
export function isTreeGridScript(gridSelector) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
return grid ? !!grid.querySelector('.gridBoxTree') : false;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return center coords of the grid's `.gridHead` element.
|
||||||
|
* Used as a click target to commit a pending cell edit (clicking the header
|
||||||
|
* defocuses the input without selecting another row).
|
||||||
|
*
|
||||||
|
* Returns `{ x, y } | null`.
|
||||||
|
*/
|
||||||
|
export function findGridHeadCenterCoordsScript(gridSelector) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return null;
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
if (!head) return null;
|
||||||
|
const r = head.getBoundingClientRect();
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the index of the currently selected row in the target grid, or
|
||||||
|
* fall back to the last row when nothing is selected.
|
||||||
|
*
|
||||||
|
* Returns row index, or `-1` when no rows.
|
||||||
|
*/
|
||||||
|
export function getSelectedOrLastRowIndexScript(gridSelector) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return -1;
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return -1;
|
||||||
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
|
const sel = lines.findIndex(l => l.classList.contains('selected'));
|
||||||
|
return sel >= 0 ? sel : lines.length - 1;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive,
|
||||||
|
* NBSP-normalised). Match order: exact → startsWith → includes.
|
||||||
|
*
|
||||||
|
* When `searchLower` is empty, returns coords of the first row (fallback).
|
||||||
|
*
|
||||||
|
* Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`.
|
||||||
|
*/
|
||||||
|
export function scanGridRowsScript(formNum, searchLower) {
|
||||||
|
return `(() => {
|
||||||
|
const p = 'form${formNum}_';
|
||||||
|
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
|
||||||
|
if (!grid) return null;
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return null;
|
||||||
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
|
if (!lines.length) return { rowCount: 0 };
|
||||||
|
const searchLower = ${JSON.stringify(searchLower || '')};
|
||||||
|
let sel = null;
|
||||||
|
if (searchLower) {
|
||||||
|
const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е');
|
||||||
|
const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) }));
|
||||||
|
sel = rowData.find(r => r.text === searchLower)?.el
|
||||||
|
|| rowData.find(r => r.text.startsWith(searchLower))?.el
|
||||||
|
|| rowData.find(r => r.text.includes(searchLower))?.el;
|
||||||
|
} else {
|
||||||
|
sel = lines[0]; // empty search → first row
|
||||||
|
}
|
||||||
|
if (!sel) return null;
|
||||||
|
const imgBox = sel.querySelector('.gridBoxImg');
|
||||||
|
const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false;
|
||||||
|
const r = sel.getBoundingClientRect();
|
||||||
|
return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cell-click DOM scripts (for clickElement({row, column}) on grids) ───────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a target cell in a grid by (row, column).
|
||||||
|
* - `column` matched: exact (case+ё-insensitive) → endsWith ' / X' → includes.
|
||||||
|
* - `row`: number = index in current DOM window; object = {col: value, ...} filter
|
||||||
|
* (matches first non-group/parent row where every column condition passes).
|
||||||
|
*
|
||||||
|
* Returns `{ x, y, cellX, cellRight, gridX, gridRight, columnText, rowIdx, cellText, visible } | { error, ... }`.
|
||||||
|
*
|
||||||
|
* Visibility (`visible`) is true when the cell is fully within the grid's horizontal viewport.
|
||||||
|
* Callers should horizontally scroll first if `visible === false`.
|
||||||
|
*/
|
||||||
|
export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const norm = s => (s || '').replace(/\\u00a0/g, ' ').replace(/ё/gi, 'е').trim();
|
||||||
|
const lo = s => norm(s).toLowerCase();
|
||||||
|
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const grid = ${gridSelector
|
||||||
|
? `document.querySelector(${JSON.stringify(gridSelector)})`
|
||||||
|
: `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`};
|
||||||
|
if (!grid) return { error: 'no_grid' };
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!head || !body) return { error: 'no_grid_structure' };
|
||||||
|
|
||||||
|
// Header X-ranges (mirror of readTableScript logic, simplified). We also
|
||||||
|
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
|
||||||
|
// columns can share X coordinates after horizontal scroll, so cell matching
|
||||||
|
// must respect the frozen/scrollable partition.
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
const headers = [...headLine.children]
|
||||||
|
.filter(c => c.offsetWidth > 0)
|
||||||
|
.map(c => {
|
||||||
|
const textEl = c.querySelector('.gridBoxText');
|
||||||
|
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||||
|
// Picture/icon columns have no header text — fall back to the title tooltip
|
||||||
|
// (mirrors readTable naming) so they can still be targeted for clicking.
|
||||||
|
const title = (c.getAttribute('title') || '').trim();
|
||||||
|
const r = c.getBoundingClientRect();
|
||||||
|
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
||||||
|
})
|
||||||
|
.filter(h => h.name);
|
||||||
|
|
||||||
|
const resolveCol = (name) => {
|
||||||
|
const suffix = ' / ' + name;
|
||||||
|
const cand = h => [h.text, h.title].filter(Boolean);
|
||||||
|
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|
||||||
|
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|
||||||
|
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetCol = ${JSON.stringify(column)};
|
||||||
|
const col = resolveCol(targetCol);
|
||||||
|
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
|
||||||
|
|
||||||
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
|
if (lines.length === 0) return { error: 'empty_grid' };
|
||||||
|
|
||||||
|
// Match cell to column by X overlap, but only among cells with the same
|
||||||
|
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
|
||||||
|
// cell may have the same x as a frozen one — without this guard cellAtColX
|
||||||
|
// would silently return the frozen cell for a scrollable header.
|
||||||
|
const cellAtColX = (line, c) => [...line.children]
|
||||||
|
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
||||||
|
.find(b => {
|
||||||
|
const r = b.getBoundingClientRect();
|
||||||
|
const cx = r.x + r.width / 2;
|
||||||
|
return cx >= c.x && cx < c.right;
|
||||||
|
});
|
||||||
|
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
||||||
|
|
||||||
|
const target = ${JSON.stringify(row)};
|
||||||
|
let line, rowIdx;
|
||||||
|
if (typeof target === 'number') {
|
||||||
|
if (target < 0 || target >= lines.length) {
|
||||||
|
return { error: 'row_out_of_range', row: target, loaded: lines.length };
|
||||||
|
}
|
||||||
|
line = lines[target];
|
||||||
|
rowIdx = target;
|
||||||
|
} else if (target && typeof target === 'object') {
|
||||||
|
const entries = Object.entries(target);
|
||||||
|
const colsByKey = {};
|
||||||
|
for (const [k] of entries) {
|
||||||
|
const c = resolveCol(k);
|
||||||
|
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
|
||||||
|
colsByKey[k] = c;
|
||||||
|
}
|
||||||
|
const matches = (ln) => {
|
||||||
|
for (const [k, v] of entries) {
|
||||||
|
const c = colsByKey[k];
|
||||||
|
const cell = cellAtColX(ln, c);
|
||||||
|
const txt = cellText(cell);
|
||||||
|
const wanted = lo(v);
|
||||||
|
if (!txt) return false;
|
||||||
|
const t = txt.toLowerCase();
|
||||||
|
if (!(t === wanted || t.includes(wanted))) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
rowIdx = lines.findIndex(matches);
|
||||||
|
if (rowIdx < 0) return { error: 'row_not_found', filter: target };
|
||||||
|
line = lines[rowIdx];
|
||||||
|
} else {
|
||||||
|
return { error: 'invalid_row_type' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = cellAtColX(line, col);
|
||||||
|
if (!cell) return { error: 'cell_not_in_dom', column: col.name, rowIdx };
|
||||||
|
const r = cell.getBoundingClientRect();
|
||||||
|
const gridBox = grid.getBoundingClientRect();
|
||||||
|
// Frozen columns (.gridBoxFix) stay pinned at the left edge of the grid even
|
||||||
|
// when the rest scrolls horizontally. For non-frozen cells, "visible" means
|
||||||
|
// inside the SCROLLABLE viewport (right of any frozen columns). Frozen cells
|
||||||
|
// are always visible by definition.
|
||||||
|
const isFixed = cell.classList.contains('gridBoxFix');
|
||||||
|
let scrollableLeft = gridBox.x;
|
||||||
|
if (!isFixed) {
|
||||||
|
[...line.children].forEach(b => {
|
||||||
|
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||||
|
const br = b.getBoundingClientRect();
|
||||||
|
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// "Visible enough to click" — the cell's CENTER is inside the scrollable area
|
||||||
|
// and the cell's right edge is inside the grid. Strict left-edge check would
|
||||||
|
// reject cells that 1С rendered touching the frozen-column boundary (off by 1px).
|
||||||
|
const center = r.x + r.width / 2;
|
||||||
|
const visible = center >= scrollableLeft && center <= (gridBox.x + gridBox.width) && (r.x + r.width) <= (gridBox.x + gridBox.width);
|
||||||
|
return {
|
||||||
|
x: Math.round(r.x + r.width / 2),
|
||||||
|
y: Math.round(r.y + r.height / 2),
|
||||||
|
cellX: Math.round(r.x), cellRight: Math.round(r.x + r.width),
|
||||||
|
gridX: Math.round(gridBox.x), gridRight: Math.round(gridBox.x + gridBox.width),
|
||||||
|
scrollableLeft: Math.round(scrollableLeft),
|
||||||
|
columnText: col.name, rowIdx, isFixed,
|
||||||
|
cellText: cellText(cell),
|
||||||
|
visible
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick coordinates for a focus-click on a safe cell within the grid.
|
||||||
|
*
|
||||||
|
* Used both for vertical reveal-loop focus and for horizontal-scroll edge focus.
|
||||||
|
* The caller passes a profile that selects which row, which cells to exclude,
|
||||||
|
* and (for horizontal scroll) which edge of the row to take.
|
||||||
|
*
|
||||||
|
* @param {string} gridSelector
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} [opts.rowIdx] - Pick from this row; falls back to first non-group/parent data row.
|
||||||
|
* @param {'ArrowRight'|'ArrowLeft'} [opts.direction]
|
||||||
|
* - When set, restricts to non-frozen FULLY visible cells and picks the edge
|
||||||
|
* cell in that direction (rightmost for ArrowRight, leftmost for ArrowLeft).
|
||||||
|
* - When omitted, picks a generic safe cell (skips first column to avoid tree-toggles).
|
||||||
|
*
|
||||||
|
* Always prefers non-checkbox cells (center-click on a checkbox would toggle it).
|
||||||
|
*
|
||||||
|
* Returns `{ x, y } | null`.
|
||||||
|
*/
|
||||||
|
export function findFocusCellScript(gridSelector, { rowIdx, direction } = {}) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return null;
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return null;
|
||||||
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
|
if (!lines.length) return null;
|
||||||
|
|
||||||
|
const rowIdx = ${rowIdx == null ? 'null' : JSON.stringify(rowIdx)};
|
||||||
|
const direction = ${direction ? JSON.stringify(direction) : 'null'};
|
||||||
|
|
||||||
|
const line = (rowIdx != null && lines[rowIdx])
|
||||||
|
|| lines.find(ln => {
|
||||||
|
const imgBox = ln.querySelector('.gridBoxImg');
|
||||||
|
return !imgBox?.querySelector('.gridListH, .gridListV');
|
||||||
|
})
|
||||||
|
|| lines[0];
|
||||||
|
if (!line) return null;
|
||||||
|
|
||||||
|
let candidates;
|
||||||
|
if (direction) {
|
||||||
|
// Horizontal-scroll mode: edge cell in the scrollable area, exclude frozen.
|
||||||
|
const gridBox = grid.getBoundingClientRect();
|
||||||
|
let scrollableLeft = gridBox.x;
|
||||||
|
[...line.children].forEach(b => {
|
||||||
|
if (b.offsetWidth > 0 && b.classList.contains('gridBoxFix')) {
|
||||||
|
const br = b.getBoundingClientRect();
|
||||||
|
if (br.x + br.width > scrollableLeft) scrollableLeft = br.x + br.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const visible = [...line.children]
|
||||||
|
.filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxFix'))
|
||||||
|
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }))
|
||||||
|
.filter(({ r }) => r.x >= scrollableLeft && (r.x + r.width) <= (gridBox.x + gridBox.width));
|
||||||
|
if (!visible.length) return null;
|
||||||
|
visible.sort((a, b) => a.r.x - b.r.x);
|
||||||
|
candidates = direction === 'ArrowRight' ? [...visible].reverse() : visible;
|
||||||
|
} else {
|
||||||
|
// Generic focus mode (used by reveal-loop): pick the FIRST visible cell —
|
||||||
|
// typically a Reference column (Номенклатура in документах) which doesn't
|
||||||
|
// auto-enter edit mode on click. Number/Date/String cells auto-edit and
|
||||||
|
// break subsequent PageDown navigation.
|
||||||
|
// For tree grids (presence of .gridBoxTree), skip first column to avoid
|
||||||
|
// toggling expand/collapse of the row.
|
||||||
|
const isTree = !!body.querySelector('.gridBoxTree');
|
||||||
|
const cells = [...line.children]
|
||||||
|
.filter(b => b.offsetWidth > 0)
|
||||||
|
.map(b => ({ b, r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox') }));
|
||||||
|
if (!cells.length) return null;
|
||||||
|
candidates = isTree && cells.length > 1 ? cells.slice(1) : cells;
|
||||||
|
}
|
||||||
|
const pick = candidates.find(v => !v.checkbox) || candidates[0];
|
||||||
|
if (!pick) return null;
|
||||||
|
return { x: Math.round(pick.r.x + pick.r.width / 2), y: Math.round(pick.r.y + pick.r.height / 2) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot grid state for reveal-loop end detection.
|
||||||
|
* Returns `{ firstText, lastText, lineCount, selIdx, hasBelow }`.
|
||||||
|
*
|
||||||
|
* `firstText`/`lastText` use the first cell's `.gridBoxText` content.
|
||||||
|
* `hasBelow` is derived from scrollbar widget tracks when visible, else from scrollHeight>clientHeight.
|
||||||
|
*/
|
||||||
|
export function snapshotGridScript(gridSelector) {
|
||||||
|
return `(() => {
|
||||||
|
const grid = ${gridResolver(gridSelector)};
|
||||||
|
if (!grid) return null;
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return null;
|
||||||
|
const lines = body.querySelectorAll('.gridLine');
|
||||||
|
// Full-row signature: join EVERY cell's text, not just the first column.
|
||||||
|
// A low-cardinality first column (e.g. all "Товар 0X") would otherwise make
|
||||||
|
// two different windows look identical and abort the reveal-loop early.
|
||||||
|
const txt = ln => ln ? [...ln.querySelectorAll('.gridBoxText')].map(b => (b.innerText || '').trim()).join('|') : '';
|
||||||
|
const selIdx = [...lines].findIndex(l => l.classList.contains('selRow') || l.classList.contains('select'));
|
||||||
|
// hasBelow priority: (1) dynamic-list turn buttons, (2) tabular scrollbar tracks, (3) scrollHeight.
|
||||||
|
let hasBelow;
|
||||||
|
const turnsBox = document.getElementById('vertButtonScroll_' + grid.id);
|
||||||
|
if (turnsBox && turnsBox.offsetHeight > 0) {
|
||||||
|
const dnBtns = turnsBox.querySelectorAll('[data-down], [data-end]');
|
||||||
|
hasBelow = [...dnBtns].some(b => !b.classList.contains('disabled'));
|
||||||
|
} else {
|
||||||
|
const vs = document.getElementById('vertScroll_' + grid.id);
|
||||||
|
if (vs && vs.classList.contains('scrollV') && vs.offsetWidth > 0) {
|
||||||
|
hasBelow = (vs.querySelector('[data-track-next]')?.offsetHeight ?? 0) > 0;
|
||||||
|
} else {
|
||||||
|
hasBelow = body.scrollHeight > body.clientHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
firstText: txt(lines[0]),
|
||||||
|
lastText: txt(lines[lines.length - 1]),
|
||||||
|
lineCount: lines.length,
|
||||||
|
selIdx,
|
||||||
|
hasBelow
|
||||||
|
};
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the click target kind for `clickElement({row, column})`.
|
||||||
|
*
|
||||||
|
* Routing:
|
||||||
|
* - `tableName` specified: try to match a visible grid by name (exact → contains).
|
||||||
|
* If matched → grid. Else if form has a spreadsheet iframe → spreadsheet. Else error.
|
||||||
|
* - `tableName` omitted: spreadsheet iframe present → spreadsheet (backward-compat).
|
||||||
|
* Else first visible grid. Else error.
|
||||||
|
*
|
||||||
|
* Returns `{ kind: 'spreadsheet' } | { kind: 'grid', gridSelector, gridName } | { error, ... }`.
|
||||||
|
*/
|
||||||
|
export function resolveCellTargetScript(formNum, tableName) {
|
||||||
|
const p = `form${formNum}_`;
|
||||||
|
return `(() => {
|
||||||
|
const p = ${JSON.stringify(p)};
|
||||||
|
const tableName = ${JSON.stringify(tableName || '')};
|
||||||
|
// Spreadsheet = iframe under form prefix with non-trivial width.
|
||||||
|
const hasSpreadsheet = [...document.querySelectorAll('iframe')].some(f => {
|
||||||
|
if (f.offsetWidth < 100) return false;
|
||||||
|
let el = f.parentElement;
|
||||||
|
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
||||||
|
if (el.id && el.id.startsWith(p)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const grids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
|
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||||
|
const norm = s => (s || '').replace(/ё/gi, 'е').toLowerCase();
|
||||||
|
|
||||||
|
if (tableName) {
|
||||||
|
const target = norm(tableName);
|
||||||
|
const matched = grids.find(g => norm(g.id.replace(p, '')) === target)
|
||||||
|
|| grids.find(g => norm(g.id.replace(p, '')).includes(target));
|
||||||
|
if (matched) {
|
||||||
|
return { kind: 'grid', gridSelector: '#' + CSS.escape(matched.id), gridName: matched.id.replace(p, '') };
|
||||||
|
}
|
||||||
|
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||||
|
return { error: 'table_not_found', table: tableName, availableGrids: grids.map(g => g.id.replace(p, '')) };
|
||||||
|
}
|
||||||
|
if (hasSpreadsheet) return { kind: 'spreadsheet' };
|
||||||
|
if (grids.length > 0) {
|
||||||
|
const g = grids[0];
|
||||||
|
return { kind: 'grid', gridSelector: '#' + CSS.escape(g.id), gridName: g.id.replace(p, '') };
|
||||||
|
}
|
||||||
|
return { error: 'no_spreadsheet_or_grid' };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
/** Read sections panel (left sidebar). */
|
||||||
|
export function readSectionsScript() {
|
||||||
|
return `(() => {
|
||||||
|
const sections = [];
|
||||||
|
document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => {
|
||||||
|
const entry = { name: el.innerText?.trim() || '' };
|
||||||
|
if (el.classList.contains('select')) entry.active = true;
|
||||||
|
sections.push(entry);
|
||||||
|
});
|
||||||
|
return sections;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read open tabs bar. */
|
||||||
|
export function readTabsScript() {
|
||||||
|
return `(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const tabs = [];
|
||||||
|
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
||||||
|
const text = norm(el.innerText);
|
||||||
|
if (!text) return;
|
||||||
|
const entry = { name: text };
|
||||||
|
if (el.classList.contains('select')) entry.active = true;
|
||||||
|
tabs.push(entry);
|
||||||
|
});
|
||||||
|
return tabs;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */
|
||||||
|
export function switchTabScript(name) {
|
||||||
|
return `(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
||||||
|
const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText));
|
||||||
|
let best = tabs.find(el => norm(el.innerText).toLowerCase() === target);
|
||||||
|
if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||||
|
if (best) { best.click(); return norm(best.innerText); }
|
||||||
|
return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read commands in the function panel (current section). */
|
||||||
|
export function readCommandsScript() {
|
||||||
|
return `(() => {
|
||||||
|
const groups = [];
|
||||||
|
const container = document.querySelector('#funcPanel_container table tr');
|
||||||
|
if (!container) return groups;
|
||||||
|
for (const td of container.children) {
|
||||||
|
const commands = [];
|
||||||
|
td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
commands.push(el.innerText?.trim() || '');
|
||||||
|
});
|
||||||
|
if (commands.length > 0) groups.push(commands);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a section by name (fuzzy match).
|
||||||
|
* Returns the matched section name, or { error, available }.
|
||||||
|
*/
|
||||||
|
export function navigateSectionScript(name) {
|
||||||
|
return `(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))};
|
||||||
|
const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
||||||
|
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
||||||
|
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||||
|
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
||||||
|
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a command from function panel by name (fuzzy match).
|
||||||
|
*/
|
||||||
|
export function openCommandScript(name) {
|
||||||
|
return `(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))};
|
||||||
|
const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0);
|
||||||
|
let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target);
|
||||||
|
if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target));
|
||||||
|
if (bestEl) { bestEl.click(); return norm(bestEl.innerText); }
|
||||||
|
return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// web-test dom/submenu v1.0 — popup/submenu reading and clicking
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read open popup/submenu items.
|
||||||
|
* Looks for absolutely positioned visible popup containers with a.press items inside.
|
||||||
|
* Returns [{ id, name }] or { error }.
|
||||||
|
*/
|
||||||
|
export function readSubmenuScript() {
|
||||||
|
return `(() => {
|
||||||
|
const items = [];
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
|
||||||
|
// 1. DLB dropdown (#editDropDown with .eddText items)
|
||||||
|
const edd = document.getElementById('editDropDown');
|
||||||
|
if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) {
|
||||||
|
edd.querySelectorAll('.eddText').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const text = norm(el.innerText);
|
||||||
|
if (!text) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
items.push({ id: '', name: text, kind: 'dropdown',
|
||||||
|
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||||
|
});
|
||||||
|
// Detect "Показать все" link in EDD footer
|
||||||
|
// Structure: div.eddBottom > div > span.hyperlink "Показать все"
|
||||||
|
let showAllEl = edd.querySelector('.eddBottom .hyperlink');
|
||||||
|
if (!showAllEl || showAllEl.offsetWidth === 0) {
|
||||||
|
// Fallback: scan all visible elements for text match
|
||||||
|
const candidates = [...edd.querySelectorAll('a.press, a, span, div')]
|
||||||
|
.filter(el => el.offsetWidth > 0 && el.children.length === 0);
|
||||||
|
showAllEl = candidates.find(el => {
|
||||||
|
const t = norm(el.innerText).toLowerCase();
|
||||||
|
return t === 'показать все' || t === 'show all';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (showAllEl) {
|
||||||
|
const r = showAllEl.getBoundingClientRect();
|
||||||
|
items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll',
|
||||||
|
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||||
|
}
|
||||||
|
if (items.length > 0) return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items)
|
||||||
|
// Read ALL visible high-z clouds (main menu + nested submenus)
|
||||||
|
const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0);
|
||||||
|
const seen = new Set();
|
||||||
|
clouds.forEach(c => {
|
||||||
|
const z = parseInt(getComputedStyle(c).zIndex) || 0;
|
||||||
|
if (z <= 1000) return;
|
||||||
|
c.querySelectorAll('.submenuText').forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
const text = norm(el.innerText);
|
||||||
|
if (!text || seen.has(text)) return;
|
||||||
|
seen.add(text);
|
||||||
|
const block = el.closest('.submenuBlock');
|
||||||
|
if (block && block.classList.contains('submenuBlockDisabled')) return;
|
||||||
|
const hasSub = block && /_sub$/.test(block.id);
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu',
|
||||||
|
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (items.length > 0) return items;
|
||||||
|
|
||||||
|
// 3. Submenu popups — find the topmost positioned container with non-form a.press items
|
||||||
|
const popups = [...document.querySelectorAll('div')].filter(c => {
|
||||||
|
const style = getComputedStyle(c);
|
||||||
|
return (style.position === 'absolute' || style.position === 'fixed')
|
||||||
|
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const za = parseInt(getComputedStyle(a).zIndex) || 0;
|
||||||
|
const zb = parseInt(getComputedStyle(b).zIndex) || 0;
|
||||||
|
return zb - za;
|
||||||
|
});
|
||||||
|
for (const container of popups) {
|
||||||
|
// Only direct a.press children or those not nested in another positioned div
|
||||||
|
const menuItems = [...container.querySelectorAll('a.press')].filter(el => {
|
||||||
|
if (el.offsetWidth === 0) return false;
|
||||||
|
if (el.id && /^form\\d+_/.test(el.id)) return false;
|
||||||
|
// Skip if this a.press is inside a deeper positioned container
|
||||||
|
let parent = el.parentElement;
|
||||||
|
while (parent && parent !== container) {
|
||||||
|
const ps = getComputedStyle(parent).position;
|
||||||
|
if (ps === 'absolute' || ps === 'fixed') return false;
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (menuItems.length < 2) continue; // Not a real menu
|
||||||
|
const seen = new Set();
|
||||||
|
menuItems.forEach(el => {
|
||||||
|
const text = norm(el.innerText);
|
||||||
|
if (!text) return;
|
||||||
|
if (seen.has(text)) return;
|
||||||
|
seen.add(text);
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
items.push({ id: el.id || '', name: text, kind: 'submenu',
|
||||||
|
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
|
||||||
|
});
|
||||||
|
if (items.length > 0) break; // Found the popup menu
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' };
|
||||||
|
return items;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a popup/dropdown item by text match (evaluate-based for items without IDs).
|
||||||
|
* Returns true if clicked, false if not found.
|
||||||
|
*/
|
||||||
|
export function clickPopupItemScript(text) {
|
||||||
|
return `(() => {
|
||||||
|
const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))};
|
||||||
|
// 1. DLB dropdown (#editDropDown .eddText items)
|
||||||
|
const edd = document.getElementById('editDropDown');
|
||||||
|
if (edd && edd.offsetWidth > 0) {
|
||||||
|
for (const el of edd.querySelectorAll('.eddText')) {
|
||||||
|
if (el.offsetWidth === 0) continue;
|
||||||
|
const t = el.innerText?.trim() || '';
|
||||||
|
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
||||||
|
el.click();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Submenu popups (a.press in absolutely positioned containers)
|
||||||
|
const containers = [...document.querySelectorAll('div')].filter(c => {
|
||||||
|
const style = getComputedStyle(c);
|
||||||
|
return (style.position === 'absolute' || style.position === 'fixed')
|
||||||
|
&& c.offsetWidth > 0 && c.offsetHeight > 0;
|
||||||
|
});
|
||||||
|
for (const container of containers) {
|
||||||
|
const items = [...container.querySelectorAll('a.press')]
|
||||||
|
.filter(el => el.offsetWidth > 0);
|
||||||
|
for (const el of items) {
|
||||||
|
const t = el.innerText?.trim() || '';
|
||||||
|
if (t.toLowerCase() === target || t.toLowerCase().includes(target)) {
|
||||||
|
el.click();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// web-test core/click v1.22 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element / field-focus handlers by target kind.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page, ensureConnected, highlightMode } from './state.mjs';
|
||||||
|
import {
|
||||||
|
detectFormScript, findClickTargetScript, resolveGridScript,
|
||||||
|
readSubmenuScript, resolveCellTargetScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
||||||
|
import { waitForStable } from './wait.mjs';
|
||||||
|
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||||
|
import { modifierClick, returnFormState } from './helpers.mjs';
|
||||||
|
import {
|
||||||
|
clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget,
|
||||||
|
} from '../table/click-row.mjs';
|
||||||
|
import { clickGridCell } from '../table/click-cell.mjs';
|
||||||
|
import {
|
||||||
|
clickConfirmationButton, tryClickPopupItem,
|
||||||
|
} from '../forms/click-popup.mjs';
|
||||||
|
import { clickFormTarget, focusFormField } from '../forms/click-form.mjs';
|
||||||
|
import {
|
||||||
|
clickSpreadsheetCell, findSpreadsheetCellByText,
|
||||||
|
} from '../spreadsheet/spreadsheet.mjs';
|
||||||
|
|
||||||
|
/** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists).
|
||||||
|
* First argument can also be an object { row, column } to click a cell in a SpreadsheetDocument (отчёт) or a form grid (таблица/табчасть). */
|
||||||
|
export async function clickElement(text, { dblclick, table, toggle, expand, modifier, scroll, timeout } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
|
||||||
|
// Dispatch to cell handler when first arg is { row, column }.
|
||||||
|
// Routing (see resolveCellTargetScript):
|
||||||
|
// - `table` named: matches grid → grid cell; falls back to spreadsheet if it's the spreadsheet's name.
|
||||||
|
// - no `table`: form has spreadsheet → spreadsheet cell (backward-compat);
|
||||||
|
// else first visible grid → grid cell.
|
||||||
|
if (typeof text === 'object' && text !== null && text.column != null) {
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error('clickElement: no form found');
|
||||||
|
const route = await page.evaluate(resolveCellTargetScript(formNum, table));
|
||||||
|
if (route.error === 'table_not_found') {
|
||||||
|
throw new Error(`clickElement: table "${table}" not found. Available grids: ${(route.availableGrids || []).join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
if (route.error) {
|
||||||
|
throw new Error(`clickElement: no spreadsheet or grid on form to click cell in.`);
|
||||||
|
}
|
||||||
|
if (route.kind === 'spreadsheet') {
|
||||||
|
return clickSpreadsheetCell(text, { dblclick, modifier });
|
||||||
|
}
|
||||||
|
// route.kind === 'grid'
|
||||||
|
return clickGridCell(text, {
|
||||||
|
formNum,
|
||||||
|
gridSelector: route.gridSelector,
|
||||||
|
gridName: route.gridName,
|
||||||
|
modifier, dblclick, scroll,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await dismissPendingErrors();
|
||||||
|
if (highlightMode) {
|
||||||
|
try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Intercept open confirmation dialog (Да/Нет/Отмена) — match button by text.
|
||||||
|
const pending = await checkForErrors();
|
||||||
|
if (pending?.confirmation) {
|
||||||
|
return await clickConfirmationButton(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Intercept open popup (from previous submenu/split-button click).
|
||||||
|
// Returns null if popup is open but `text` doesn't match — fall through.
|
||||||
|
const popupItems = await page.evaluate(readSubmenuScript());
|
||||||
|
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||||
|
const popupResult = await tryClickPopupItem(text, popupItems);
|
||||||
|
if (popupResult) return popupResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Find a target on the current form.
|
||||||
|
let formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error(`clickElement: no form found`);
|
||||||
|
|
||||||
|
let gridSelector;
|
||||||
|
if (table) {
|
||||||
|
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||||
|
if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||||
|
gridSelector = resolved.gridSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||||||
|
|
||||||
|
// Retry: if not found, a modal form may still be loading (e.g. after F4).
|
||||||
|
if (target?.error) {
|
||||||
|
for (let retry = 0; retry < 4; retry++) {
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const newForm = await page.evaluate(detectFormScript());
|
||||||
|
if (newForm !== null && newForm !== formNum) {
|
||||||
|
formNum = newForm;
|
||||||
|
target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector }));
|
||||||
|
if (!target?.error) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spreadsheet fallback: search iframes for text match before giving up.
|
||||||
|
if (target?.error) {
|
||||||
|
const ssCell = await findSpreadsheetCellByText(formNum, text);
|
||||||
|
if (ssCell) {
|
||||||
|
const cx = ssCell.box.x + ssCell.box.width / 2;
|
||||||
|
const cy = ssCell.box.y + ssCell.box.height / 2;
|
||||||
|
await modifierClick(cx, cy, modifier, { dbl: !!dblclick });
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({
|
||||||
|
clicked: { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Dispatch to the right handler by target kind.
|
||||||
|
const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector };
|
||||||
|
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
|
||||||
|
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
|
||||||
|
if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx);
|
||||||
|
if (target.kind === 'field') return await focusFormField(target, ctx);
|
||||||
|
return await clickFormTarget(target, ctx);
|
||||||
|
} finally {
|
||||||
|
if (highlightMode) try { await unhighlight(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// pasteText() — the only path 1C respects for autocomplete and Cyrillic input.
|
||||||
|
// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME
|
||||||
|
// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't
|
||||||
|
// clobbered. Blobs are stashed on `window` to avoid CDP serialization.
|
||||||
|
|
||||||
|
import {
|
||||||
|
page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged,
|
||||||
|
} from './state.mjs';
|
||||||
|
|
||||||
|
export async function saveClipboard() {
|
||||||
|
if (!page) return;
|
||||||
|
try {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
try {
|
||||||
|
const items = await navigator.clipboard.read();
|
||||||
|
const saved = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const types = {};
|
||||||
|
for (const t of item.types) types[t] = await item.getType(t);
|
||||||
|
saved.push(types);
|
||||||
|
}
|
||||||
|
window.__webTestSavedClipboard = saved;
|
||||||
|
delete window.__webTestClipboardError;
|
||||||
|
} catch (e) {
|
||||||
|
window.__webTestSavedClipboard = null;
|
||||||
|
window.__webTestClipboardError = e?.name || String(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// page.evaluate itself failed (closed page, navigation in flight) — skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreClipboard() {
|
||||||
|
if (!page) return;
|
||||||
|
let err = null;
|
||||||
|
try {
|
||||||
|
err = await page.evaluate(async () => {
|
||||||
|
const saved = window.__webTestSavedClipboard;
|
||||||
|
const captured = window.__webTestClipboardError || null;
|
||||||
|
delete window.__webTestSavedClipboard;
|
||||||
|
delete window.__webTestClipboardError;
|
||||||
|
try {
|
||||||
|
if (!saved || saved.length === 0) {
|
||||||
|
// Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API)
|
||||||
|
// or buffer was empty. Either way, the test's writeText already destroyed
|
||||||
|
// any prior native formats in the OS clipboard, so explicitly clear here
|
||||||
|
// to avoid leaking the test value into the user's clipboard.
|
||||||
|
await navigator.clipboard.writeText('');
|
||||||
|
return captured;
|
||||||
|
}
|
||||||
|
const items = saved.map(types => new ClipboardItem(types));
|
||||||
|
await navigator.clipboard.write(items);
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return e?.name || String(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err && !clipboardWarnLogged) {
|
||||||
|
setClipboardWarnLogged(true);
|
||||||
|
console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste `text` via OS clipboard (the only trusted-paste path that 1C respects
|
||||||
|
* for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a
|
||||||
|
* narrow save/restore so a user's clipboard survives the test run — the window
|
||||||
|
* between save and restore is microseconds.
|
||||||
|
*
|
||||||
|
* - `confirm` — key (string) or sequence (array) to press after writeText.
|
||||||
|
* Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste,
|
||||||
|
* or 'Shift+F11' for the goto-link dialog.
|
||||||
|
* - `postDelay` — ms to wait between confirm-press and restore, for dialogs
|
||||||
|
* that read clipboard asynchronously (e.g. Shift+F11). Default 0.
|
||||||
|
*/
|
||||||
|
export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) {
|
||||||
|
if (!page) return;
|
||||||
|
if (preserveClipboard) await saveClipboard();
|
||||||
|
try {
|
||||||
|
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`);
|
||||||
|
if (Array.isArray(confirm)) {
|
||||||
|
for (const key of confirm) await page.keyboard.press(key);
|
||||||
|
} else if (confirm) {
|
||||||
|
await page.keyboard.press(confirm);
|
||||||
|
}
|
||||||
|
if (postDelay) await page.waitForTimeout(postDelay);
|
||||||
|
} finally {
|
||||||
|
if (preserveClipboard) await restoreClipboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// web-test core/errors v1.18 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page } from './state.mjs';
|
||||||
|
import { checkErrorsScript } from '../../dom.mjs';
|
||||||
|
import {
|
||||||
|
getOpenReportCoordsScript, isErrorDetailLinkVisibleScript,
|
||||||
|
readLargestVisibleTextareaScript, clickTopCloudOkButtonScript,
|
||||||
|
clickReportCloseButtonScript,
|
||||||
|
} from '../../dom/errors-stack.mjs';
|
||||||
|
import { waitForStable } from './wait.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close startup modals and guide tabs.
|
||||||
|
* Strategy: Escape → click default buttons → close extra tabs → repeat.
|
||||||
|
*/
|
||||||
|
export async function closeModals() {
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
// 1. Press Escape to dismiss any popup/modal
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// 2. Try clicking default "Закрыть"/"OK" buttons
|
||||||
|
const clicked = await page.evaluate(`(() => {
|
||||||
|
const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0);
|
||||||
|
for (const btn of btns) {
|
||||||
|
const text = (btn.innerText?.trim() || '').toLowerCase();
|
||||||
|
if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) {
|
||||||
|
btn.click();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()`);
|
||||||
|
if (clicked) { await page.waitForTimeout(1000); continue; }
|
||||||
|
|
||||||
|
// 3. Close extra tabs (Путеводитель etc.) via openedClose button
|
||||||
|
const tabClosed = await page.evaluate(`(() => {
|
||||||
|
const btn = document.querySelector('.openedClose');
|
||||||
|
if (btn && btn.offsetWidth > 0) { btn.click(); return true; }
|
||||||
|
return false;
|
||||||
|
})()`);
|
||||||
|
if (tabClosed) { await page.waitForTimeout(1000); continue; }
|
||||||
|
|
||||||
|
// Nothing to close — done
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for validation errors / diagnostics after an action.
|
||||||
|
* Detects: inline balloon tooltip, messages panel, modal error dialog.
|
||||||
|
* Returns { balloon, messages[], modal } or null.
|
||||||
|
*/
|
||||||
|
export async function checkForErrors() {
|
||||||
|
return await page.evaluate(checkErrorsScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss pending error modal if present (single OK button dialog).
|
||||||
|
* Called at the start of action functions so that a leftover error modal
|
||||||
|
* from a previous operation doesn't block the next action.
|
||||||
|
* Does NOT dismiss confirmations (Да/Нет — require user decision).
|
||||||
|
* Returns the dismissed error object or null.
|
||||||
|
*/
|
||||||
|
export async function dismissPendingErrors() {
|
||||||
|
// Close leftover platform dialogs first (About, Support Info, Error Report)
|
||||||
|
// These block all interaction via modalSurface and are invisible to 1C form detection
|
||||||
|
try {
|
||||||
|
const pd = await detectPlatformDialogs();
|
||||||
|
if (pd.length) await closePlatformDialogs();
|
||||||
|
} catch { /* OK */ }
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (!err?.modal) return null;
|
||||||
|
try {
|
||||||
|
// Target pressDefault within the modal's form container specifically
|
||||||
|
const formNum = err.modal.formNum;
|
||||||
|
const sel = formNum != null
|
||||||
|
? `#form${formNum}_container a.press.pressDefault`
|
||||||
|
: 'a.press.pressDefault';
|
||||||
|
const btn = await page.$(sel);
|
||||||
|
if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); }
|
||||||
|
} catch { /* OK */ }
|
||||||
|
await waitForStable();
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect open platform-level dialogs (About, Support Info, Error Report).
|
||||||
|
* Returns array of { type, title? } for each detected dialog, or empty array.
|
||||||
|
*/
|
||||||
|
export async function detectPlatformDialogs() {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const result = [];
|
||||||
|
// "О программе" dialog
|
||||||
|
const about = document.getElementById('aboutContainer');
|
||||||
|
if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' });
|
||||||
|
// "Информация для технической поддержки" (inside a ps*win with errJournalInput)
|
||||||
|
const errJ = document.getElementById('errJournalInput');
|
||||||
|
if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' });
|
||||||
|
// "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer
|
||||||
|
if (!result.length) {
|
||||||
|
document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => {
|
||||||
|
if (w.offsetWidth === 0 || w.offsetHeight === 0) return;
|
||||||
|
// Skip the main app window (ps*win that contains the 1C forms)
|
||||||
|
if (w.querySelector('[id^="form"][id$="_container"]')) return;
|
||||||
|
// Check title text
|
||||||
|
const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]');
|
||||||
|
const title = titleEl?.textContent?.trim() || '';
|
||||||
|
if (title) result.push({ type: 'platformWindow', title });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close any platform-level dialogs that may be left open (about, support info, error report).
|
||||||
|
* These are NOT 1C forms — they are platform UI overlays invisible to getFormState().
|
||||||
|
* Each close is wrapped in try/catch to avoid cascading failures.
|
||||||
|
*/
|
||||||
|
export async function closePlatformDialogs() {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// "Подробный текст ошибки" OK button (inside error report detail view)
|
||||||
|
// It's a cloud window with its own OK button — look for visible pressDefault in small ps*win
|
||||||
|
const psWins = document.querySelectorAll('[id^="ps"][id$="win"]');
|
||||||
|
for (const w of psWins) {
|
||||||
|
if (w.offsetWidth === 0) continue;
|
||||||
|
// Check if this is a small dialog (error detail, about, support info)
|
||||||
|
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
||||||
|
if (closeBtn && closeBtn.offsetWidth > 0) {
|
||||||
|
try { closeBtn.click(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Информация для технической поддержки" — extOkBtn
|
||||||
|
const extOk = document.getElementById('extOkBtn');
|
||||||
|
if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {}
|
||||||
|
// "О программе" — aboutOkButton
|
||||||
|
const aboutOk = document.getElementById('aboutOkButton');
|
||||||
|
if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {}
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse raw error stack text into structured entries.
|
||||||
|
* Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea.
|
||||||
|
* Returns { raw, timestamp?, entries: [{location, code}] }
|
||||||
|
*/
|
||||||
|
function parseErrorStack(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const result = { raw, entries: [] };
|
||||||
|
// Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS)
|
||||||
|
const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m);
|
||||||
|
if (tsMatch) result.timestamp = tsMatch[1];
|
||||||
|
// Extract {Module.Path(lineNum)}: code entries
|
||||||
|
const entryRe = /\{([^}]+)\}:\s*(.+)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = entryRe.exec(raw)) !== null) {
|
||||||
|
result.entries.push({ location: m[1].trim(), code: m[2].trim() });
|
||||||
|
}
|
||||||
|
return result.entries.length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch error call stack from the 1C platform UI.
|
||||||
|
* Uses two strategies:
|
||||||
|
* Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea
|
||||||
|
* Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput
|
||||||
|
*
|
||||||
|
* Always closes the error modal and any platform dialogs it opened.
|
||||||
|
* Returns parsed stack object or null on failure.
|
||||||
|
*
|
||||||
|
* @param {number} formNum - form number of the error modal (e.g. 6 for form6_)
|
||||||
|
* @param {boolean} hasReport - true if OpenReport link is available
|
||||||
|
*/
|
||||||
|
export async function fetchErrorStack(formNum, hasReport) {
|
||||||
|
try {
|
||||||
|
// Platform exception modals are initially unstable — they redraw within ~1s.
|
||||||
|
// The initial state may lack the OpenReport link. Re-check after a short delay.
|
||||||
|
if (!hasReport) {
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
hasReport = await page.evaluate((fn) => {
|
||||||
|
const el = document.getElementById('form' + fn + '_OpenReport#text');
|
||||||
|
return !!(el && el.offsetWidth > 2 && el.textContent.trim());
|
||||||
|
}, formNum);
|
||||||
|
}
|
||||||
|
if (hasReport) return await fetchStackViaReport(formNum);
|
||||||
|
return await fetchStackViaHamburger(formNum);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// Ensure all platform dialogs are closed
|
||||||
|
try { await closePlatformDialogs(); } catch {}
|
||||||
|
// Ensure the error modal itself is closed
|
||||||
|
try {
|
||||||
|
const sel = formNum != null
|
||||||
|
? `#form${formNum}_container a.press.pressDefault`
|
||||||
|
: 'a.press.pressDefault';
|
||||||
|
const btn = await page.$(sel);
|
||||||
|
if (btn) await btn.click({ force: true });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path 1: Fetch stack via OpenReport link (for platform exceptions).
|
||||||
|
* The error modal must still be open with a visible "Сформировать отчет об ошибке" link.
|
||||||
|
*/
|
||||||
|
async function fetchStackViaReport(formNum) {
|
||||||
|
// 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks)
|
||||||
|
const coords = await page.evaluate(getOpenReportCoordsScript(formNum));
|
||||||
|
if (!coords) return null;
|
||||||
|
|
||||||
|
await page.mouse.click(coords.x, coords.y);
|
||||||
|
|
||||||
|
// 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link
|
||||||
|
let found = false;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
found = await page.evaluate(isErrorDetailLinkVisibleScript());
|
||||||
|
if (found) break;
|
||||||
|
}
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
// 3. Click "подробный текст ошибки"
|
||||||
|
await page.getByText('подробный текст ошибки').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 4. Read the textarea with detailed error text (find the largest visible textarea)
|
||||||
|
const raw = await page.evaluate(readLargestVisibleTextareaScript());
|
||||||
|
|
||||||
|
// 5. Close "Подробный текст ошибки" dialog (click its OK button)
|
||||||
|
try {
|
||||||
|
await page.evaluate(clickTopCloudOkButtonScript());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 6. Close "Отчет об ошибке" dialog (click its × close button)
|
||||||
|
try {
|
||||||
|
await page.evaluate(clickReportCloseButtonScript());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return parseErrorStack(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки".
|
||||||
|
* Works for all error types including simple ВызватьИсключение.
|
||||||
|
* The error modal is closed first to allow access to the hamburger menu.
|
||||||
|
*/
|
||||||
|
async function fetchStackViaHamburger(formNum) {
|
||||||
|
// 1. Close the error modal first
|
||||||
|
try {
|
||||||
|
const sel = formNum != null
|
||||||
|
? `#form${formNum}_container a.press.pressDefault`
|
||||||
|
: 'a.press.pressDefault';
|
||||||
|
const btn = await page.$(sel);
|
||||||
|
if (btn) await btn.click({ force: true });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 2. Click hamburger menu
|
||||||
|
await page.click('#captionbarMore', { timeout: 5000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// 3. Click "О программе..."
|
||||||
|
await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 4. Click "Информация для технической поддержки"
|
||||||
|
await page.click('#aboutHyperLink', { timeout: 5000 });
|
||||||
|
|
||||||
|
// 5. Wait for errJournalInput to appear and be filled
|
||||||
|
let raw = null;
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
raw = await page.evaluate(() => {
|
||||||
|
const el = document.getElementById('errJournalInput');
|
||||||
|
return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null;
|
||||||
|
});
|
||||||
|
if (raw) break;
|
||||||
|
}
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// 6. Parse first error block (most recent — before first separator)
|
||||||
|
const separator = / - - - - /;
|
||||||
|
const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw;
|
||||||
|
// Find the "Ошибки:" section
|
||||||
|
const errIdx = raw.indexOf('Ошибки:');
|
||||||
|
let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw;
|
||||||
|
// Take first block (before first separator line)
|
||||||
|
const lines = errorText.split('\n');
|
||||||
|
const firstBlockLines = [];
|
||||||
|
let inBlock = false;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (separator.test(line)) {
|
||||||
|
if (inBlock) break; // end of first block
|
||||||
|
inBlock = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inBlock) firstBlockLines.push(line);
|
||||||
|
}
|
||||||
|
const firstBlock = firstBlockLines.join('\n').trim();
|
||||||
|
|
||||||
|
// 7. Close support info and about dialogs (done in finally via closePlatformDialogs)
|
||||||
|
return parseErrorStack(firstBlock || errorText);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// web-test core/helpers v1.21 — private, cross-cutting helpers used by the
|
||||||
|
// public action functions (clickElement/fillFields/selectValue/etc).
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page } from './state.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors } from './errors.mjs';
|
||||||
|
import { getFormState } from '../forms/state.mjs';
|
||||||
|
import {
|
||||||
|
detectNewFormScript,
|
||||||
|
isInputFocusedScript,
|
||||||
|
isInputFocusedInGridScript,
|
||||||
|
findOpenPopupScript,
|
||||||
|
readEddScript,
|
||||||
|
isEddVisibleScript,
|
||||||
|
clickEddItemViaDispatchScript,
|
||||||
|
clickShowAllInEddScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* page.click with the standard "intercepts pointer events" retry ladder:
|
||||||
|
* normal → force → Escape (+ optional dismissPendingErrors) → normal.
|
||||||
|
* Mirrors the three hand-written copies in fillReferenceField, clickElement
|
||||||
|
* and the DLB branch of selectValue.
|
||||||
|
*
|
||||||
|
* @param {string} selector
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.timeout] — passed through to page.click
|
||||||
|
* @param {boolean} [opts.dismissErrors=false] — call dismissPendingErrors()
|
||||||
|
* before pressing Escape on the second retry (used in fillReferenceField).
|
||||||
|
*/
|
||||||
|
export async function safeClick(selector, { timeout, dismissErrors = false } = {}) {
|
||||||
|
const baseOpts = timeout != null ? { timeout } : {};
|
||||||
|
try {
|
||||||
|
await page.click(selector, baseOpts);
|
||||||
|
} catch (e) {
|
||||||
|
if (!e.message.includes('intercepts pointer events')) throw e;
|
||||||
|
try {
|
||||||
|
await page.click(selector, { ...baseOpts, force: true });
|
||||||
|
} catch (e2) {
|
||||||
|
if (!e2.message.includes('intercepts pointer events')) throw e2;
|
||||||
|
if (dismissErrors) await dismissPendingErrors();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.click(selector, baseOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a form field's input element id by name. Tries `form{N}_{name}` first,
|
||||||
|
* then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the
|
||||||
|
* element id or null. Used in selectValue's clear/composite-type/F4 fallback
|
||||||
|
* branches.
|
||||||
|
*
|
||||||
|
* @param {number} formNum
|
||||||
|
* @param {string} fieldName
|
||||||
|
* @returns {Promise<string|null>}
|
||||||
|
*/
|
||||||
|
export async function findFieldInputId(formNum, fieldName) {
|
||||||
|
return await page.evaluate(`(() => {
|
||||||
|
const p = 'form${formNum}_';
|
||||||
|
const name = ${JSON.stringify(fieldName)};
|
||||||
|
const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]');
|
||||||
|
return el ? el.id : null;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect a new form opened above the given `prevFormNum`. Two modes:
|
||||||
|
* `{ strict: true }` — only counts visible interactive elements
|
||||||
|
* (`input.editInput[id], a.press[id]`); used by fillReferenceField.
|
||||||
|
* default (broad) — any element with `id^=form{N}_` that's visible
|
||||||
|
* in either dimension; also finds type-dialogs whose a.press buttons
|
||||||
|
* have empty IDs. Used by selectValue / fillTableRow.
|
||||||
|
*
|
||||||
|
* @param {number} prevFormNum
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.strict=false]
|
||||||
|
* @returns {Promise<number|null>} new form number or null
|
||||||
|
*/
|
||||||
|
export async function detectNewForm(prevFormNum, { strict = false } = {}) {
|
||||||
|
return page.evaluate(detectNewFormScript(prevFormNum, { strict }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)?
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.allowTextarea=false]
|
||||||
|
*/
|
||||||
|
export async function isInputFocused({ allowTextarea = false } = {}) {
|
||||||
|
return page.evaluate(isInputFocusedScript({ allowTextarea }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`?
|
||||||
|
* Used to verify grid edit-mode. Pass `{ gridSelector }` to scope the check
|
||||||
|
* to a specific grid (when a form has multiple grids).
|
||||||
|
*/
|
||||||
|
export async function isInputFocusedInGrid({ gridSelector } = {}) {
|
||||||
|
return page.evaluate(isInputFocusedInGridScript(gridSelector));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`)
|
||||||
|
* popup visible? Returns `'calculator' | 'calendar' | null`.
|
||||||
|
*/
|
||||||
|
export async function findOpenPopup() {
|
||||||
|
return page.evaluate(findOpenPopupScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the `#editDropDown` autocomplete popup. Returns whether it's visible
|
||||||
|
* and, when visible, an array of `.eddText` items with display name and
|
||||||
|
* center coordinates (suitable for page.mouse.click).
|
||||||
|
*
|
||||||
|
* @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>}
|
||||||
|
*/
|
||||||
|
export async function readEdd() {
|
||||||
|
return page.evaluate(readEddScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper: is the EDD popup currently visible?
|
||||||
|
* Lighter than `readEdd` when only presence matters.
|
||||||
|
*/
|
||||||
|
export async function isEddVisible() {
|
||||||
|
return page.evaluate(isEddVisibleScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click an EDD item by name via dispatchEvent (bypasses div.surface overlays).
|
||||||
|
* Returns the clicked item's innerText, or `null` if no match.
|
||||||
|
*/
|
||||||
|
export async function clickEddItemViaDispatch(itemName) {
|
||||||
|
return page.evaluate(clickEddItemViaDispatchScript(itemName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the "Показать все" / "Show all" link in the EDD footer.
|
||||||
|
* Returns boolean.
|
||||||
|
*/
|
||||||
|
export async function clickShowAllInEdd() {
|
||||||
|
return page.evaluate(clickShowAllInEddScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard "tail" of action functions: fetch current form state, attach
|
||||||
|
* caller-specified extras (e.g. `{ clicked: {...} }`) and the result of
|
||||||
|
* `checkForErrors()` if any. Returns the flat state object.
|
||||||
|
*
|
||||||
|
* Unifies ~15 hand-written copies in clickElement, selectValue, closeForm,
|
||||||
|
* navigation functions, etc. Also closes R1/R2/R3 from the refactor plan —
|
||||||
|
* any caller using this helper gets `state.errors` for free.
|
||||||
|
*
|
||||||
|
* @param {object} [extras] — merged into the state object via Object.assign.
|
||||||
|
* @returns {Promise<object>} form state (flat) with optional `errors`.
|
||||||
|
*/
|
||||||
|
export async function returnFormState(extras = {}) {
|
||||||
|
const state = await getFormState();
|
||||||
|
Object.assign(state, extras);
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err) state.errors = err;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse click at (x, y) with an optional modifier key held down for the duration.
|
||||||
|
* Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select).
|
||||||
|
* Pass `{ dbl: true }` for double-click.
|
||||||
|
*/
|
||||||
|
export async function modifierClick(x, y, modifier, { dbl = false } = {}) {
|
||||||
|
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||||
|
if (modKey) await page.keyboard.down(modKey);
|
||||||
|
if (dbl) await page.mouse.dblclick(x, y);
|
||||||
|
else await page.mouse.click(x, y);
|
||||||
|
if (modKey) await page.keyboard.up(modKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// web-test core/scroll-horiz v1.0 — horizontal scroll loop helper for grids and spreadsheets.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// 1С scrolls horizontally by shifting absolute X coordinates of cells (not via
|
||||||
|
// scrollLeft). The only reliable way to drive this from outside is to press
|
||||||
|
// ArrowRight / ArrowLeft on a focused cell. Both SpreadsheetDocument and form
|
||||||
|
// grids share this mechanic, so the loop body is identical: press an arrow,
|
||||||
|
// wait, check visibility, bail when the cell stops moving (lost focus / hit edge).
|
||||||
|
//
|
||||||
|
// Callers handle their own focus setup (clicking a visible cell to put keyboard
|
||||||
|
// focus on the grid/spreadsheet), direction selection, and visibility queries.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Press {direction} key in a loop until the target cell is fully visible or
|
||||||
|
* progress stalls.
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {import('playwright').Page} opts.page
|
||||||
|
* @param {'ArrowRight'|'ArrowLeft'} opts.direction
|
||||||
|
* @param {() => Promise<boolean>} opts.isFullyVisible — true when target inside viewport
|
||||||
|
* @param {() => Promise<number|null>} opts.getCenterX — current target center X (page coords); null if cell vanished
|
||||||
|
* @param {number} [opts.maxPresses=100]
|
||||||
|
* @param {number} [opts.staleMax=5] — bail when center hasn't moved this many presses in a row
|
||||||
|
* @param {number} [opts.delayMs=50] — wait after each key press
|
||||||
|
* @param {number} [opts.finalDelayMs=200] — wait after the loop completes
|
||||||
|
*/
|
||||||
|
export async function scrollHorizontallyByKey({
|
||||||
|
page, direction,
|
||||||
|
isFullyVisible, getCenterX,
|
||||||
|
maxPresses = 100, staleMax = 5,
|
||||||
|
delayMs = 50, finalDelayMs = 200,
|
||||||
|
}) {
|
||||||
|
let prevCx = await getCenterX();
|
||||||
|
if (prevCx == null) return;
|
||||||
|
let stale = 0;
|
||||||
|
for (let i = 0; i < maxPresses; i++) {
|
||||||
|
await page.keyboard.press(direction);
|
||||||
|
await page.waitForTimeout(delayMs);
|
||||||
|
if (await isFullyVisible()) break;
|
||||||
|
const cx = await getCenterX();
|
||||||
|
if (cx == null) break;
|
||||||
|
if (Math.abs(cx - prevCx) >= 1) stale = 0;
|
||||||
|
else { stale++; if (stale >= staleMax) break; }
|
||||||
|
prevCx = cx;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(finalDelayMs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||||
|
import { join as pathJoin } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import {
|
||||||
|
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
|
||||||
|
contexts, activeContextName, activeMode, persistentUserDataDir,
|
||||||
|
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
|
||||||
|
setActiveContextName, setActiveMode, setPersistentUserDataDir,
|
||||||
|
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
|
||||||
|
} from './state.mjs';
|
||||||
|
import { closeModals } from './errors.mjs';
|
||||||
|
import { stopRecording } from '../recording/capture.mjs';
|
||||||
|
import { getPageState } from '../nav/navigation.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the 1C browser extension in Chrome/Edge user profiles.
|
||||||
|
* Returns the path to the latest version, or null if not found.
|
||||||
|
* Can be overridden via extensionPath in .v8-project.json.
|
||||||
|
*/
|
||||||
|
function findExtension(overridePath) {
|
||||||
|
if (overridePath) {
|
||||||
|
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const localAppData = process.env.LOCALAPPDATA;
|
||||||
|
if (!localAppData) return null;
|
||||||
|
const browsers = [
|
||||||
|
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
|
||||||
|
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
|
||||||
|
];
|
||||||
|
for (const userData of browsers) {
|
||||||
|
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
|
||||||
|
let profiles;
|
||||||
|
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
|
||||||
|
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
|
||||||
|
let versions;
|
||||||
|
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
|
||||||
|
if (versions.length > 0) {
|
||||||
|
const best = pathJoin(extDir, versions[versions.length - 1]);
|
||||||
|
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* isConnected moved to core/state.mjs */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open browser and navigate to 1C web client URL.
|
||||||
|
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
|
||||||
|
*/
|
||||||
|
export async function connect(url, { extensionPath } = {}) {
|
||||||
|
if (isConnected()) {
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||||
|
} else {
|
||||||
|
const extPath = findExtension(extensionPath);
|
||||||
|
if (extPath) {
|
||||||
|
// Launch with 1C browser extension via persistent context
|
||||||
|
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
|
||||||
|
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||||
|
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||||
|
headless: false,
|
||||||
|
args: [
|
||||||
|
'--start-maximized',
|
||||||
|
'--disable-extensions-except=' + extPath,
|
||||||
|
'--load-extension=' + extPath,
|
||||||
|
],
|
||||||
|
viewport: null,
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
});
|
||||||
|
setBrowser(context); // persistent context IS the browser
|
||||||
|
setPage(context.pages()[0] || await context.newPage());
|
||||||
|
} else {
|
||||||
|
// Fallback: launch without extension
|
||||||
|
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: null,
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
});
|
||||||
|
setPage(await context.newPage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
|
||||||
|
page.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||||
|
|
||||||
|
// Capture seanceId from network requests for graceful logout
|
||||||
|
setSessionPrefix(null);
|
||||||
|
setSeanceId(null);
|
||||||
|
page.on('request', req => {
|
||||||
|
if (seanceId) return;
|
||||||
|
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||||
|
if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for 1C to initialize — detect by section panel appearance
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
|
||||||
|
} catch {
|
||||||
|
// Fallback: wait fixed time if selector doesn't appear (e.g. login page)
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to close startup modals (Путеводитель etc.)
|
||||||
|
await closeModals();
|
||||||
|
|
||||||
|
return await getPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
|
||||||
|
* Silent — if page is closed or session info missing, just returns.
|
||||||
|
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
|
||||||
|
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
|
||||||
|
*/
|
||||||
|
async function logoutSlot(slot, waitMs = 500) {
|
||||||
|
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
|
||||||
|
try {
|
||||||
|
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||||
|
await slot.page.evaluate(async (url) => {
|
||||||
|
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||||
|
}, logoutUrl);
|
||||||
|
await slot.page.waitForTimeout(waitMs);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully terminate the 1C session and close the browser.
|
||||||
|
* Sends POST /e1cib/logout to release the license before closing.
|
||||||
|
*/
|
||||||
|
export async function disconnect() {
|
||||||
|
// Multi-context path: stop recording + logout each slot before closing browser
|
||||||
|
if (contexts.size > 0) {
|
||||||
|
saveActiveSlot();
|
||||||
|
// Recorder is global — one stop covers all contexts
|
||||||
|
if (recorder) {
|
||||||
|
try { await stopRecording(); } catch {}
|
||||||
|
}
|
||||||
|
for (const [, slot] of contexts.entries()) {
|
||||||
|
await logoutSlot(slot);
|
||||||
|
}
|
||||||
|
contexts.clear();
|
||||||
|
setActiveContextName(null);
|
||||||
|
setActiveMode(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-session path (connect): auto-stop recording if active
|
||||||
|
if (recorder) {
|
||||||
|
try { await stopRecording(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
// Graceful logout — release the 1C license (single-session connect path)
|
||||||
|
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
setBrowser(null);
|
||||||
|
setPage(null);
|
||||||
|
setSessionPrefix(null);
|
||||||
|
setSeanceId(null);
|
||||||
|
// Clean up persistent user data dir
|
||||||
|
if (persistentUserDataDir) {
|
||||||
|
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||||
|
setPersistentUserDataDir(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to a running browser server via CDP WebSocket.
|
||||||
|
* Sets module state so all functions (getFormState, clickElement, etc.) work.
|
||||||
|
*/
|
||||||
|
export async function attach(wsEndpoint, session = {}) {
|
||||||
|
if (isConnected()) return;
|
||||||
|
setBrowser(await chromium.connect(wsEndpoint));
|
||||||
|
const ctx = browser.contexts()[0];
|
||||||
|
setPage(ctx?.pages()[0]);
|
||||||
|
if (!page) throw new Error('No page found in browser');
|
||||||
|
setSessionPrefix(session.sessionPrefix || null);
|
||||||
|
setSeanceId(session.seanceId || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach from browser without closing it.
|
||||||
|
* Returns session state for persistence.
|
||||||
|
*/
|
||||||
|
export function detach() {
|
||||||
|
const session = { sessionPrefix, seanceId };
|
||||||
|
setBrowser(null);
|
||||||
|
setPage(null);
|
||||||
|
setSessionPrefix(null);
|
||||||
|
setSeanceId(null);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current session state (for saving between reconnections). */
|
||||||
|
export function getSession() {
|
||||||
|
return { sessionPrefix, seanceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Multi-context support (used by run.mjs cmdTest only)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current module-level state into the active slot before switching.
|
||||||
|
* No-op if no active slot.
|
||||||
|
*/
|
||||||
|
function saveActiveSlot() {
|
||||||
|
if (!activeContextName) return;
|
||||||
|
const slot = contexts.get(activeContextName);
|
||||||
|
if (!slot) return;
|
||||||
|
slot.page = page;
|
||||||
|
slot.sessionPrefix = sessionPrefix;
|
||||||
|
slot.seanceId = seanceId;
|
||||||
|
slot.highlightMode = highlightMode;
|
||||||
|
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
||||||
|
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
||||||
|
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a slot's state into module-level vars and mark it active. */
|
||||||
|
function activateSlot(name) {
|
||||||
|
const slot = contexts.get(name);
|
||||||
|
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
|
||||||
|
setPage(slot.page);
|
||||||
|
setSessionPrefix(slot.sessionPrefix);
|
||||||
|
setSeanceId(slot.seanceId);
|
||||||
|
setHighlightMode(slot.highlightMode || false);
|
||||||
|
setActiveContextName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||||
|
function attachSessionListeners(pg, slot, name) {
|
||||||
|
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||||
|
pg.on('request', req => {
|
||||||
|
if (slot.seanceId) return;
|
||||||
|
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||||
|
if (m) {
|
||||||
|
slot.sessionPrefix = m[1];
|
||||||
|
slot.seanceId = m[2];
|
||||||
|
if (activeContextName === name) {
|
||||||
|
setSessionPrefix(m[1]);
|
||||||
|
setSeanceId(m[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create (or navigate) a named browser context.
|
||||||
|
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
|
||||||
|
* subsequent calls can create additional isolated BrowserContexts in the same process.
|
||||||
|
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
|
||||||
|
* persistent profile.
|
||||||
|
*
|
||||||
|
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||||
|
* legacy persistent-context path.
|
||||||
|
*/
|
||||||
|
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||||
|
if (contexts.has(name)) {
|
||||||
|
await setActiveContext(name);
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||||
|
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||||
|
catch { await page.waitForTimeout(5000); }
|
||||||
|
await closeModals();
|
||||||
|
return await getPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['tab', 'window'].includes(isolation)) {
|
||||||
|
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
|
||||||
|
}
|
||||||
|
if (activeMode && activeMode !== isolation) {
|
||||||
|
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First context: launch browser. Subsequent: reuse existing.
|
||||||
|
let isFirstContext = !browser;
|
||||||
|
if (isFirstContext) {
|
||||||
|
const extPath = findExtension(extensionPath);
|
||||||
|
const launchArgs = ['--start-maximized'];
|
||||||
|
if (extPath) {
|
||||||
|
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
|
||||||
|
}
|
||||||
|
if (isolation === 'tab') {
|
||||||
|
// Persistent context: extension loads reliably, one window with tabs per context
|
||||||
|
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
|
||||||
|
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||||
|
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||||
|
headless: false,
|
||||||
|
args: launchArgs,
|
||||||
|
viewport: null,
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Window mode: separate BrowserContext per slot, full cookie isolation
|
||||||
|
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
|
||||||
|
}
|
||||||
|
setActiveMode(isolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current active before switching
|
||||||
|
saveActiveSlot();
|
||||||
|
|
||||||
|
// Create slot — page differs by mode
|
||||||
|
let newCtx, newPage;
|
||||||
|
if (activeMode === 'tab') {
|
||||||
|
// Reuse the persistent context for all slots; each slot gets its own page (tab)
|
||||||
|
newCtx = browser;
|
||||||
|
if (isFirstContext) {
|
||||||
|
newPage = browser.pages()[0] || await browser.newPage();
|
||||||
|
} else {
|
||||||
|
newPage = await browser.newPage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Window mode: each slot owns its BrowserContext + page
|
||||||
|
newCtx = await browser.newContext({
|
||||||
|
viewport: null,
|
||||||
|
permissions: ['clipboard-read', 'clipboard-write'],
|
||||||
|
});
|
||||||
|
newPage = await newCtx.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = {
|
||||||
|
context: newCtx,
|
||||||
|
page: newPage,
|
||||||
|
sessionPrefix: null,
|
||||||
|
seanceId: null,
|
||||||
|
highlightMode: false,
|
||||||
|
};
|
||||||
|
contexts.set(name, slot);
|
||||||
|
|
||||||
|
attachSessionListeners(newPage, slot, name);
|
||||||
|
activateSlot(name);
|
||||||
|
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||||
|
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||||
|
catch { await page.waitForTimeout(5000); }
|
||||||
|
await closeModals();
|
||||||
|
|
||||||
|
return await getPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||||
|
export async function setActiveContext(name) {
|
||||||
|
if (activeContextName === name) return;
|
||||||
|
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||||
|
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
||||||
|
// up to the moment of the switch (avoids a "jump" in video time).
|
||||||
|
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
||||||
|
saveActiveSlot();
|
||||||
|
activateSlot(name);
|
||||||
|
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
||||||
|
// re-attach its screencast to the newly active page.
|
||||||
|
if (recorder && recorder._attachPage) {
|
||||||
|
await recorder._attachPage(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listContexts() {
|
||||||
|
return [...contexts.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveContext() {
|
||||||
|
return activeContextName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasContext(name) {
|
||||||
|
return contexts.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a named context: logout, close its page (tab mode) or BrowserContext
|
||||||
|
* (window mode), remove from registry. Cannot close the currently active
|
||||||
|
* context — caller must setActiveContext to another first. This keeps the
|
||||||
|
* recorder/page invariants simple: recorder is always attached to the
|
||||||
|
* active slot, which closeContext never touches.
|
||||||
|
*
|
||||||
|
* @throws if name is not registered or equals the active context.
|
||||||
|
*/
|
||||||
|
export async function closeContext(name) {
|
||||||
|
if (!contexts.has(name)) {
|
||||||
|
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||||
|
}
|
||||||
|
if (name === activeContextName) {
|
||||||
|
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
|
||||||
|
}
|
||||||
|
const slot = contexts.get(name);
|
||||||
|
await logoutSlot(slot);
|
||||||
|
if (activeMode === 'tab') {
|
||||||
|
try { await slot.page.close(); } catch {}
|
||||||
|
} else {
|
||||||
|
try { await slot.context.close(); } catch {}
|
||||||
|
}
|
||||||
|
contexts.delete(name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// web-test core/state v1.17 — module-level state for the web-test engine.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// Holds the single browser/page/recorder slot plus the multi-context registry,
|
||||||
|
// constants, and small state-only utilities (ensureConnected, getPage,
|
||||||
|
// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
|
||||||
|
// for live read access from consumer modules; writes go through setters so
|
||||||
|
// imported bindings stay read-only at the import site.
|
||||||
|
|
||||||
|
import { dirname, resolve as pathResolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs
|
||||||
|
const __fn_state = fileURLToPath(import.meta.url);
|
||||||
|
export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..');
|
||||||
|
|
||||||
|
/** Resolve a user-provided path relative to the project root (not cwd). */
|
||||||
|
export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Mutable single-session state. Importers read via the live binding; writes
|
||||||
|
// must go through the corresponding setter (ESM imports are read-only).
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export let browser = null;
|
||||||
|
export let page = null;
|
||||||
|
export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
|
||||||
|
export let seanceId = null;
|
||||||
|
export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
|
||||||
|
export let lastCaptions = []; // captions from the last completed recording (for addNarration)
|
||||||
|
export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
|
||||||
|
export let highlightMode = false;
|
||||||
|
export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
|
||||||
|
|
||||||
|
// Clipboard preservation: save full clipboard contents (all MIME types) right
|
||||||
|
// before each writeText+Ctrl+V pair, restore right after. Toggled via
|
||||||
|
// setPreserveClipboard() from run.mjs.
|
||||||
|
export let preserveClipboard = true;
|
||||||
|
export let clipboardWarnLogged = false;
|
||||||
|
|
||||||
|
export const setBrowser = (v) => { browser = v; };
|
||||||
|
export const setPage = (v) => { page = v; };
|
||||||
|
export const setSessionPrefix = (v) => { sessionPrefix = v; };
|
||||||
|
export const setSeanceId = (v) => { seanceId = v; };
|
||||||
|
export const setRecorder = (v) => { recorder = v; };
|
||||||
|
export const setLastCaptions = (v) => { lastCaptions = v; };
|
||||||
|
export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
|
||||||
|
export const setHighlightMode = (v) => { highlightMode = !!v; };
|
||||||
|
export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
|
||||||
|
export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
|
||||||
|
export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
|
||||||
|
// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
|
||||||
|
// Populated by createContext(); module-level vars above mirror the active
|
||||||
|
// slot. connect() does NOT use this Map — it preserves legacy single-session
|
||||||
|
// behavior for exec/run/start.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const contexts = new Map();
|
||||||
|
export let activeContextName = null;
|
||||||
|
// Isolation mode for the current cmdTest session — set by the first
|
||||||
|
// createContext call. 'tab': all contexts share one persistent context
|
||||||
|
// (one window, multiple tabs, extension loads reliably). 'window': each
|
||||||
|
// context gets its own BrowserContext (separate window per context, full
|
||||||
|
// cookie isolation, extension may not load).
|
||||||
|
export let activeMode = null;
|
||||||
|
|
||||||
|
export const setActiveContextName = (v) => { activeContextName = v; };
|
||||||
|
export const setActiveMode = (v) => { activeMode = v; };
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Constants.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const LOAD_TIMEOUT = 60000;
|
||||||
|
export const INIT_TIMEOUT = 60000;
|
||||||
|
export const ACTION_WAIT = 2000; // fallback minimum wait
|
||||||
|
export const MAX_WAIT = 10000; // max wait for stability
|
||||||
|
export const POLL_INTERVAL = 200; // polling interval
|
||||||
|
export const STABLE_CYCLES = 3; // consecutive stable cycles needed
|
||||||
|
|
||||||
|
// 1C browser extension ID (stable across versions, defined by key in manifest.json)
|
||||||
|
export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// Utilities that only depend on state.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Normalize ё→е and →space for fuzzy matching. */
|
||||||
|
export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
|
||||||
|
|
||||||
|
/** Check if browser is connected and page is usable. */
|
||||||
|
export function isConnected() {
|
||||||
|
if (!browser || !page || page.isClosed()) return false;
|
||||||
|
// launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
|
||||||
|
if (typeof browser.isConnected === 'function') return browser.isConnected();
|
||||||
|
// For persistent context, check via context's browser()
|
||||||
|
return browser.browser()?.isConnected() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureConnected() {
|
||||||
|
if (!isConnected()) {
|
||||||
|
throw new Error('Browser not connected. Call web_connect first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the raw Playwright page object (for advanced scripting in skill mode). */
|
||||||
|
export function getPage() {
|
||||||
|
ensureConnected();
|
||||||
|
return page;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// web-test core/wait v1.17 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs';
|
||||||
|
import { detectFormScript } from '../../dom.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart wait: poll until DOM is stable and no loading indicators are visible.
|
||||||
|
* Checks: form number change, loading indicators, DOM stability.
|
||||||
|
* @param {number|null} previousFormNum — form number before the action (null = don't check)
|
||||||
|
*/
|
||||||
|
export async function waitForStable(previousFormNum = null) {
|
||||||
|
let stableCount = 0;
|
||||||
|
let lastSnapshot = '';
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - start < MAX_WAIT) {
|
||||||
|
await page.waitForTimeout(POLL_INTERVAL);
|
||||||
|
|
||||||
|
// Check for loading indicators
|
||||||
|
const status = await page.evaluate(`(() => {
|
||||||
|
const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar');
|
||||||
|
const isLoading = loading && loading.offsetWidth > 0;
|
||||||
|
const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length;
|
||||||
|
return { isLoading, formCount };
|
||||||
|
})()`);
|
||||||
|
|
||||||
|
if (status.isLoading) {
|
||||||
|
stableCount = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DOM stability by comparing element count snapshot
|
||||||
|
const snapshot = String(status.formCount);
|
||||||
|
if (snapshot === lastSnapshot) {
|
||||||
|
stableCount++;
|
||||||
|
} else {
|
||||||
|
stableCount = 0;
|
||||||
|
lastSnapshot = snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If form was expected to change, ensure it did
|
||||||
|
if (previousFormNum !== null && stableCount === 1) {
|
||||||
|
const currentForm = await page.evaluate(detectFormScript());
|
||||||
|
if (currentForm !== previousFormNum) {
|
||||||
|
// Form changed — still wait for stability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stableCount >= STABLE_CYCLES) return;
|
||||||
|
}
|
||||||
|
// Fallback: max wait reached
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start monitoring network activity via CDP.
|
||||||
|
* Must be called BEFORE the click so it captures all server requests.
|
||||||
|
* Returns a monitor object with waitDone() and cleanup() methods.
|
||||||
|
*/
|
||||||
|
export async function startNetworkMonitor() {
|
||||||
|
const client = await page.context().newCDPSession(page);
|
||||||
|
await client.send('Network.enable');
|
||||||
|
|
||||||
|
let pending = 0;
|
||||||
|
let total = 0;
|
||||||
|
let lastZeroTime = null;
|
||||||
|
const DEBOUNCE = 300;
|
||||||
|
|
||||||
|
client.on('Network.requestWillBeSent', () => {
|
||||||
|
pending++;
|
||||||
|
total++;
|
||||||
|
lastZeroTime = null;
|
||||||
|
});
|
||||||
|
client.on('Network.loadingFinished', () => {
|
||||||
|
if (--pending === 0) lastZeroTime = Date.now();
|
||||||
|
});
|
||||||
|
client.on('Network.loadingFailed', () => {
|
||||||
|
if (--pending === 0) lastZeroTime = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Wait until all network requests complete (300ms debounce) or UI element appears. */
|
||||||
|
async waitDone(timeout = 10000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
|
||||||
|
// Check for UI elements (modal, balloon, confirm)
|
||||||
|
const ui = await page.evaluate(`(() => {
|
||||||
|
const modal = document.querySelector('#modalSurface:not([style*="display: none"])');
|
||||||
|
const balloon = document.querySelector('.balloon');
|
||||||
|
const confirm = document.querySelector('.confirm');
|
||||||
|
return !!(modal || balloon || confirm);
|
||||||
|
})()`);
|
||||||
|
if (ui) return;
|
||||||
|
|
||||||
|
// CDP debounce: pending===0 held for DEBOUNCE ms
|
||||||
|
if (total > 0 && pending === 0 && lastZeroTime !== null) {
|
||||||
|
if (Date.now() - lastZeroTime >= DEBOUNCE) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Detach CDP session. Always call this when done. */
|
||||||
|
async cleanup() {
|
||||||
|
await client.send('Network.disable').catch(() => {});
|
||||||
|
await client.detach().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll until a JS expression returns truthy, or timeout (ms) expires.
|
||||||
|
* Resolves early — typically within 100-300ms instead of fixed delays.
|
||||||
|
*/
|
||||||
|
export async function waitForCondition(evalScript, timeout = 2000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
const result = await page.evaluate(evalScript);
|
||||||
|
if (result) return result;
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// web-test forms/click-form v1.1 — click handler for form-element targets: button, tab, submenu, link, field-focus.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// Called by core/click.mjs dispatcher after target is found.
|
||||||
|
// Owns the CDP network-monitor lifecycle for button clicks (server roundtrip waits),
|
||||||
|
// post-click submenu detection (split buttons like "Создать на основании"),
|
||||||
|
// and confirmation hint propagation in the final state.
|
||||||
|
|
||||||
|
import { page, ACTION_WAIT } from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
detectFormScript, readSubmenuScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
import { checkForErrors } from '../core/errors.mjs';
|
||||||
|
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
||||||
|
import { safeClick, returnFormState, isInputFocused } from '../core/helpers.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}.
|
||||||
|
* Handles three special concerns:
|
||||||
|
* 1. **netMonitor** for `kind: 'button'` — captures CDP requests started by the click
|
||||||
|
* so we can wait for them (when the form doesn't change) before stabilising.
|
||||||
|
* 2. **Submenu detection** — both pre-click (`kind: 'submenu'` already known) and
|
||||||
|
* post-click (split buttons like "Создать на основании" which open a popup).
|
||||||
|
* Returns `submenu[]` items as a hint for the caller.
|
||||||
|
* 3. **Confirmation propagation** — if a confirmation dialog opens as a result of the
|
||||||
|
* click, surface `confirmation` and `hint` fields on the returned state so the
|
||||||
|
* caller can react with Да/Нет/Отмена on the next call.
|
||||||
|
*/
|
||||||
|
export async function clickFormTarget(target, ctx) {
|
||||||
|
const { formNum, timeout } = ctx;
|
||||||
|
let netMonitor = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// CDP network monitor BEFORE the click for buttons — captures all server requests
|
||||||
|
// triggered by the click so we can wait for them after.
|
||||||
|
if (target.kind === 'button') {
|
||||||
|
try { netMonitor = await startNetworkMonitor(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs without ID — use coordinate click to avoid global [data-content] ambiguity
|
||||||
|
if (target.kind === 'tab' && !target.id && target.x && target.y) {
|
||||||
|
await page.mouse.click(target.x, target.y);
|
||||||
|
} else {
|
||||||
|
const selector = `[id="${target.id}"]`;
|
||||||
|
// Use Playwright click for proper mousedown/mouseup events
|
||||||
|
await safeClick(selector, { timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-known submenu button — read popup items and return them as hints
|
||||||
|
if (target.kind === 'submenu') {
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
const submenuItems = await page.evaluate(readSubmenuScript());
|
||||||
|
const extras = { clicked: { kind: 'submenu', name: target.name } };
|
||||||
|
if (Array.isArray(submenuItems)) {
|
||||||
|
extras.submenu = submenuItems.map(i => i.name);
|
||||||
|
extras.hint = 'Call web_click again with a submenu item name to select it';
|
||||||
|
}
|
||||||
|
return returnFormState(extras);
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForStable(formNum);
|
||||||
|
|
||||||
|
// Check if the click opened a popup/submenu (split buttons like "Создать на основании")
|
||||||
|
const openedPopup = await page.evaluate(readSubmenuScript());
|
||||||
|
if (Array.isArray(openedPopup) && openedPopup.length > 0) {
|
||||||
|
return returnFormState({
|
||||||
|
clicked: { kind: 'submenu', name: target.name },
|
||||||
|
submenu: openedPopup.map(i => i.name),
|
||||||
|
hint: 'Call web_click again with a submenu item name to select it',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For buttons that trigger server-side operations (post, write, etc.),
|
||||||
|
// the DOM may stabilise BEFORE the server response arrives.
|
||||||
|
// The CDP monitor (started before click) lets us wait for all in-flight requests
|
||||||
|
// to complete (300ms debounce) or for a modal/balloon/confirm to appear.
|
||||||
|
// Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected.
|
||||||
|
if (target.kind === 'button') {
|
||||||
|
const postForm = await page.evaluate(detectFormScript());
|
||||||
|
if (postForm === formNum) {
|
||||||
|
const inGridEdit = await page.evaluate(`(() => {
|
||||||
|
const f = document.activeElement;
|
||||||
|
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||||||
|
let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; }
|
||||||
|
return false;
|
||||||
|
})()`);
|
||||||
|
if (!inGridEdit && netMonitor) {
|
||||||
|
await netMonitor.waitDone(timeout);
|
||||||
|
await waitForStable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final state with confirmation propagation
|
||||||
|
// (the one custom branch deliberately skipped by Phase 2 — surfaces confirmation
|
||||||
|
// + hint when a save/delete dialog opened as a result of the click).
|
||||||
|
const extras = { clicked: { kind: target.kind, name: target.name } };
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err?.confirmation) {
|
||||||
|
extras.confirmation = err.confirmation;
|
||||||
|
extras.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
|
||||||
|
}
|
||||||
|
return returnFormState(extras);
|
||||||
|
} finally {
|
||||||
|
if (netMonitor) try { await netMonitor.cleanup(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus a form input field (last-resort target kind: 'field') by clicking the input itself —
|
||||||
|
* does NOT change its value. Lets the caller then drive focus-dependent shortcuts
|
||||||
|
* (F4 selection form, Shift+F4 clear, etc.) via getPage().keyboard.
|
||||||
|
* Returns flat form state with `focused: { field, id, ok }`; `ok` reflects whether the
|
||||||
|
* input actually received focus (false for disabled/readonly fields). Never throws on ok=false.
|
||||||
|
*/
|
||||||
|
export async function focusFormField(target, ctx) {
|
||||||
|
const selector = `[id="${target.id}"]`;
|
||||||
|
await safeClick(selector, { timeout: 5000 });
|
||||||
|
await waitForStable(ctx.formNum);
|
||||||
|
const ok = await isInputFocused({ allowTextarea: true });
|
||||||
|
return returnFormState({ focused: { field: target.name, id: target.id, ok } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// web-test forms/click-popup v1.0 — click handlers for in-form popups: confirmation dialogs and open submenus.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// Both handlers run BEFORE clickElement's regular target-finding flow:
|
||||||
|
// - clickConfirmationButton intercepts when a pending confirmation dialog is open
|
||||||
|
// - tryClickPopupItem intercepts when a submenu/popup is open from a previous click
|
||||||
|
|
||||||
|
import { page, ACTION_WAIT, normYo } from '../core/state.mjs';
|
||||||
|
import { readSubmenuScript } from '../../dom.mjs';
|
||||||
|
import { waitForStable } from '../core/wait.mjs';
|
||||||
|
import { returnFormState } from '../core/helpers.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a button in the currently-open confirmation dialog (Да/Нет/Отмена, etc).
|
||||||
|
* Caller is responsible for verifying that a confirmation is actually pending
|
||||||
|
* (via checkForErrors().confirmation) before invoking this handler.
|
||||||
|
*
|
||||||
|
* Throws if no button matching `text` is found in the dialog.
|
||||||
|
*/
|
||||||
|
export async function clickConfirmationButton(text) {
|
||||||
|
const btnResult = await page.evaluate(`(() => {
|
||||||
|
const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || '';
|
||||||
|
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||||
|
const target = ny(${JSON.stringify(text.toLowerCase())});
|
||||||
|
const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0);
|
||||||
|
let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target);
|
||||||
|
if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target));
|
||||||
|
if (best) {
|
||||||
|
const r = best.getBoundingClientRect();
|
||||||
|
return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) };
|
||||||
|
}
|
||||||
|
return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) };
|
||||||
|
})()`);
|
||||||
|
if (btnResult?.error) {
|
||||||
|
throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
await page.mouse.click(btnResult.x, btnResult.y);
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to click an item inside an already-open submenu/popup.
|
||||||
|
*
|
||||||
|
* Returns a form-state result on match (kind: 'popupItem' or 'submenuArrow'),
|
||||||
|
* or `null` if the requested text doesn't match any visible popup item — in
|
||||||
|
* which case the caller should fall through to regular form-element finding.
|
||||||
|
*
|
||||||
|
* @param {string} text — fuzzy-matched against item labels (NBSP/ё-normalised)
|
||||||
|
* @param {Array} popupItems — items already read via readSubmenuScript()
|
||||||
|
*/
|
||||||
|
export async function tryClickPopupItem(text, popupItems) {
|
||||||
|
const target = normYo(text.toLowerCase());
|
||||||
|
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||||
|
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
// submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu
|
||||||
|
if (found.kind === 'submenuArrow') {
|
||||||
|
// page.hover(selector) is more reliable than page.mouse.move(x,y) —
|
||||||
|
// some submenu groups don't expand with plain mouse.move
|
||||||
|
if (found.id) {
|
||||||
|
await page.hover(`[id="${found.id}"]`);
|
||||||
|
} else {
|
||||||
|
await page.mouse.move(found.x, found.y);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
const nestedItems = await page.evaluate(readSubmenuScript());
|
||||||
|
const extras = { clicked: { kind: 'submenuArrow', name: found.name } };
|
||||||
|
if (Array.isArray(nestedItems)) {
|
||||||
|
extras.submenu = nestedItems.map(i => i.name);
|
||||||
|
extras.hint = 'Call web_click again with a submenu item name to select it';
|
||||||
|
}
|
||||||
|
return returnFormState(extras);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular submenu/dropdown items — trusted events required.
|
||||||
|
// Use mouse.click(x,y) when in viewport; use :visible selector for clipped items
|
||||||
|
// (same ID can exist hidden in parent cloud AND visible in nested cloud).
|
||||||
|
const vpHeight = await page.evaluate('window.innerHeight');
|
||||||
|
if (found.x && found.y && found.y > 0 && found.y < vpHeight) {
|
||||||
|
await page.mouse.click(found.x, found.y);
|
||||||
|
} else if (found.id) {
|
||||||
|
await page.click(`[id="${found.id}"]:visible`);
|
||||||
|
} else if (found.x && found.y) {
|
||||||
|
await page.mouse.click(found.x, found.y);
|
||||||
|
}
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({ clicked: { kind: 'popupItem', name: found.name } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page, recorder, ensureConnected } from '../core/state.mjs';
|
||||||
|
import { detectFormScript } from '../../dom.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
|
||||||
|
import { waitForStable } from '../core/wait.mjs';
|
||||||
|
import { returnFormState } from '../core/helpers.mjs';
|
||||||
|
import { getFormState } from './state.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current form/dialog via Escape.
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
|
||||||
|
* true → click "Да" (save and close)
|
||||||
|
* false → click "Нет" (discard and close)
|
||||||
|
* undefined → return confirmation as hint for caller to decide
|
||||||
|
*/
|
||||||
|
export async function closeForm({ save } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
// If platform dialogs are open, close them instead of pressing Escape
|
||||||
|
const pd = await detectPlatformDialogs();
|
||||||
|
if (pd.length) {
|
||||||
|
await closePlatformDialogs();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
return returnFormState({ closed: true, closedPlatformDialogs: pd });
|
||||||
|
}
|
||||||
|
const beforeForm = await page.evaluate(detectFormScript());
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitForStable(beforeForm);
|
||||||
|
const state = await getFormState();
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err?.confirmation) {
|
||||||
|
if (save === true || save === false) {
|
||||||
|
const label = save ? 'Да' : 'Нет';
|
||||||
|
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
||||||
|
const btns = await page.$$(btnSel);
|
||||||
|
for (const b of btns) {
|
||||||
|
const txt = (await b.textContent()).trim();
|
||||||
|
if (txt === label) {
|
||||||
|
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
|
||||||
|
await b.click({ force: true });
|
||||||
|
await waitForStable(beforeForm);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const afterForm = await page.evaluate(detectFormScript());
|
||||||
|
return returnFormState({ closed: afterForm !== beforeForm });
|
||||||
|
}
|
||||||
|
state.confirmation = err.confirmation;
|
||||||
|
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return returnFormState({ closed: state.form !== beforeForm });
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// web-test forms/fill v1.19 — Fill form fields by name (text/checkbox/date/number/dropdown/reference). Delegates references to selectValue / fillReferenceField.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import {
|
||||||
|
page, ensureConnected, ACTION_WAIT, highlightMode, normYo,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
detectFormScript, resolveFieldsScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||||
|
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
||||||
|
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||||
|
import {
|
||||||
|
fillReferenceField, selectValue, pickFromSelectionForm,
|
||||||
|
isTypeDialog, pickFromTypeDialog,
|
||||||
|
} from './select-value.mjs';
|
||||||
|
import { pasteText } from '../core/clipboard.mjs';
|
||||||
|
import { returnFormState } from '../core/helpers.mjs';
|
||||||
|
|
||||||
|
/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */
|
||||||
|
export async function fillFields(fields) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error('fillFields: no form found');
|
||||||
|
|
||||||
|
// Resolve field names to element IDs
|
||||||
|
const resolved = await page.evaluate(resolveFieldsScript(formNum, fields));
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const r of resolved) {
|
||||||
|
if (r.error) {
|
||||||
|
results.push(r);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Auto-highlight the field input before filling
|
||||||
|
if (highlightMode && r.inputId) {
|
||||||
|
try {
|
||||||
|
await page.evaluate(({ id }) => {
|
||||||
|
const target = document.getElementById(id);
|
||||||
|
if (!target) return;
|
||||||
|
let div = document.getElementById('__web_test_highlight');
|
||||||
|
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||||
|
const r = target.getBoundingClientRect();
|
||||||
|
div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80';
|
||||||
|
}, { id: r.inputId });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await unhighlight();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Auto-enable DCS checkbox if resolved via label
|
||||||
|
if (r.dcsCheckbox && !r.dcsCheckbox.checked) {
|
||||||
|
await page.click(`[id="${r.dcsCheckbox.inputId}"]`);
|
||||||
|
await waitForStable();
|
||||||
|
}
|
||||||
|
const selector = `[id="${r.inputId}"]`;
|
||||||
|
// Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio)
|
||||||
|
const rawValue = fields[r.field];
|
||||||
|
const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined;
|
||||||
|
if (isEmpty && !r.isCheckbox && !r.isRadio) {
|
||||||
|
await page.click(selector);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Shift+F4');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await waitForStable();
|
||||||
|
results.push({ field: r.field, ok: true, value: '', method: 'clear' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r.isCheckbox) {
|
||||||
|
// Checkbox: compare desired with current, toggle if mismatch
|
||||||
|
const desired = String(fields[r.field]).toLowerCase();
|
||||||
|
const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired);
|
||||||
|
if (wantChecked !== r.checked) {
|
||||||
|
await page.click(selector);
|
||||||
|
await waitForStable();
|
||||||
|
}
|
||||||
|
results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' });
|
||||||
|
} else if (r.isRadio) {
|
||||||
|
// Radio button: find option by label (fuzzy match) and click it
|
||||||
|
const desired = normYo(String(fields[r.field]).toLowerCase());
|
||||||
|
const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired)
|
||||||
|
|| r.options.find(o => normYo(o.label.toLowerCase()).includes(desired));
|
||||||
|
if (opt) {
|
||||||
|
// Option 0 = base element (no suffix), options 1+ = #N#radio
|
||||||
|
const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`;
|
||||||
|
await page.click(`[id="${radioId}"]`);
|
||||||
|
await waitForStable();
|
||||||
|
results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' });
|
||||||
|
} else {
|
||||||
|
results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) });
|
||||||
|
}
|
||||||
|
} else if (r.hasSelect) {
|
||||||
|
// Combobox/reference with DLB: DLB-first, then paste fallback
|
||||||
|
const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum);
|
||||||
|
results.push(refResult);
|
||||||
|
} else if (r.hasPick && (r.isDate || r.isCalc)) {
|
||||||
|
// Date/time (calendar CB) or numeric (calculator CB) field — use paste:
|
||||||
|
// the pick button is a calendar/calculator widget, not a selection form.
|
||||||
|
await page.click(selector);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await pasteText(fields[r.field]);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await waitForStable();
|
||||||
|
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
|
||||||
|
} else if (r.hasPick) {
|
||||||
|
// Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form)
|
||||||
|
const svResult = await selectValue(r.field, String(fields[r.field]));
|
||||||
|
if (svResult?.error) {
|
||||||
|
results.push({ field: r.field, error: svResult.error, message: svResult.message });
|
||||||
|
} else {
|
||||||
|
results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain field: clipboard paste + Tab to commit
|
||||||
|
// page.fill() sets DOM value but doesn't trigger 1C input events;
|
||||||
|
// clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly.
|
||||||
|
await page.click(selector);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await pasteText(fields[r.field]);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await waitForStable();
|
||||||
|
results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
results.push({ field: r.field, error: e.message });
|
||||||
|
}
|
||||||
|
if (highlightMode) try { await unhighlight(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter(r => r.error);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n');
|
||||||
|
throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`);
|
||||||
|
}
|
||||||
|
return returnFormState({ filled: results });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */
|
||||||
|
export async function fillField(name, value) {
|
||||||
|
return fillFields({ [name]: value });
|
||||||
|
}
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
// web-test forms/select-value v1.24 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import {
|
||||||
|
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
detectFormScript, findFieldButtonScript, resolveFieldsScript,
|
||||||
|
readSubmenuScript, checkErrorsScript,
|
||||||
|
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
|
||||||
|
findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript,
|
||||||
|
findChildFormByButtonScript, readTypeDialogVisibleRowsScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
import { scanGridRowsScript } from '../../dom/grid.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||||
|
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||||
|
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||||
|
import {
|
||||||
|
safeClick, findFieldInputId, readEdd,
|
||||||
|
detectNewForm as helperDetectNewForm,
|
||||||
|
clickEddItemViaDispatch, clickShowAllInEdd, returnFormState,
|
||||||
|
} from '../core/helpers.mjs';
|
||||||
|
import { pasteText } from '../core/clipboard.mjs';
|
||||||
|
import { getFormState } from './state.mjs';
|
||||||
|
import { filterList } from '../table/filter.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan visible grid rows for a text match (exact → startsWith → includes).
|
||||||
|
* Returns center coords of the matched row, or null if not found.
|
||||||
|
* When searchLower is empty, returns coords of the first row (fallback).
|
||||||
|
*/
|
||||||
|
async function scanGridRows(formNum, searchLower) {
|
||||||
|
return page.evaluate(scanGridRowsScript(formNum, searchLower));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a row in a selection form via click + Enter, verify it closed.
|
||||||
|
* Uses click + Enter instead of dblclick because dblclick toggles
|
||||||
|
* expand/collapse in tree-style selection forms.
|
||||||
|
* Returns { field, ok: true, method: 'form' } on success,
|
||||||
|
* or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row).
|
||||||
|
*/
|
||||||
|
async function dblclickAndVerify(coords, selFormNum, fieldName) {
|
||||||
|
// Click to highlight the row, then Enter to confirm selection.
|
||||||
|
// This works for both flat grids and tree forms (dblclick would
|
||||||
|
// toggle expand/collapse on tree group rows).
|
||||||
|
await page.mouse.click(coords.x, coords.y);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await waitForStable(selFormNum);
|
||||||
|
|
||||||
|
// Verify selection form closed
|
||||||
|
const stillOpen = await page.evaluate(isFormVisibleScript(selFormNum));
|
||||||
|
if (stillOpen) {
|
||||||
|
// Enter didn't select — item is likely a non-selectable group.
|
||||||
|
// Don't Escape here — let the caller decide (may want to try another row).
|
||||||
|
return { field: fieldName, ok: false, reason: 'still_open' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 1C error modals after selection
|
||||||
|
const err = await page.evaluate(checkErrorsScript());
|
||||||
|
if (err?.modal) {
|
||||||
|
try {
|
||||||
|
const btn = await page.$('a.press.pressDefault');
|
||||||
|
if (btn) { await btn.click(); await page.waitForTimeout(500); }
|
||||||
|
} catch { /* OK */ }
|
||||||
|
}
|
||||||
|
return { field: fieldName, ok: true, method: 'form' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline advanced search on a selection form via Alt+F.
|
||||||
|
* Does NOT click any column — FieldSelector auto-populates with main representation.
|
||||||
|
* Switches to "по части строки" (CompareType#1) to avoid composite type issues.
|
||||||
|
* Does not throw — returns silently on failure.
|
||||||
|
*/
|
||||||
|
async function advancedSearchInline(formNum, text) {
|
||||||
|
try {
|
||||||
|
// 1. Open advanced search via Alt+F
|
||||||
|
await page.keyboard.press('Alt+f');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const dialogForm = await page.evaluate(detectFormScript());
|
||||||
|
if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog
|
||||||
|
|
||||||
|
// 2. Switch to "по части строки" (CompareType#1)
|
||||||
|
const radioClicked = await page.evaluate(findCompareTypeRadioScript(dialogForm, 1));
|
||||||
|
if (radioClicked && !radioClicked.already) {
|
||||||
|
await page.mouse.click(radioClicked.x, radioClicked.y);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fill Pattern field via clipboard paste
|
||||||
|
const patternId = await page.evaluate(findPatternInputIdScript(dialogForm));
|
||||||
|
if (!patternId) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.click(`[id="${patternId}"]`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await pasteText(text);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// 4. Click "Найти"
|
||||||
|
const findBtn = await page.evaluate(findNamedButtonScript('Найти'));
|
||||||
|
if (findBtn) {
|
||||||
|
await page.mouse.click(findBtn.x, findBtn.y);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Close advanced search dialog
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
|
||||||
|
if (!dialogVisible) break;
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
await waitForStable(formNum);
|
||||||
|
} catch { /* silently fail — caller will re-scan and handle not_found */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a value from an opened selection form.
|
||||||
|
*
|
||||||
|
* Strategy (escalating):
|
||||||
|
* 1. Scan visible rows for text match (exact → startsWith → includes)
|
||||||
|
* 2. Advanced search (Alt+F, "по части строки") → re-scan
|
||||||
|
* 3. Fallback: simple search (search input + Enter) → re-scan
|
||||||
|
* 4. Not found → Escape → error
|
||||||
|
*
|
||||||
|
* For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan.
|
||||||
|
* For empty search: pick first visible row.
|
||||||
|
*
|
||||||
|
* @param {number} selFormNum - selection form number
|
||||||
|
* @param {string} fieldName - field being filled (for error messages)
|
||||||
|
* @param {string|Object} search - string for simple search, or { field: value } for per-field search
|
||||||
|
* @param {number} origFormNum - original form number (to verify we returned)
|
||||||
|
* @returns {{ field, ok, method }} or {{ field, error, message }}
|
||||||
|
*/
|
||||||
|
export async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) {
|
||||||
|
const searchText = typeof search === 'string'
|
||||||
|
? search : (search ? Object.values(search).join(' ') : '');
|
||||||
|
const searchLower = normYo((searchText || '').toLowerCase());
|
||||||
|
|
||||||
|
// Helper: try to select a row; returns result if ok, null if item wasn't selectable (group).
|
||||||
|
let hadUnselectableMatch = false;
|
||||||
|
async function trySelect(row) {
|
||||||
|
const r = await dblclickAndVerify(row, selFormNum, fieldName);
|
||||||
|
if (r.ok) return r;
|
||||||
|
hadUnselectableMatch = true; // found match but couldn't select (possibly group row or overlay)
|
||||||
|
return null; // form still open, try next step
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Scan visible rows (no filtering)
|
||||||
|
if (searchLower) {
|
||||||
|
const row = await scanGridRows(selFormNum, searchLower);
|
||||||
|
if (row?.x) {
|
||||||
|
const r = await trySelect(row);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Advanced search (Alt+F — fast, no overlay issues)
|
||||||
|
if (typeof search === 'object' && search) {
|
||||||
|
// Per-field advanced search via filterList(val, {field})
|
||||||
|
for (const [fld, val] of Object.entries(search)) {
|
||||||
|
try {
|
||||||
|
await filterList(String(val), { field: fld });
|
||||||
|
} catch (e) {
|
||||||
|
// Re-throw programming errors (e.g. a missing import surfacing as
|
||||||
|
// ReferenceError) — only field-filter failures (not found / unsupported
|
||||||
|
// column) should be swallowed so we fall through to the re-scan.
|
||||||
|
if (e instanceof ReferenceError || e instanceof TypeError) throw e;
|
||||||
|
/* proceed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (searchLower) {
|
||||||
|
// Inline advanced search (Alt+F, "по части строки")
|
||||||
|
await advancedSearchInline(selFormNum, searchText);
|
||||||
|
}
|
||||||
|
if (searchLower) {
|
||||||
|
const row = await scanGridRows(selFormNum, searchLower);
|
||||||
|
if (row?.x) {
|
||||||
|
const r = await trySelect(row);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Fallback — simple search via search input (for forms without Alt+F support)
|
||||||
|
if (typeof search === 'string' && searchLower) {
|
||||||
|
const searchInputInfo = await page.evaluate(findSearchInputScript(selFormNum));
|
||||||
|
if (searchInputInfo) {
|
||||||
|
try {
|
||||||
|
await page.click(`[id="${searchInputInfo.id}"]`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await pasteText(searchText);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await waitForStable(selFormNum);
|
||||||
|
} catch { /* proceed */ }
|
||||||
|
const row = await scanGridRows(selFormNum, searchLower);
|
||||||
|
if (row?.x) {
|
||||||
|
const r = await trySelect(row);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Empty search → pick first row; otherwise not found
|
||||||
|
if (!searchLower) {
|
||||||
|
const row = await scanGridRows(selFormNum, '');
|
||||||
|
if (row?.x) {
|
||||||
|
const r = await trySelect(row);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitForStable();
|
||||||
|
const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search);
|
||||||
|
if (hadUnselectableMatch) {
|
||||||
|
return { field: fieldName, error: 'not_selectable',
|
||||||
|
message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' };
|
||||||
|
}
|
||||||
|
return { field: fieldName, error: 'not_found',
|
||||||
|
message: 'No matches in selection form for ' + searchDesc };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether a form is a type selection dialog ("Выбор типа данных").
|
||||||
|
* Type dialogs appear when selecting a value for a composite-type field.
|
||||||
|
*
|
||||||
|
* Detection signals (any one is sufficient):
|
||||||
|
* - form{N}_OK element exists (selection forms use "Выбрать", not "OK")
|
||||||
|
* - form{N}_ValueList grid exists (specific to type/value list dialogs)
|
||||||
|
* - Window title contains "Выбор типа" (title attr on .toplineBoxTitle)
|
||||||
|
*/
|
||||||
|
export async function isTypeDialog(formNum) {
|
||||||
|
return page.evaluate(isTypeDialogScript(formNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a type from the type selection dialog ("Выбор типа данных")
|
||||||
|
* using Ctrl+F search. The dialog has a virtual grid (~5 visible rows),
|
||||||
|
* so Ctrl+F is the only reliable way to find a type.
|
||||||
|
*
|
||||||
|
* Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) →
|
||||||
|
* verify selected row matches → Enter (OK)
|
||||||
|
*
|
||||||
|
* @param {number} formNum - type dialog form number
|
||||||
|
* @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт")
|
||||||
|
* @throws {Error} if type not found
|
||||||
|
*/
|
||||||
|
export async function pickFromTypeDialog(formNum, typeName) {
|
||||||
|
// The type dialog is a modal ValueList grid.
|
||||||
|
// Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists.
|
||||||
|
//
|
||||||
|
// Key constraints discovered during testing:
|
||||||
|
// - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true})
|
||||||
|
// which punches through the modal overlay to the form underneath
|
||||||
|
// - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog)
|
||||||
|
// - Buttons: use page.click({force:true}), NOT evaluate(() => el.click())
|
||||||
|
// because evaluate click doesn't trigger 1C's event chain properly
|
||||||
|
// - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти"
|
||||||
|
// - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open
|
||||||
|
|
||||||
|
const typeNorm = normYo(typeName.toLowerCase());
|
||||||
|
|
||||||
|
// Helper: read visible rows and find matching ones
|
||||||
|
async function readVisibleRows() {
|
||||||
|
return page.evaluate(readTypeDialogVisibleRowsScript(formNum, typeNorm));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: dismiss the type-selection dialog (and any child "Найти") on error.
|
||||||
|
// Escape closes the dialog chain, but a blind Escape×3 cascades into the underlying
|
||||||
|
// form. So press Escape only while THIS type dialog is still present, then stop —
|
||||||
|
// leaving the source form (and cell edit mode) for the caller to handle.
|
||||||
|
async function dismissTypeDialog() {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const stillOpen = await page.evaluate(
|
||||||
|
`!!document.getElementById('form${formNum}_OK') || !!document.getElementById('form${formNum}_ValueList')`);
|
||||||
|
if (!stillOpen) break;
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact-match preference: substring search can surface several types that merely CONTAIN the
|
||||||
|
// requested name (e.g. "Контрагент" → "Банковская карта контрагента", "Договор с контрагентом",
|
||||||
|
// …, "Контрагент"). Prefer the row equal to the requested name; only the absence of a single
|
||||||
|
// exact match among multiple substring hits is a genuine ambiguity.
|
||||||
|
function resolveExact(matches) {
|
||||||
|
if (!matches || matches.length === 0) return null;
|
||||||
|
if (matches.length === 1) return matches[0];
|
||||||
|
const exact = matches.filter(m => normYo((m.text || '').toLowerCase()) === typeNorm);
|
||||||
|
return exact.length === 1 ? exact[0] : null;
|
||||||
|
}
|
||||||
|
async function selectRowAndOk(row) {
|
||||||
|
await page.mouse.click(row.x, row.y);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.click(`#form${formNum}_OK`, { force: true });
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
}
|
||||||
|
// Focus the grid via evaluate (does NOT punch through the modal overlay like page.click).
|
||||||
|
async function focusGrid() {
|
||||||
|
await page.evaluate(`(() => {
|
||||||
|
const grid = document.getElementById('form${formNum}_ValueList');
|
||||||
|
if (!grid) return;
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (body) body.focus(); else grid.focus();
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists)
|
||||||
|
const scan = await readVisibleRows();
|
||||||
|
const scanPick = resolveExact(scan.matches);
|
||||||
|
if (scanPick) { await selectRowAndOk(scanPick); return; }
|
||||||
|
if (scan.matches.length > 1) {
|
||||||
|
await dismissTypeDialog();
|
||||||
|
await waitForStable();
|
||||||
|
throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Not in visible rows — Ctrl+F jumps near the match in the large virtual list.
|
||||||
|
await focusGrid();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Ctrl+F to open "Найти" dialog
|
||||||
|
await page.keyboard.press('Control+f');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Paste search text (focus is on "Что искать" field)
|
||||||
|
await page.keyboard.press('Control+a');
|
||||||
|
await pasteText(typeName);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Find the "Найти" dialog form number (it's > formNum)
|
||||||
|
const findFormNum = await page.evaluate(findChildFormByButtonScript(formNum, 'Find'));
|
||||||
|
|
||||||
|
if (findFormNum === null) {
|
||||||
|
await dismissTypeDialog();
|
||||||
|
await waitForStable();
|
||||||
|
throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click "Найти" — search is client-side (no server round-trip)
|
||||||
|
await page.click(`#form${findFormNum}_Find`, { force: true });
|
||||||
|
|
||||||
|
// "Найти" positions at the first match; the exact row is at or just below it. Read, and if the
|
||||||
|
// exact match is not yet in view, PageDown a few times (bounded) — virtualised grid, scrollTop
|
||||||
|
// stays 0 but the visible window changes. Poll each window for matches to settle.
|
||||||
|
let resolved = null, lastMatches = [], sawMatches = false;
|
||||||
|
for (let pageStep = 0; pageStep <= 3; pageStep++) {
|
||||||
|
if (pageStep > 0) { await focusGrid(); await page.keyboard.press('PageDown'); }
|
||||||
|
let v = null;
|
||||||
|
for (let w = 0; w < 5; w++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
v = await readVisibleRows();
|
||||||
|
if (v.matches.length) break;
|
||||||
|
}
|
||||||
|
if (v && v.matches.length) {
|
||||||
|
sawMatches = true;
|
||||||
|
lastMatches = v.matches;
|
||||||
|
resolved = resolveExact(v.matches);
|
||||||
|
if (resolved) break;
|
||||||
|
// matches present but no single exact in this window — scroll to look just below
|
||||||
|
} else if (sawMatches) {
|
||||||
|
break; // scrolled past the matches without finding an exact one
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolved) { await selectRowAndOk(resolved); return; }
|
||||||
|
|
||||||
|
await dismissTypeDialog();
|
||||||
|
await waitForStable();
|
||||||
|
if (!sawMatches) {
|
||||||
|
throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` +
|
||||||
|
`. Visible: ${(scan.visible || []).join(', ')}`);
|
||||||
|
}
|
||||||
|
throw new Error(`selectValue: multiple types match "${typeName}": ${lastMatches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill a reference field via clipboard paste + 1C autocomplete.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors)
|
||||||
|
* 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete)
|
||||||
|
* 3. Check editDropDown for autocomplete results → click match or Tab to resolve
|
||||||
|
* 4. Verify result: resolved → ok, not found → clear + error
|
||||||
|
*
|
||||||
|
* Clipboard paste was chosen because:
|
||||||
|
* - Ctrl+V produces trusted browser events that 1C respects for autocomplete
|
||||||
|
* - page.fill() + synthetic keydown/keyup only triggers hints, not real search
|
||||||
|
* - keyboard.type() garbles Cyrillic on some fields
|
||||||
|
*
|
||||||
|
* @returns {{ field, ok?, method?, error?, value?, message?, available? }}
|
||||||
|
*/
|
||||||
|
export async function fillReferenceField(selector, fieldName, value, formNum) {
|
||||||
|
const text = String(value);
|
||||||
|
const escapedSel = selector.replace(/'/g, "\\'");
|
||||||
|
|
||||||
|
// Helper: detect new forms opened above the current one (strict — interactive
|
||||||
|
// elements only; fillReferenceField-specific)
|
||||||
|
const detectNewForm = () => helperDetectNewForm(formNum, { strict: true });
|
||||||
|
|
||||||
|
// Helper: clear the field using Shift+F4 (native 1C mechanism)
|
||||||
|
async function clearField() {
|
||||||
|
try {
|
||||||
|
await page.click(selector, { timeout: 3000 });
|
||||||
|
await page.keyboard.press('Shift+F4');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
} catch { /* OK */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке")
|
||||||
|
async function checkNotInListCloud() {
|
||||||
|
return page.evaluate(isNotInListCloudVisibleScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. Dismiss any leftover error modal from a previous operation
|
||||||
|
await dismissPendingErrors();
|
||||||
|
|
||||||
|
// 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields
|
||||||
|
// and also for reference fields that show a dropdown.
|
||||||
|
const inputId = selector.match(/\[id="(.+)"\]/)?.[1];
|
||||||
|
// DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB)
|
||||||
|
const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB';
|
||||||
|
const dlbSelector = `[id="${dlbId}"]`;
|
||||||
|
try {
|
||||||
|
const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`);
|
||||||
|
if (dlbVisible) {
|
||||||
|
await page.click(dlbSelector);
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const eddState = await readEdd();
|
||||||
|
if (eddState.visible && eddState.items?.length > 0) {
|
||||||
|
const target = normYo(text.toLowerCase());
|
||||||
|
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
|
||||||
|
let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target);
|
||||||
|
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||||
|
if (!match) match = candidates.find(i => {
|
||||||
|
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||||
|
return name.includes(target) || target.includes(name);
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
await page.mouse.click(match.x, match.y);
|
||||||
|
await waitForStable();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
return { field: fieldName, ok: true, method: 'dropdown',
|
||||||
|
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
|
||||||
|
}
|
||||||
|
// No match in DLB dropdown — close and fall through to paste approach
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
} else if (eddState.visible) {
|
||||||
|
// DLB opened a hint popup (no .eddText items) — close it before proceeding
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* DLB approach failed — fall through to paste */ }
|
||||||
|
|
||||||
|
// 1. Focus (handle surface/modal overlay from previous interaction)
|
||||||
|
await safeClick(selector, { dismissErrors: true });
|
||||||
|
|
||||||
|
// 2. If field already has a value, clear using Shift+F4 (native 1C mechanism).
|
||||||
|
// This is needed for reference fields — Shift+F4 properly clears the ref link.
|
||||||
|
const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
|
||||||
|
if (currentVal) {
|
||||||
|
await page.keyboard.press('Shift+F4');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
// Refocus
|
||||||
|
await page.click(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete)
|
||||||
|
await pasteText(text);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 4. Check editDropDown for autocomplete suggestions
|
||||||
|
const eddState = await readEdd();
|
||||||
|
|
||||||
|
if (eddState.visible && eddState.items?.length > 0) {
|
||||||
|
const target = normYo(text.toLowerCase());
|
||||||
|
// Separate real matches from "Создать:" items
|
||||||
|
const candidates = eddState.items.filter(i => !i.name.startsWith('Создать'));
|
||||||
|
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
// Find best match (items have format "Name (Code)" — match against name part)
|
||||||
|
let match = candidates.find(i => {
|
||||||
|
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||||
|
return name === target;
|
||||||
|
});
|
||||||
|
if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||||
|
if (!match) match = candidates.find(i => {
|
||||||
|
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||||
|
return name.includes(target) || target.includes(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
await page.mouse.click(match.x, match.y);
|
||||||
|
await waitForStable();
|
||||||
|
await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async
|
||||||
|
return { field: fieldName, ok: true, method: 'dropdown',
|
||||||
|
value: match.name.replace(/\s*\([^)]*\)\s*$/, '') };
|
||||||
|
}
|
||||||
|
// Candidates exist but none match — report them
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clearField();
|
||||||
|
return { field: fieldName, error: 'not_matched',
|
||||||
|
available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only "Создать:" items — no existing matches
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clearField();
|
||||||
|
return { field: fieldName, error: 'not_found',
|
||||||
|
message: 'No existing values match "' + text + '"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4b. No edd — check for "not in list" cloud that may have appeared during paste
|
||||||
|
if (await checkNotInListCloud()) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clearField();
|
||||||
|
return { field: fieldName, error: 'not_found',
|
||||||
|
message: 'Value "' + text + '" not found (not in list)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. No edd at all — press Tab to trigger direct resolve
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await waitForStable();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
|
||||||
|
// 5x. Check for "not in list" cloud popup after Tab
|
||||||
|
if (await checkNotInListCloud()) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clearField();
|
||||||
|
return { field: fieldName, error: 'not_found',
|
||||||
|
message: 'Value "' + text + '" not found (not in list)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5a. New form opened? (creation form = value not found)
|
||||||
|
const newForm = await detectNewForm();
|
||||||
|
if (newForm !== null) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitForStable();
|
||||||
|
await clearField();
|
||||||
|
return { field: fieldName, error: 'not_found',
|
||||||
|
message: 'Value "' + text + '" not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5b. Dropdown after Tab?
|
||||||
|
const popup = await page.evaluate(readSubmenuScript());
|
||||||
|
if (Array.isArray(popup) && popup.length > 0) {
|
||||||
|
const realItems = popup.filter(i => !i.name.startsWith('Создать'));
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await clearField();
|
||||||
|
if (realItems.length > 0) {
|
||||||
|
return { field: fieldName, error: 'ambiguous',
|
||||||
|
message: 'Multiple matches for "' + text + '"',
|
||||||
|
available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) };
|
||||||
|
}
|
||||||
|
return { field: fieldName, error: 'not_found',
|
||||||
|
message: 'Value "' + text + '" not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5c. Check final value
|
||||||
|
const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`);
|
||||||
|
if (!finalVal) {
|
||||||
|
// 6. Last resort: try F4 to open selection form and pick from there
|
||||||
|
try {
|
||||||
|
await page.click(selector);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
} catch { /* OK — field may be unfocused */ }
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
|
||||||
|
const selFormNum = await detectNewForm();
|
||||||
|
if (selFormNum !== null) {
|
||||||
|
const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum);
|
||||||
|
if (pickResult.ok) return pickResult;
|
||||||
|
// pickFromSelectionForm already closed the form on error
|
||||||
|
}
|
||||||
|
|
||||||
|
return { field: fieldName, error: 'not_found',
|
||||||
|
message: 'Value "' + text + '" not found (field is empty)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { field: fieldName, ok: true, method: 'typeahead', value: finalVal };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a value from a reference field (compound operation).
|
||||||
|
* Handles three patterns:
|
||||||
|
* A) DLB opens an inline dropdown popup — click matching item
|
||||||
|
* B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form
|
||||||
|
* C) DLB opens a separate selection form directly — search + dblclick in grid
|
||||||
|
*/
|
||||||
|
export async function selectValue(fieldName, searchText, { type } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error(`selectValue: no form found`);
|
||||||
|
|
||||||
|
// Detect any new form opened above this one (broad — includes type dialogs).
|
||||||
|
// Hoisted to the top so the composite-type branch can call it before its
|
||||||
|
// original declaration site further below.
|
||||||
|
const detectNewForm = () => helperDetectNewForm(formNum);
|
||||||
|
|
||||||
|
// 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields)
|
||||||
|
let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB'));
|
||||||
|
if (btn?.error === 'button_not_found') {
|
||||||
|
btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB'));
|
||||||
|
}
|
||||||
|
if (btn?.error) return btn;
|
||||||
|
if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||||
|
try {
|
||||||
|
|
||||||
|
// === CLEAR FIELD if searchText is empty/null ===
|
||||||
|
if (!searchText && searchText !== 0) {
|
||||||
|
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||||
|
if (inputId) {
|
||||||
|
await page.click(`[id="${inputId}"]`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Shift+F4');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await waitForStable();
|
||||||
|
}
|
||||||
|
if (highlightMode) try { await unhighlight(); } catch {}
|
||||||
|
return returnFormState({ selected: { field: fieldName, search: null, method: 'clear' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// === COMPOSITE TYPE HANDLING ===
|
||||||
|
// When `type` is specified, clear the field first to reset cached type,
|
||||||
|
// then open type selection dialog, pick the type, then pick the value.
|
||||||
|
if (type) {
|
||||||
|
// Find and focus the field input
|
||||||
|
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||||
|
if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`);
|
||||||
|
|
||||||
|
// Clear cached type + value with Shift+F4
|
||||||
|
await page.click(`[id="${inputId}"]`);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Shift+F4');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Re-focus and press F4 to open type selection dialog
|
||||||
|
await page.click(`[id="${inputId}"]`);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
await waitForStable(formNum);
|
||||||
|
|
||||||
|
const newFormNum = await detectNewForm();
|
||||||
|
if (newFormNum === null) {
|
||||||
|
throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isTypeDialog(newFormNum)) {
|
||||||
|
// Pick type from the dialog
|
||||||
|
await pickFromTypeDialog(newFormNum, type);
|
||||||
|
await waitForStable(newFormNum);
|
||||||
|
|
||||||
|
// After type selection, the actual selection form should open
|
||||||
|
const selFormNum = await detectSelectionForm();
|
||||||
|
if (selFormNum === null) {
|
||||||
|
throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
|
||||||
|
const state = await getFormState();
|
||||||
|
state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' };
|
||||||
|
if (pickResult.error) state.selected.error = pickResult.error;
|
||||||
|
if (pickResult.message) state.selected.message = pickResult.message;
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err) state.errors = err;
|
||||||
|
return state;
|
||||||
|
} else {
|
||||||
|
// Not a type dialog — field is not composite type, proceed with normal selection
|
||||||
|
const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum);
|
||||||
|
const state = await getFormState();
|
||||||
|
state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
|
||||||
|
if (pickResult.error) state.selected.error = pickResult.error;
|
||||||
|
if (pickResult.message) state.selected.message = pickResult.message;
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err) state.errors = err;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// === END COMPOSITE TYPE HANDLING ===
|
||||||
|
|
||||||
|
// Auto-enable DCS checkbox if resolved via label
|
||||||
|
if (btn.dcsCheckbox) {
|
||||||
|
const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`;
|
||||||
|
const isChecked = await page.$eval(cbSel, el =>
|
||||||
|
el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'));
|
||||||
|
if (!isChecked) { await page.click(cbSel); await waitForStable(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: detect selection form (form number > formNum, strict mode)
|
||||||
|
async function detectSelectionForm() {
|
||||||
|
return helperDetectNewForm(formNum, { strict: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectNewForm is hoisted at the top of selectValue (see above).
|
||||||
|
|
||||||
|
// Helper: open selection form and pick value
|
||||||
|
async function openFormAndPick() {
|
||||||
|
await waitForStable(formNum);
|
||||||
|
const selFormNum = await detectSelectionForm();
|
||||||
|
if (selFormNum !== null) {
|
||||||
|
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
|
||||||
|
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
|
||||||
|
if (pickResult.error) selected.error = pickResult.error;
|
||||||
|
if (pickResult.message) selected.message = pickResult.message;
|
||||||
|
return returnFormState({ selected });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locals → dom-scripts in helpers.mjs (see clickEddItemViaDispatch / clickShowAllInEdd)
|
||||||
|
const clickEddItem = clickEddItemViaDispatch;
|
||||||
|
const clickShowAll = clickShowAllInEdd;
|
||||||
|
|
||||||
|
// 2. Click DLB (handle funcPanel / surface overlay intercept)
|
||||||
|
const dlbSel = `[id="${btn.buttonId}"]`;
|
||||||
|
await safeClick(dlbSel, { timeout: 5000 });
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
|
||||||
|
// 3A. Check if a dropdown popup appeared (inline quick selection)
|
||||||
|
const popupItems = await page.evaluate(readSubmenuScript());
|
||||||
|
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||||
|
const regularItems = popupItems.filter(i => i.kind !== 'showAll');
|
||||||
|
const showAllItem = popupItems.find(i => i.kind === 'showAll');
|
||||||
|
|
||||||
|
if (searchText && typeof searchText !== 'string') {
|
||||||
|
// Object search ({field: value}) can't be matched against dropdown item
|
||||||
|
// text — close the typeahead popup and open the full selection form, which
|
||||||
|
// handles per-field advanced search (pickFromSelectionForm → filterList).
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||||
|
if (inputId) { await page.click(`[id="${inputId}"]`); await page.waitForTimeout(300); }
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
const formResult = await openFormAndPick();
|
||||||
|
if (formResult) return formResult;
|
||||||
|
throw new Error(`selectValue: object search ${JSON.stringify(searchText)} for "${btn.fieldName}" did not open a selection form`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText) {
|
||||||
|
const target = normYo(searchText.toLowerCase());
|
||||||
|
// Try to find match among regular dropdown items
|
||||||
|
let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||||
|
if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||||
|
if (!match) match = regularItems.find(i => {
|
||||||
|
const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase());
|
||||||
|
return name === target || name.includes(target) || target.includes(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
// Click via evaluate to bypass div.surface overlay
|
||||||
|
await clickEddItem(match.name);
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({ selected: { field: btn.fieldName, search: searchText, method: 'dropdown' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match in dropdown — try "Показать все" to open selection form
|
||||||
|
if (showAllItem) {
|
||||||
|
await clickShowAll();
|
||||||
|
const formResult = await openFormAndPick();
|
||||||
|
if (formResult) return formResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No "Показать все" — close dropdown, try F4
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Focus the field input and press F4 to open selection form
|
||||||
|
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||||
|
if (inputId) {
|
||||||
|
await page.click(`[id="${inputId}"]`);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
|
||||||
|
const formResult = await openFormAndPick();
|
||||||
|
if (formResult) return formResult;
|
||||||
|
|
||||||
|
// Still nothing — report available items from original dropdown
|
||||||
|
throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No search text — click first regular item
|
||||||
|
if (regularItems.length > 0) {
|
||||||
|
await clickEddItem(regularItems[0].name);
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({ selected: { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs)
|
||||||
|
const selFormNum = await detectNewForm();
|
||||||
|
if (selFormNum !== null) {
|
||||||
|
// Auto-detect type selection dialog when `type` was not specified
|
||||||
|
if (await isTypeDialog(selFormNum)) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitForStable();
|
||||||
|
throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`);
|
||||||
|
}
|
||||||
|
const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum);
|
||||||
|
const selected = { field: btn.fieldName, search: searchText || null, method: 'form' };
|
||||||
|
if (pickResult.error) selected.error = pickResult.error;
|
||||||
|
if (pickResult.message) selected.message = pickResult.message;
|
||||||
|
return returnFormState({ selected });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3C. Neither popup nor form — try F4 as last resort
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const inputId = await findFieldInputId(formNum, btn.fieldName);
|
||||||
|
if (inputId) {
|
||||||
|
await page.click(`[id="${inputId}"]`);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
await page.waitForTimeout(ACTION_WAIT);
|
||||||
|
|
||||||
|
const formResult = await openFormAndPick();
|
||||||
|
if (formResult) return formResult;
|
||||||
|
|
||||||
|
throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`);
|
||||||
|
|
||||||
|
} finally { if (highlightMode) try { await unhighlight(); } catch {} }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// web-test engine/forms/state v1.17 — central form-state reader.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// getFormState — the canonical "what's on the screen right now" call. Combines:
|
||||||
|
// 1. DOM script (getFormStateScript) → form structure (fields, buttons, tables, openForms, ...)
|
||||||
|
// 2. checkForErrors → state.errors + state.confirmation hint
|
||||||
|
// 3. detectPlatformDialogs → state.platformDialogs (About / Support Info / Error Report)
|
||||||
|
//
|
||||||
|
// Returned by virtually every action-function as the "after" snapshot.
|
||||||
|
|
||||||
|
import { page, ensureConnected } from '../core/state.mjs';
|
||||||
|
import { getFormStateScript } from '../../dom.mjs';
|
||||||
|
import { checkForErrors, detectPlatformDialogs } from '../core/errors.mjs';
|
||||||
|
|
||||||
|
/** Read current form state. Single evaluate call via combined script. */
|
||||||
|
export async function getFormState() {
|
||||||
|
ensureConnected();
|
||||||
|
const state = await page.evaluate(getFormStateScript());
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err) {
|
||||||
|
state.errors = err;
|
||||||
|
if (err.confirmation) {
|
||||||
|
state.confirmation = err.confirmation;
|
||||||
|
state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Detect platform-level dialogs (About, Support Info, Error Report)
|
||||||
|
// These are NOT 1C forms — invisible to detectForms() and not closeable via Escape.
|
||||||
|
const pd = await detectPlatformDialogs();
|
||||||
|
if (pd.length) state.platformDialogs = pd;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// web-test nav/navigation v1.17 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import {
|
||||||
|
page, ensureConnected, highlightMode, resolveProjectPath,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
readSectionsScript, readTabsScript, readCommandsScript,
|
||||||
|
navigateSectionScript, openCommandScript, switchTabScript,
|
||||||
|
detectFormScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||||
|
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||||
|
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||||
|
import { returnFormState } from '../core/helpers.mjs';
|
||||||
|
// Static import — ESM cycle that resolves at call time.
|
||||||
|
import { pasteText } from '../core/clipboard.mjs';
|
||||||
|
import { getFormState } from '../forms/state.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current page state: active section, tabs.
|
||||||
|
* Combined into a single evaluate call.
|
||||||
|
*/
|
||||||
|
export async function getPageState() {
|
||||||
|
ensureConnected();
|
||||||
|
const { sections, tabs } = await page.evaluate(`({
|
||||||
|
sections: ${readSectionsScript()},
|
||||||
|
tabs: ${readTabsScript()}
|
||||||
|
})`);
|
||||||
|
const activeSection = sections.find(s => s.active)?.name || null;
|
||||||
|
const activeTab = tabs.find(t => t.active)?.name || null;
|
||||||
|
return { activeSection, activeTab, sections, tabs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read section panel + commands in a single evaluate call. */
|
||||||
|
export async function getSections() {
|
||||||
|
ensureConnected();
|
||||||
|
const { sections, commands } = await page.evaluate(`({
|
||||||
|
sections: ${readSectionsScript()},
|
||||||
|
commands: ${readCommandsScript()}
|
||||||
|
})`);
|
||||||
|
const activeSection = sections.find(s => s.active)?.name || null;
|
||||||
|
return { activeSection, sections, commands };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a section by name. Returns new state with commands. */
|
||||||
|
export async function navigateSection(name) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||||
|
const result = await page.evaluate(navigateSectionScript(name));
|
||||||
|
if (result?.error) {
|
||||||
|
const avail = result.available?.filter(Boolean);
|
||||||
|
if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`);
|
||||||
|
throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForStable();
|
||||||
|
const { sections, commands } = await page.evaluate(`({
|
||||||
|
sections: ${readSectionsScript()},
|
||||||
|
commands: ${readCommandsScript()}
|
||||||
|
})`);
|
||||||
|
return returnFormState({ navigated: result, sections, commands });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read commands of the current section. */
|
||||||
|
export async function getCommands() {
|
||||||
|
ensureConnected();
|
||||||
|
return await page.evaluate(readCommandsScript());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a command from function panel by name. Returns new form state. */
|
||||||
|
export async function openCommand(name) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {}
|
||||||
|
const formBefore = await page.evaluate(detectFormScript());
|
||||||
|
const result = await page.evaluate(openCommandScript(name));
|
||||||
|
if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||||
|
|
||||||
|
await waitForStable(formBefore);
|
||||||
|
return await returnFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch to an open tab by name (fuzzy match). Returns updated form state. */
|
||||||
|
export async function switchTab(name) {
|
||||||
|
ensureConnected();
|
||||||
|
const result = await page.evaluate(switchTabScript(name));
|
||||||
|
if (result?.error) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`);
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// English → Russian metadata type mapping for e1cib navigation links
|
||||||
|
const E1CIB_TYPE_MAP = {
|
||||||
|
'catalog': 'Справочник', 'catalogs': 'Справочник',
|
||||||
|
'document': 'Документ', 'documents': 'Документ',
|
||||||
|
'commonmodule': 'ОбщийМодуль',
|
||||||
|
'enum': 'Перечисление', 'enums': 'Перечисление',
|
||||||
|
'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка',
|
||||||
|
'report': 'Отчет', 'reports': 'Отчет',
|
||||||
|
'accumulationregister': 'РегистрНакопления',
|
||||||
|
'informationregister': 'РегистрСведений',
|
||||||
|
'accountingregister': 'РегистрБухгалтерии',
|
||||||
|
'calculationregister': 'РегистрРасчета',
|
||||||
|
'chartofaccounts': 'ПланСчетов',
|
||||||
|
'chartofcharacteristictypes': 'ПланВидовХарактеристик',
|
||||||
|
'chartofcalculationtypes': 'ПланВидовРасчета',
|
||||||
|
'businessprocess': 'БизнесПроцесс',
|
||||||
|
'task': 'Задача',
|
||||||
|
'exchangeplan': 'ПланОбмена',
|
||||||
|
'constant': 'Константа',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Types that open via e1cib/app/ (reports and data processors have their own app forms)
|
||||||
|
const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']);
|
||||||
|
|
||||||
|
function normalizeE1cibUrl(url) {
|
||||||
|
// Already a full e1cib link
|
||||||
|
if (url.startsWith('e1cib/')) return url;
|
||||||
|
// "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix
|
||||||
|
const dot = url.indexOf('.');
|
||||||
|
if (dot > 0) {
|
||||||
|
const typePart = url.substring(0, dot);
|
||||||
|
const namePart = url.substring(dot + 1);
|
||||||
|
const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart;
|
||||||
|
const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list';
|
||||||
|
return `${prefix}/${ruType}.${namePart}`;
|
||||||
|
}
|
||||||
|
return `e1cib/list/${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an external data processor or report (EPF/ERF) via File → Open menu.
|
||||||
|
* Handles the security confirmation dialog on first open.
|
||||||
|
* @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd)
|
||||||
|
* @returns {Promise<object>} form state of the opened processor/report
|
||||||
|
*/
|
||||||
|
export async function openFile(filePath) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const absPath = resolveProjectPath(filePath.replace(/\\/g, '/'));
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open
|
||||||
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||||
|
const formBefore = await page.evaluate(detectFormScript());
|
||||||
|
|
||||||
|
// 1. Ctrl+O opens 1C's "Выбор файлов" dialog
|
||||||
|
await page.keyboard.press('Control+o');
|
||||||
|
|
||||||
|
// 2. Wait for the file selection dialog
|
||||||
|
const dialogOk = await waitForCondition(`(() => {
|
||||||
|
const ok = document.querySelector('#fileSelectDialogOk');
|
||||||
|
return ok && ok.offsetWidth > 0 ? true : false;
|
||||||
|
})()`, 3000);
|
||||||
|
if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)");
|
||||||
|
|
||||||
|
// 3. Click "выберите с диска" to trigger the native OS file picker
|
||||||
|
let fileChooser;
|
||||||
|
try {
|
||||||
|
[fileChooser] = await Promise.all([
|
||||||
|
page.waitForEvent('filechooser', { timeout: 5000 }),
|
||||||
|
page.click('a.underline.pointer'),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
// Try closing the dialog before throwing
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
throw new Error(`File chooser did not appear: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Set the file path and click OK
|
||||||
|
await fileChooser.setFiles(absPath);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.click('#fileSelectDialogOk');
|
||||||
|
await waitForStable(formBefore);
|
||||||
|
|
||||||
|
// 5. Check for security dialog
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err?.confirmation) {
|
||||||
|
// Security confirmation — click the positive button (Продолжить/Да/OK)
|
||||||
|
const positiveBtn = err.confirmation.buttons.find(b =>
|
||||||
|
/продолжить|да|ok|yes|открыть/i.test(b)
|
||||||
|
) || err.confirmation.buttons[0];
|
||||||
|
if (positiveBtn) {
|
||||||
|
const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`);
|
||||||
|
for (const b of btns) {
|
||||||
|
const txt = (await b.textContent())?.trim();
|
||||||
|
if (txt === positiveBtn) { await b.click(); break; }
|
||||||
|
}
|
||||||
|
await waitForStable(formBefore);
|
||||||
|
}
|
||||||
|
// After confirmation, check if EPF form appeared or a follow-up dialog showed.
|
||||||
|
// Check form change FIRST — avoids confusing a small EPF form with a modal dialog.
|
||||||
|
const formAfter = await page.evaluate(detectFormScript());
|
||||||
|
if (formAfter != null && formAfter !== formBefore) {
|
||||||
|
// New form appeared — but is it the EPF or an informational dialog?
|
||||||
|
// Informational "re-open" dialogs are tiny (< 20 elements).
|
||||||
|
const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`);
|
||||||
|
if (elCount < 20) {
|
||||||
|
// Likely an info dialog — check and dismiss
|
||||||
|
const err2 = await checkForErrors();
|
||||||
|
if (err2?.modal) {
|
||||||
|
await dismissPendingErrors();
|
||||||
|
await waitForStable(formBefore);
|
||||||
|
continue; // retry open cycle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// It's the real EPF form
|
||||||
|
return returnFormState({ opened: { file: absPath, attempt: attempt + 1 } });
|
||||||
|
}
|
||||||
|
// Form didn't appear — retry
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No security dialog — check if form appeared
|
||||||
|
if (err?.modal) {
|
||||||
|
throw new Error(`Error opening file: ${err.modal.message}`);
|
||||||
|
}
|
||||||
|
const formAfter = await page.evaluate(detectFormScript());
|
||||||
|
if (formAfter != null && formAfter !== formBefore) {
|
||||||
|
const state = await getFormState();
|
||||||
|
state.opened = { file: absPath, attempt: attempt + 1 };
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */
|
||||||
|
export async function navigateLink(url) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const link = normalizeE1cibUrl(url);
|
||||||
|
const formBefore = await page.evaluate(detectFormScript());
|
||||||
|
|
||||||
|
// Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content)
|
||||||
|
await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 });
|
||||||
|
await waitForStable();
|
||||||
|
|
||||||
|
// Click "Перейти" in the navigation dialog
|
||||||
|
const dialog = await page.evaluate(detectFormScript());
|
||||||
|
if (dialog != null && dialog !== formBefore) {
|
||||||
|
const btns = await page.$$(`#form${dialog}_container a.press`);
|
||||||
|
for (const b of btns) {
|
||||||
|
const txt = (await b.textContent())?.trim();
|
||||||
|
if (txt === 'Перейти') { await b.click(); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitForStable(formBefore);
|
||||||
|
return await returnFormState();
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// web-test recording/captions v1.17 — Overlay primitives: captions, title slides, image overlays.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { existsSync as fsExistsSync, readFileSync } from 'fs';
|
||||||
|
import { extname } from 'path';
|
||||||
|
import {
|
||||||
|
page, recorder, lastCaptions, ensureConnected, resolveProjectPath,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a text caption overlay on the page (visible in recording).
|
||||||
|
* Calling again updates the text without creating a new element.
|
||||||
|
* @param {string} text — caption text
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
|
||||||
|
* @param {number} [opts.fontSize=24] — font size in pixels
|
||||||
|
* @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
|
||||||
|
* @param {string} [opts.color='#fff'] — text color
|
||||||
|
* @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text,
|
||||||
|
* pass a string for custom narration, or false to skip narration for this caption.
|
||||||
|
*/
|
||||||
|
export async function showCaption(text, opts = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
|
||||||
|
// Collect caption for TTS narration if recording
|
||||||
|
let smartWaitMs = 0;
|
||||||
|
if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) {
|
||||||
|
const speech = typeof opts.speech === 'string' ? opts.speech : text;
|
||||||
|
// Use video timeline position (accounts for frame duplication) instead of wall-clock
|
||||||
|
recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||||
|
// Estimate TTS duration and wait so the video has enough screen time for voiceover
|
||||||
|
smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
|
||||||
|
}
|
||||||
|
const position = opts.position || 'bottom';
|
||||||
|
const fontSize = opts.fontSize || 24;
|
||||||
|
const bg = opts.background || 'rgba(0,0,0,0.7)';
|
||||||
|
const color = opts.color || '#fff';
|
||||||
|
|
||||||
|
await page.evaluate(({ text, position, fontSize, bg, color }) => {
|
||||||
|
let el = document.getElementById('__web_test_caption');
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.id = '__web_test_caption';
|
||||||
|
el.style.cssText = `
|
||||||
|
position: fixed; left: 0; right: 0; z-index: 99999;
|
||||||
|
text-align: center; padding: 12px 24px;
|
||||||
|
font-family: Arial, sans-serif; pointer-events: none;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
|
||||||
|
el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
|
||||||
|
el.style.fontSize = fontSize + 'px';
|
||||||
|
el.style.background = bg;
|
||||||
|
el.style.color = color;
|
||||||
|
el.textContent = text;
|
||||||
|
}, { text, position, fontSize, bg, color });
|
||||||
|
|
||||||
|
// Smart TTS wait: pause for estimated speech duration so video has enough screen time.
|
||||||
|
// Split into chunks and flush frames periodically — CDP doesn't send screencast frames
|
||||||
|
// for static pages, so we must write duplicate frames to keep video timeline in sync.
|
||||||
|
if (smartWaitMs > 0) {
|
||||||
|
let remaining = smartWaitMs;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 1000);
|
||||||
|
await page.waitForTimeout(chunk);
|
||||||
|
remaining -= chunk;
|
||||||
|
if (recorder?._flushFrames) recorder._flushFrames();
|
||||||
|
}
|
||||||
|
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the caption overlay from the page. */
|
||||||
|
export async function hideCaption() {
|
||||||
|
ensureConnected();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.getElementById('__web_test_caption');
|
||||||
|
if (el) el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get captions collected during the current or last recording.
|
||||||
|
* @returns {Array<{text: string, speech: string, time: number}>}
|
||||||
|
*/
|
||||||
|
export function getCaptions() {
|
||||||
|
if (recorder) return [...recorder.captions];
|
||||||
|
return [...lastCaptions];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a full-screen title slide overlay (for video recordings).
|
||||||
|
* Repeated calls update the content. Use hideTitleSlide() to remove.
|
||||||
|
* @param {string} text Title text (\n → line break)
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.subtitle] Smaller text below the title
|
||||||
|
* @param {string} [opts.background] CSS background (default: dark gradient)
|
||||||
|
* @param {string} [opts.color] Text color (default: '#fff')
|
||||||
|
* @param {number} [opts.fontSize] Title font size in px (default: 36)
|
||||||
|
*/
|
||||||
|
export async function showTitleSlide(text, opts = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
const {
|
||||||
|
subtitle = '',
|
||||||
|
background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
||||||
|
color = '#fff',
|
||||||
|
fontSize = 36,
|
||||||
|
speech,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// Collect caption for TTS narration if recording
|
||||||
|
let smartWaitMs = 0;
|
||||||
|
if (recorder && speech && speech !== false) {
|
||||||
|
const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
|
||||||
|
if (captionText) {
|
||||||
|
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||||
|
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
|
||||||
|
let div = document.getElementById('__web_test_title');
|
||||||
|
if (!div) {
|
||||||
|
div = document.createElement('div');
|
||||||
|
div.id = '__web_test_title';
|
||||||
|
document.body.appendChild(div);
|
||||||
|
}
|
||||||
|
div.style.cssText = [
|
||||||
|
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||||
|
`background:${background}`,
|
||||||
|
'display:flex', 'align-items:center', 'justify-content:center',
|
||||||
|
'z-index:999999', 'pointer-events:none',
|
||||||
|
].join(';');
|
||||||
|
// Remove other overlays to prevent flash between slides
|
||||||
|
const img = document.getElementById('__web_test_image');
|
||||||
|
if (img) img.remove();
|
||||||
|
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/\n/g, '<br>');
|
||||||
|
let html = `<div style="font-size:${fontSize}px;font-weight:600;line-height:1.4;">${esc(text)}</div>`;
|
||||||
|
if (subtitle) {
|
||||||
|
html += `<div style="font-size:${Math.round(fontSize * 0.5)}px;margin-top:16px;opacity:0.7;">${esc(subtitle)}</div>`;
|
||||||
|
}
|
||||||
|
div.innerHTML = `<div style="text-align:center;max-width:70%;color:${color};font-family:'Segoe UI',Arial,sans-serif;">${html}</div>`;
|
||||||
|
}, { text, subtitle, background, color, fontSize });
|
||||||
|
|
||||||
|
// Smart TTS wait (same pattern as showCaption/showImage)
|
||||||
|
if (smartWaitMs > 0) {
|
||||||
|
let remaining = smartWaitMs;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 1000);
|
||||||
|
await page.waitForTimeout(chunk);
|
||||||
|
remaining -= chunk;
|
||||||
|
if (recorder?._flushFrames) recorder._flushFrames();
|
||||||
|
}
|
||||||
|
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the title slide overlay. */
|
||||||
|
export async function hideTitleSlide() {
|
||||||
|
ensureConnected();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.getElementById('__web_test_title');
|
||||||
|
if (el) el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a full-screen image overlay (e.g. presentation slide screenshot).
|
||||||
|
* Reads the image file, base64-encodes it, and renders as a fixed overlay
|
||||||
|
* on the page — captured by CDP screencast automatically.
|
||||||
|
*
|
||||||
|
* Style presets:
|
||||||
|
* - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow
|
||||||
|
* - 'dark' — dark background (#2a2a2a) with shadow
|
||||||
|
* - 'light' — white background with shadow
|
||||||
|
* - 'full' — image covers entire screen, no padding/shadow
|
||||||
|
*
|
||||||
|
* Custom background overrides the preset (e.g. background: '#003366').
|
||||||
|
*
|
||||||
|
* @param {string} imagePath — path to the image file (PNG, JPG, etc.)
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
|
||||||
|
* @param {string} [opts.background] — custom background color/gradient (overrides style preset)
|
||||||
|
* @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
|
||||||
|
* @param {string|false} [opts.speech] — TTS narration text while image is shown.
|
||||||
|
* Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
|
||||||
|
*/
|
||||||
|
export async function showImage(imagePath, opts = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
const style = opts.style || 'blur';
|
||||||
|
const speech = opts.speech;
|
||||||
|
|
||||||
|
// Style presets
|
||||||
|
const presets = {
|
||||||
|
blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
|
||||||
|
dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
|
||||||
|
light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
|
||||||
|
full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
|
||||||
|
};
|
||||||
|
const preset = presets[style] || presets.blur;
|
||||||
|
|
||||||
|
const bg = opts.background || preset.bg;
|
||||||
|
const fit = preset.fit;
|
||||||
|
const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
|
||||||
|
const useBlur = opts.background ? false : preset.blur;
|
||||||
|
|
||||||
|
// Read image and base64-encode
|
||||||
|
const absPath = resolveProjectPath(imagePath);
|
||||||
|
if (!fsExistsSync(absPath)) {
|
||||||
|
throw new Error(`showImage: file not found: ${absPath}`);
|
||||||
|
}
|
||||||
|
const buf = readFileSync(absPath);
|
||||||
|
const ext = extname(absPath).toLowerCase().replace('.', '');
|
||||||
|
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
|
||||||
|
: ext === 'png' ? 'image/png'
|
||||||
|
: ext === 'gif' ? 'image/gif'
|
||||||
|
: ext === 'webp' ? 'image/webp'
|
||||||
|
: ext === 'svg' ? 'image/svg+xml'
|
||||||
|
: 'image/png';
|
||||||
|
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
||||||
|
|
||||||
|
// Collect caption for TTS narration if recording
|
||||||
|
let smartWaitMs = 0;
|
||||||
|
if (recorder && speech && speech !== false) {
|
||||||
|
const captionText = typeof speech === 'string' ? speech : '';
|
||||||
|
if (captionText) {
|
||||||
|
recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
|
||||||
|
smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Padding: full style uses 100%, others use 92% for breathing room
|
||||||
|
const isFull = style === 'full';
|
||||||
|
const maxSize = isFull ? '100%' : '92%';
|
||||||
|
|
||||||
|
await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
|
||||||
|
let div = document.getElementById('__web_test_image');
|
||||||
|
if (!div) {
|
||||||
|
div = document.createElement('div');
|
||||||
|
div.id = '__web_test_image';
|
||||||
|
document.body.appendChild(div);
|
||||||
|
}
|
||||||
|
// Remove other overlays to prevent flash between slides
|
||||||
|
const title = document.getElementById('__web_test_title');
|
||||||
|
if (title) title.remove();
|
||||||
|
|
||||||
|
div.style.cssText = [
|
||||||
|
'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
|
||||||
|
`background:${bg}`,
|
||||||
|
'display:flex', 'align-items:center', 'justify-content:center',
|
||||||
|
'z-index:999999', 'pointer-events:none', 'overflow:hidden'
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Blurred background layer: the same image stretched to cover, blurred and dimmed
|
||||||
|
if (useBlur) {
|
||||||
|
html += `<img src="${dataUrl}" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;filter:blur(30px) brightness(0.5);transform:scale(1.1);" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main image
|
||||||
|
const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
|
||||||
|
const sizeCss = isFull
|
||||||
|
? `width:100%;height:100%;object-fit:${fit};`
|
||||||
|
: `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
|
||||||
|
html += `<img src="${dataUrl}" style="position:relative;${sizeCss}${shadowCss}" />`;
|
||||||
|
|
||||||
|
div.innerHTML = html;
|
||||||
|
}, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
|
||||||
|
|
||||||
|
// Smart TTS wait (same pattern as showCaption)
|
||||||
|
if (smartWaitMs > 0) {
|
||||||
|
let remaining = smartWaitMs;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 1000);
|
||||||
|
await page.waitForTimeout(chunk);
|
||||||
|
remaining -= chunk;
|
||||||
|
if (recorder?._flushFrames) recorder._flushFrames();
|
||||||
|
}
|
||||||
|
recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the image overlay. */
|
||||||
|
export async function hideImage() {
|
||||||
|
ensureConnected();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.getElementById('__web_test_image');
|
||||||
|
if (el) el.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// web-test recording/capture v1.17 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { mkdirSync, statSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import {
|
||||||
|
page, recorder, lastCaptions,
|
||||||
|
setRecorder, setLastCaptions, setLastRecordingDuration,
|
||||||
|
resolveProjectPath, ensureConnected,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
import { resolveFfmpeg } from './tts.mjs';
|
||||||
|
// Imported lazily inside wait() to avoid initialization-time circular deps.
|
||||||
|
|
||||||
|
/** Take a screenshot. Returns PNG buffer. */
|
||||||
|
export async function screenshot() {
|
||||||
|
ensureConnected();
|
||||||
|
return await page.screenshot({ type: 'png' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for a specified number of seconds. */
|
||||||
|
export async function wait(seconds) {
|
||||||
|
ensureConnected();
|
||||||
|
let ms = seconds * 1000;
|
||||||
|
// Credit system: if showCaption already waited for TTS, subtract that time
|
||||||
|
if (recorder && recorder.captionCredit) {
|
||||||
|
const elapsed = Date.now() - recorder.captionCredit.at;
|
||||||
|
const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
|
||||||
|
ms = Math.max(0, ms - credit);
|
||||||
|
recorder.captionCredit = null;
|
||||||
|
}
|
||||||
|
if (ms > 0) {
|
||||||
|
// During recording, split long waits into chunks and flush frames
|
||||||
|
// to keep video timeline in sync (CDP may not send frames for static pages)
|
||||||
|
if (recorder?._flushFrames && ms > 1000) {
|
||||||
|
let remaining = ms;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, 1000);
|
||||||
|
await page.waitForTimeout(chunk);
|
||||||
|
remaining -= chunk;
|
||||||
|
recorder._flushFrames();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await page.waitForTimeout(ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { getFormState } = await import('../forms/state.mjs');
|
||||||
|
return await getFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Video recording — CDP screencast + ffmpeg
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** Check if video recording is active. */
|
||||||
|
export function isRecording() {
|
||||||
|
return recorder !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start video recording via CDP screencast + ffmpeg.
|
||||||
|
* Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
|
||||||
|
* @param {string} outputPath — output .mp4 file path
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.fps=25] — target framerate
|
||||||
|
* @param {number} [opts.quality=80] — JPEG quality (1-100)
|
||||||
|
* @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
|
||||||
|
*/
|
||||||
|
export async function startRecording(outputPath, opts = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
if (recorder) {
|
||||||
|
if (opts.force) {
|
||||||
|
try { await stopRecording(); } catch {}
|
||||||
|
} else {
|
||||||
|
throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLastCaptions([]);
|
||||||
|
setLastRecordingDuration(null);
|
||||||
|
|
||||||
|
const fps = opts.fps || 25;
|
||||||
|
const quality = opts.quality || 80;
|
||||||
|
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
const resolvedPath = resolveProjectPath(outputPath);
|
||||||
|
mkdirSync(dirname(resolvedPath), { recursive: true });
|
||||||
|
|
||||||
|
// Spawn ffmpeg process — single output file across context switches
|
||||||
|
const ffmpeg = spawn(ffmpegPath, [
|
||||||
|
'-y', // overwrite output
|
||||||
|
'-f', 'image2pipe', // input: piped images
|
||||||
|
'-framerate', String(fps), // input framerate
|
||||||
|
'-i', '-', // read from stdin
|
||||||
|
'-c:v', 'libx264', // H.264 codec
|
||||||
|
'-preset', 'fast', // good quality/speed balance
|
||||||
|
'-crf', '23', // default quality (good for screen content)
|
||||||
|
'-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
|
||||||
|
'-pix_fmt', 'yuv420p', // broad compatibility
|
||||||
|
'-color_range', 'tv', // limited range (16-235) — standard for H.264 players
|
||||||
|
'-movflags', '+faststart', // web-friendly MP4
|
||||||
|
resolvedPath
|
||||||
|
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||||
|
|
||||||
|
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
|
||||||
|
|
||||||
|
const frameDuration = 1000 / fps;
|
||||||
|
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
||||||
|
|
||||||
|
// Frame handler shared across CDP sessions (lives in recorder, not closure):
|
||||||
|
// when the active context switches, we attach a new CDP session and route its
|
||||||
|
// frames to the same ffmpeg pipe — preserving a single continuous timeline.
|
||||||
|
const frameHandler = async ({ data, sessionId }, cdp) => {
|
||||||
|
if (!recorder) return;
|
||||||
|
const buf = Buffer.from(data, 'base64');
|
||||||
|
const now = Date.now();
|
||||||
|
if (!ffmpeg.stdin.destroyed) {
|
||||||
|
let framesWritten = 0;
|
||||||
|
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
|
||||||
|
const gap = now - recorder.lastFrameTime;
|
||||||
|
const dupes = Math.round(gap / frameDuration) - 1;
|
||||||
|
for (let i = 0; i < dupes && i < fps * 30; i++) {
|
||||||
|
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||||
|
framesWritten++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ffmpeg.stdin.write(buf);
|
||||||
|
framesWritten++;
|
||||||
|
recorder.videoTimeMs += framesWritten * frameDuration;
|
||||||
|
}
|
||||||
|
recorder.lastFrameTime = now;
|
||||||
|
recorder.lastFrameBuf = buf;
|
||||||
|
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
|
||||||
|
const _flushFrames = () => {
|
||||||
|
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const gap = now - recorder.lastFrameTime;
|
||||||
|
const dupes = Math.round(gap / frameDuration);
|
||||||
|
for (let i = 0; i < dupes; i++) {
|
||||||
|
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||||
|
recorder.videoTimeMs += frameDuration;
|
||||||
|
}
|
||||||
|
if (dupes > 0) recorder.lastFrameTime = now;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach screencast to a specific page. Stops the old CDP first (if any).
|
||||||
|
// Called by startRecording for the initial page, and by setActiveContext when
|
||||||
|
// the active context changes mid-recording.
|
||||||
|
const _attachPage = async (targetPage) => {
|
||||||
|
if (recorder.cdp) {
|
||||||
|
_flushFrames(); // freeze the last frame of the outgoing page up to "now"
|
||||||
|
try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
|
||||||
|
try { await recorder.cdp.detach(); } catch {}
|
||||||
|
recorder.cdp = null;
|
||||||
|
}
|
||||||
|
const cdp = await targetPage.context().newCDPSession(targetPage);
|
||||||
|
cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
|
||||||
|
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
|
||||||
|
recorder.cdp = cdp;
|
||||||
|
recorder.activePage = targetPage;
|
||||||
|
};
|
||||||
|
|
||||||
|
setRecorder({
|
||||||
|
cdp: null,
|
||||||
|
activePage: null,
|
||||||
|
ffmpeg,
|
||||||
|
startTime: Date.now(),
|
||||||
|
outputPath: resolvedPath,
|
||||||
|
ffmpegError: '',
|
||||||
|
captions: [],
|
||||||
|
videoTimeMs: 0,
|
||||||
|
frameDuration,
|
||||||
|
lastFrameTime: null,
|
||||||
|
lastFrameBuf: null,
|
||||||
|
_flushFrames,
|
||||||
|
_attachPage,
|
||||||
|
speechRate,
|
||||||
|
});
|
||||||
|
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
|
||||||
|
|
||||||
|
await _attachPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop video recording. Finalizes the MP4 file.
|
||||||
|
* @returns {{ file: string, duration: number, size: number }}
|
||||||
|
*/
|
||||||
|
export async function stopRecording() {
|
||||||
|
if (!recorder) return { file: null, duration: 0, size: 0 };
|
||||||
|
|
||||||
|
const { cdp, ffmpeg, startTime, outputPath } = recorder;
|
||||||
|
|
||||||
|
// Final frame flush: write remaining frames to cover the gap since the last screencast frame
|
||||||
|
if (recorder._flushFrames) recorder._flushFrames();
|
||||||
|
|
||||||
|
// Stop CDP screencast
|
||||||
|
try { await cdp.send('Page.stopScreencast'); } catch {}
|
||||||
|
try { await cdp.detach(); } catch {}
|
||||||
|
|
||||||
|
// Close ffmpeg stdin and wait for encoding to finish
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ffmpeg.kill('SIGKILL');
|
||||||
|
reject(new Error('ffmpeg timed out after 30s'));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
|
||||||
|
});
|
||||||
|
ffmpeg.on('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.stdin.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = (Date.now() - startTime) / 1000;
|
||||||
|
const stats = statSync(outputPath);
|
||||||
|
|
||||||
|
// Preserve captions for addNarration()
|
||||||
|
setLastCaptions(recorder.captions || []);
|
||||||
|
setLastRecordingDuration(duration);
|
||||||
|
if (lastCaptions.length) {
|
||||||
|
const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
|
||||||
|
const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
|
||||||
|
writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecorder(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: outputPath,
|
||||||
|
duration: Math.round(duration * 10) / 10,
|
||||||
|
size: stats.size,
|
||||||
|
captions: lastCaptions.length
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// web-test recording/highlight v1.17 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue).
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import {
|
||||||
|
page, highlightMode, ensureConnected, normYo,
|
||||||
|
setHighlightMode,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
readSubmenuScript, detectFormScript, resolveGridScript,
|
||||||
|
findClickTargetScript, resolveFieldsScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight an element on the page (visual accent for video recordings).
|
||||||
|
* Uses overlay div for visibility (not clipped by overflow:hidden), with
|
||||||
|
* requestAnimationFrame tracking so it follows layout shifts (async banners etc).
|
||||||
|
* @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.color] Outline color (default: '#e74c3c')
|
||||||
|
* @param {number} [opts.padding] Extra padding around element (default: 4)
|
||||||
|
*/
|
||||||
|
export async function highlight(text, opts = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
const { color = '#e74c3c', padding = 4, table } = opts;
|
||||||
|
|
||||||
|
// Remove previous highlight first
|
||||||
|
await unhighlight();
|
||||||
|
|
||||||
|
let elId = null;
|
||||||
|
|
||||||
|
// 0. Open submenu/popup — highest priority (submenu overlays the form,
|
||||||
|
// so form search would match grid rows behind the popup)
|
||||||
|
const popupItems = await page.evaluate(readSubmenuScript());
|
||||||
|
if (Array.isArray(popupItems) && popupItems.length > 0) {
|
||||||
|
const target = normYo(text.toLowerCase());
|
||||||
|
let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
|
||||||
|
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
|
||||||
|
if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
|
||||||
|
if (found) {
|
||||||
|
// 1C duplicates IDs in clouds — getElementById returns the hidden copy.
|
||||||
|
// Use elementFromPoint to find the visible element and get its actual rect.
|
||||||
|
await page.evaluate(({ x, y, color, padding }) => {
|
||||||
|
const el = document.elementFromPoint(x, y);
|
||||||
|
if (!el) return;
|
||||||
|
const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
|
||||||
|
const r = block.getBoundingClientRect();
|
||||||
|
let div = document.getElementById('__web_test_highlight');
|
||||||
|
if (!div) {
|
||||||
|
div = document.createElement('div');
|
||||||
|
div.id = '__web_test_highlight';
|
||||||
|
document.body.appendChild(div);
|
||||||
|
}
|
||||||
|
div.style.cssText = [
|
||||||
|
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||||
|
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||||
|
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||||
|
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||||
|
`box-shadow:0 0 16px ${color}80`,
|
||||||
|
].join(';');
|
||||||
|
}, { x: found.x, y: found.y, color, padding });
|
||||||
|
return; // overlay placed, done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Visible commands on the function panel (cmd_XXX_txt elements)
|
||||||
|
// Must be checked BEFORE form search: when the section content panel
|
||||||
|
// is showing, the form behind it is hidden but detectFormScript still
|
||||||
|
// finds it, and form buttons match before commands.
|
||||||
|
if (!elId) {
|
||||||
|
elId = await page.evaluate(`(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||||
|
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
|
||||||
|
if (cmds.length === 0) return null;
|
||||||
|
let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
|
||||||
|
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||||
|
if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||||
|
return el ? el.id : null;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Command group headers on the function panel (eAccentColor labels).
|
||||||
|
// Match header text, then highlight the header + commands below it
|
||||||
|
// until the next spacer/header/end.
|
||||||
|
if (!elId) {
|
||||||
|
const groupDone = await page.evaluate(({ target, color, padding }) => {
|
||||||
|
const container = document.querySelector('#funcPanel_container');
|
||||||
|
if (!container) return false;
|
||||||
|
const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
|
||||||
|
const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
|
||||||
|
if (!headers.length) return false;
|
||||||
|
|
||||||
|
let headerEl = headers.find(h => norm(h.textContent) === target);
|
||||||
|
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
|
||||||
|
if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
|
||||||
|
if (!headerEl) return false;
|
||||||
|
|
||||||
|
// Collect header + following cmd siblings until next spacer/header
|
||||||
|
const parent = headerEl.parentElement;
|
||||||
|
const children = [...parent.children];
|
||||||
|
const startIdx = children.indexOf(headerEl);
|
||||||
|
const groupEls = [headerEl];
|
||||||
|
for (let i = startIdx + 1; i < children.length; i++) {
|
||||||
|
const el = children[i];
|
||||||
|
if (el.classList.contains('eAccentColor')) break;
|
||||||
|
if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
|
||||||
|
groupEls.push(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounding box
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const el of groupEls) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width === 0 && r.height === 0) continue;
|
||||||
|
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
|
||||||
|
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
|
||||||
|
}
|
||||||
|
if (minX === Infinity) return false;
|
||||||
|
|
||||||
|
let div = document.getElementById('__web_test_highlight');
|
||||||
|
if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
|
||||||
|
div.style.cssText = [
|
||||||
|
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||||
|
`top:${minY - padding}px`, `left:${minX - padding}px`,
|
||||||
|
`width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
|
||||||
|
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||||
|
`box-shadow:0 0 16px ${color}80`,
|
||||||
|
].join(';');
|
||||||
|
return true;
|
||||||
|
}, { target: normYo(text.toLowerCase()), color, padding });
|
||||||
|
if (groupDone) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Form groups/panels — checked BEFORE buttons/fields because group names
|
||||||
|
// often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
|
||||||
|
// panel and a command bar element). Includes _container and _div elements
|
||||||
|
// but skips logicGroupContainer (Representation=None, height=0).
|
||||||
|
if (!elId) {
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum !== null) {
|
||||||
|
elId = await page.evaluate(`(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||||
|
const p = 'form' + ${formNum} + '_';
|
||||||
|
// Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
|
||||||
|
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||||
|
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
|
||||||
|
const items = groups.map(el => {
|
||||||
|
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||||
|
const titleEl = document.getElementById(p + idName + '#title_text')
|
||||||
|
|| document.getElementById(p + idName + '_title_text');
|
||||||
|
const label = norm(titleEl?.innerText || '').toLowerCase();
|
||||||
|
const name = norm(idName).toLowerCase();
|
||||||
|
const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
|
||||||
|
return { id: el.id, name, label, big };
|
||||||
|
});
|
||||||
|
let found = items.find(i => i.label === target);
|
||||||
|
if (!found) found = items.find(i => i.name === target);
|
||||||
|
// Fuzzy match: only large groups (min 100x50) to avoid matching command bars
|
||||||
|
if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
|
||||||
|
if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
|
||||||
|
return found ? found.id : null;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Form-scoped search (buttons, links, fields, grid rows)
|
||||||
|
if (!elId) {
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum !== null) {
|
||||||
|
// 3a. Try button/link/tab/gridRow via findClickTargetScript
|
||||||
|
let gridSelector;
|
||||||
|
if (table) {
|
||||||
|
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||||
|
if (!resolved.error) gridSelector = resolved.gridSelector;
|
||||||
|
}
|
||||||
|
const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
|
||||||
|
if (target && !target.error) {
|
||||||
|
if (target.id) {
|
||||||
|
elId = target.id;
|
||||||
|
} else if (target.x && target.y) {
|
||||||
|
// Grid row — find the gridLine element and tag it
|
||||||
|
elId = await page.evaluate(`(() => {
|
||||||
|
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||||
|
const grid = document.querySelector('[id^="' + p + '"].grid');
|
||||||
|
if (!grid) return null;
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
if (!body) return null;
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||||
|
for (const line of body.querySelectorAll('.gridLine')) {
|
||||||
|
const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
|
||||||
|
const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
|
||||||
|
if (rowText.includes(target)) {
|
||||||
|
if (!line.id) line.id = '__wt_hl_tmp';
|
||||||
|
return line.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3b. If not found as button — try as field via resolveFieldsScript
|
||||||
|
if (!elId) {
|
||||||
|
const dummyFields = { [text]: '' };
|
||||||
|
const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
|
||||||
|
if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
|
||||||
|
elId = resolved[0].inputId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: sections (sidebar navigation)
|
||||||
|
if (!elId) {
|
||||||
|
elId = await page.evaluate(`(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const target = ${JSON.stringify(normYo(text.toLowerCase()))};
|
||||||
|
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
|
||||||
|
let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
|
||||||
|
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
|
||||||
|
if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
|
||||||
|
return el ? el.id : null;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!elId) {
|
||||||
|
// Collect available elements to help the caller fix the name
|
||||||
|
const available = await page.evaluate(`(() => {
|
||||||
|
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
|
||||||
|
const result = {};
|
||||||
|
// Commands
|
||||||
|
const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
|
||||||
|
if (cmds.length) result.commands = cmds;
|
||||||
|
// Command group headers
|
||||||
|
const fp = document.querySelector('#funcPanel_container');
|
||||||
|
if (fp) {
|
||||||
|
const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
|
||||||
|
if (gh.length) result.commandGroups = gh;
|
||||||
|
}
|
||||||
|
// Sections
|
||||||
|
const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
|
||||||
|
if (secs.length) result.sections = secs;
|
||||||
|
// Form elements
|
||||||
|
${(() => {
|
||||||
|
// Detect form inline to avoid extra evaluate round-trip
|
||||||
|
return `
|
||||||
|
const forms = {};
|
||||||
|
document.querySelectorAll('[id^="form"]').forEach(el => {
|
||||||
|
const m = el.id.match(/^form(\\d+)_/);
|
||||||
|
if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
|
||||||
|
});
|
||||||
|
let formNum = null, maxCount = 0;
|
||||||
|
for (const [n, c] of Object.entries(forms)) {
|
||||||
|
if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
|
||||||
|
}
|
||||||
|
if (formNum !== null) {
|
||||||
|
const p = 'form' + formNum + '_';
|
||||||
|
// Groups (_container or _div, skip logicGroupContainer, min 100x50)
|
||||||
|
const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
|
||||||
|
.filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
|
||||||
|
.map(el => {
|
||||||
|
const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
|
||||||
|
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
|
||||||
|
return norm(titleEl?.innerText || '') || idName;
|
||||||
|
}).filter(Boolean);
|
||||||
|
if (groups.length) result.groups = groups;
|
||||||
|
// Buttons/links
|
||||||
|
const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
|
||||||
|
.filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
|
||||||
|
if (btns.length) result.buttons = [...new Set(btns)];
|
||||||
|
}`;
|
||||||
|
})()}
|
||||||
|
return result;
|
||||||
|
})()`);
|
||||||
|
const parts = [];
|
||||||
|
for (const [cat, items] of Object.entries(available)) {
|
||||||
|
parts.push(` ${cat}: ${items.join(', ')}`);
|
||||||
|
}
|
||||||
|
const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
|
||||||
|
throw new Error(`highlight: "${text}" not found${hint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
|
||||||
|
await page.evaluate(({ elId, color, padding }) => {
|
||||||
|
const target = document.getElementById(elId);
|
||||||
|
if (!target) return;
|
||||||
|
let div = document.getElementById('__web_test_highlight');
|
||||||
|
if (!div) {
|
||||||
|
div = document.createElement('div');
|
||||||
|
div.id = '__web_test_highlight';
|
||||||
|
document.body.appendChild(div);
|
||||||
|
}
|
||||||
|
function sync() {
|
||||||
|
const r = target.getBoundingClientRect();
|
||||||
|
div.style.cssText = [
|
||||||
|
'position:fixed', 'pointer-events:none', 'z-index:999998',
|
||||||
|
`top:${r.y - padding}px`, `left:${r.x - padding}px`,
|
||||||
|
`width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
|
||||||
|
`outline:3px solid ${color}`, 'border-radius:4px',
|
||||||
|
`box-shadow:0 0 16px ${color}80`,
|
||||||
|
].join(';');
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
// Track position changes via rAF
|
||||||
|
function tick() {
|
||||||
|
if (!document.getElementById('__web_test_highlight')) return; // stopped
|
||||||
|
sync();
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}, { elId, color, padding });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the highlight overlay. */
|
||||||
|
export async function unhighlight() {
|
||||||
|
ensureConnected();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.getElementById('__web_test_highlight');
|
||||||
|
if (el) el.remove(); // also stops rAF loop (id check)
|
||||||
|
// Clean up temp ID from grid rows
|
||||||
|
const tmp = document.getElementById('__wt_hl_tmp');
|
||||||
|
if (tmp) tmp.removeAttribute('id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
|
||||||
|
* automatically highlight the target element before acting.
|
||||||
|
* @param {boolean} on true to enable, false to disable
|
||||||
|
*/
|
||||||
|
export function setHighlight(on) {
|
||||||
|
setHighlightMode(!!on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} Whether auto-highlight mode is active. */
|
||||||
|
export function isHighlightMode() {
|
||||||
|
return highlightMode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
|
||||||
|
import { extname, join as pathJoin } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import {
|
||||||
|
lastCaptions, lastRecordingDuration, resolveProjectPath,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence,
|
||||||
|
} from './tts.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add TTS narration to a recorded video.
|
||||||
|
* Generates speech from captions and merges audio with the video.
|
||||||
|
* @param {string} videoPath — path to the recorded MP4 file
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
|
||||||
|
* @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai'
|
||||||
|
* @param {string} [opts.voice] — voice name (provider-specific)
|
||||||
|
* @param {string} [opts.apiKey] — API key (for openai provider)
|
||||||
|
* @param {string} [opts.apiUrl] — API endpoint (for openai provider)
|
||||||
|
* @param {string} [opts.model] — model name (for openai provider, default: 'tts-1')
|
||||||
|
* @param {string} [opts.ffmpegPath] — path to ffmpeg binary
|
||||||
|
* @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4)
|
||||||
|
* @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
|
||||||
|
*/
|
||||||
|
export async function addNarration(videoPath, opts = {}) {
|
||||||
|
if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
|
||||||
|
videoPath = resolveProjectPath(videoPath);
|
||||||
|
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||||
|
const ttsProvider = getTtsProvider(opts.provider || 'edge');
|
||||||
|
const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };
|
||||||
|
|
||||||
|
// Resolve captions: explicit > lastCaptions > .captions.json
|
||||||
|
let captions = opts.captions;
|
||||||
|
let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
|
||||||
|
let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
|
||||||
|
if (!captions || !captions.length) {
|
||||||
|
if (lastCaptions.length) {
|
||||||
|
captions = [...lastCaptions];
|
||||||
|
recordingDuration = lastRecordingDuration;
|
||||||
|
// Runtime captions always use video timestamps (set in showCaption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!captions || !captions.length) {
|
||||||
|
const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
|
||||||
|
if (fsExistsSync(captionsJsonPath)) {
|
||||||
|
const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
|
||||||
|
// Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
captions = raw;
|
||||||
|
videoTimestamps = false;
|
||||||
|
} else {
|
||||||
|
captions = raw.captions;
|
||||||
|
videoTimestamps = !!raw.videoTimestamps;
|
||||||
|
recordingDuration = raw.recordingDuration || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!captions || !captions.length) {
|
||||||
|
throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoDuration = getAudioDuration(videoPath, ffmpegPath);
|
||||||
|
|
||||||
|
// Legacy fallback: scale wall-clock timestamps to video duration
|
||||||
|
// (only for old captions without videoTimestamps flag)
|
||||||
|
if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
|
||||||
|
const timeScale = videoDuration / recordingDuration;
|
||||||
|
if (Math.abs(timeScale - 1) > 0.005) {
|
||||||
|
captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output path
|
||||||
|
const ext = extname(videoPath);
|
||||||
|
const base = videoPath.slice(0, -ext.length);
|
||||||
|
const outputPath = opts.outputPath || `${base}-narrated${ext}`;
|
||||||
|
|
||||||
|
// Temp directory
|
||||||
|
const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Generate TTS audio for each caption
|
||||||
|
const ttsFiles = [];
|
||||||
|
const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
|
||||||
|
for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
|
||||||
|
const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
|
||||||
|
const promises = batch.map(async (cap, batchIdx) => {
|
||||||
|
const idx = batchStart + batchIdx;
|
||||||
|
const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
|
||||||
|
const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
|
||||||
|
try {
|
||||||
|
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||||
|
} catch (err) {
|
||||||
|
// Retry once
|
||||||
|
try {
|
||||||
|
await ttsProvider(cap.speech, ttsFile, capTtsOpts);
|
||||||
|
} catch (retryErr) {
|
||||||
|
warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
|
||||||
|
// Generate 1s silence as placeholder
|
||||||
|
generateSilence(ttsFile, 1, ffmpegPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ttsFile;
|
||||||
|
});
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
ttsFiles.push(...results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
|
||||||
|
// This avoids MP3 frame quantization drift from silence-file concatenation
|
||||||
|
const ffmpegInputs = [];
|
||||||
|
const filterParts = [];
|
||||||
|
const mixLabels = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < captions.length; i++) {
|
||||||
|
const captionTimeMs = Math.round(captions[i].time);
|
||||||
|
const ttsFile = ttsFiles[i];
|
||||||
|
const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);
|
||||||
|
|
||||||
|
ffmpegInputs.push('-i', ttsFile);
|
||||||
|
const filters = [];
|
||||||
|
|
||||||
|
// Speed up TTS slightly if it's longer than gap to next caption (max 1.3x)
|
||||||
|
if (i < captions.length - 1) {
|
||||||
|
const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
|
||||||
|
if (ttsDuration > maxDuration && maxDuration > 0.1) {
|
||||||
|
const tempo = ttsDuration / maxDuration;
|
||||||
|
if (tempo <= 1.3) {
|
||||||
|
filters.push(`atempo=${tempo.toFixed(4)}`);
|
||||||
|
} else {
|
||||||
|
// Too fast — let audio overlap instead of distorting
|
||||||
|
warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay to exact caption timestamp (milliseconds)
|
||||||
|
if (captionTimeMs > 0) {
|
||||||
|
filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = `a${i}`;
|
||||||
|
mixLabels.push(`[${label}]`);
|
||||||
|
// Input indices are shifted by 1 because silence reference is input [0]
|
||||||
|
filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a silence reference track as input [0] so amix runs for full video duration
|
||||||
|
const silencePath = pathJoin(tempDir, 'silence.mp3');
|
||||||
|
generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
|
||||||
|
|
||||||
|
const filterComplex = filterParts.join(';') + ';' +
|
||||||
|
`[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
|
||||||
|
|
||||||
|
const narrationPath = pathJoin(tempDir, 'narration.mp3');
|
||||||
|
execFileSync(ffmpegPath, [
|
||||||
|
'-y', '-i', silencePath, ...ffmpegInputs,
|
||||||
|
'-filter_complex', filterComplex,
|
||||||
|
'-t', String(Math.ceil(videoDuration)),
|
||||||
|
'-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
|
||||||
|
], { stdio: 'pipe', timeout: 120000 });
|
||||||
|
|
||||||
|
// Phase 4: Merge video + narration audio
|
||||||
|
execFileSync(ffmpegPath, [
|
||||||
|
'-y', '-i', videoPath, '-i', narrationPath,
|
||||||
|
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
|
||||||
|
'-map', '0:v:0', '-map', '1:a:0',
|
||||||
|
'-t', String(Math.ceil(videoDuration)),
|
||||||
|
'-movflags', '+faststart', outputPath,
|
||||||
|
], { stdio: 'pipe', timeout: 120000 });
|
||||||
|
|
||||||
|
const stats = statSync(outputPath);
|
||||||
|
const duration = getAudioDuration(outputPath, ffmpegPath);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
file: outputPath,
|
||||||
|
duration: Math.round(duration * 10) / 10,
|
||||||
|
size: stats.size,
|
||||||
|
captions: captions.length,
|
||||||
|
};
|
||||||
|
if (warnings.length) result.warnings = warnings;
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// Cleanup temp directory
|
||||||
|
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// web-test recording/tts v1.17 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { execFileSync, spawn } from 'child_process';
|
||||||
|
import { existsSync as fsExistsSync, writeFileSync } from 'fs';
|
||||||
|
import { resolve as pathResolve } from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
|
import { projectRoot } from '../core/state.mjs';
|
||||||
|
|
||||||
|
/** Resolve ffmpeg binary path. */
|
||||||
|
export function resolveFfmpeg(explicit) {
|
||||||
|
// 1. Explicit path
|
||||||
|
if (explicit) {
|
||||||
|
try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
|
||||||
|
catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. FFMPEG_PATH env var
|
||||||
|
const envPath = process.env.FFMPEG_PATH;
|
||||||
|
if (envPath) {
|
||||||
|
try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
|
||||||
|
catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. System PATH
|
||||||
|
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
|
||||||
|
catch { /* fall through */ }
|
||||||
|
|
||||||
|
// 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
|
||||||
|
const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
|
||||||
|
if (fsExistsSync(localPath)) {
|
||||||
|
try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
|
||||||
|
catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Error with instructions
|
||||||
|
throw new Error(
|
||||||
|
'ffmpeg not found. Install it:\n' +
|
||||||
|
' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
|
||||||
|
' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
|
||||||
|
' - Or pass ffmpegPath option to startRecording()'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TTS providers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
|
||||||
|
let _edgeTtsModule = null;
|
||||||
|
export async function resolveEdgeTts() {
|
||||||
|
if (_edgeTtsModule) return _edgeTtsModule;
|
||||||
|
|
||||||
|
// 1. Global/project-level install (standard Node resolution)
|
||||||
|
try {
|
||||||
|
_edgeTtsModule = await import('node-edge-tts');
|
||||||
|
return _edgeTtsModule;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
|
// 2. tools/tts/ relative to project root
|
||||||
|
const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
|
||||||
|
if (fsExistsSync(localPath)) {
|
||||||
|
try {
|
||||||
|
_edgeTtsModule = await import(pathToFileURL(localPath).href);
|
||||||
|
return _edgeTtsModule;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Error with instructions
|
||||||
|
throw new Error(
|
||||||
|
'node-edge-tts not found. Install it:\n' +
|
||||||
|
' - npm install --prefix tools/tts node-edge-tts\n' +
|
||||||
|
' - or: npm install node-edge-tts (global/project-level)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge TTS provider (free, no API key). Uses node-edge-tts package.
|
||||||
|
* @param {string} text — text to synthesize
|
||||||
|
* @param {string} outputPath — path for the output mp3 file
|
||||||
|
* @param {object} opts — { voice }
|
||||||
|
*/
|
||||||
|
export async function edgeTtsProvider(text, outputPath, opts = {}) {
|
||||||
|
const { EdgeTTS } = await resolveEdgeTts();
|
||||||
|
const voice = opts.voice || 'ru-RU-DmitryNeural';
|
||||||
|
const tts = new EdgeTTS({ voice });
|
||||||
|
await Promise.race([
|
||||||
|
tts.ttsPromise(text, outputPath),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI-compatible TTS provider. Requires apiKey.
|
||||||
|
* @param {string} text — text to synthesize
|
||||||
|
* @param {string} outputPath — path for the output mp3 file
|
||||||
|
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||||
|
*/
|
||||||
|
export async function openaiTtsProvider(text, outputPath, opts = {}) {
|
||||||
|
const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
|
||||||
|
if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
|
||||||
|
const resp = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: opts.model || 'tts-1',
|
||||||
|
input: text,
|
||||||
|
voice: opts.voice || 'alloy',
|
||||||
|
response_format: 'mp3',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
writeFileSync(outputPath, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ElevenLabs TTS provider. Requires apiKey.
|
||||||
|
* @param {string} text — text to synthesize
|
||||||
|
* @param {string} outputPath — path for the output mp3 file
|
||||||
|
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||||
|
*/
|
||||||
|
export async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
|
||||||
|
const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
|
||||||
|
const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
|
||||||
|
if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
|
||||||
|
const resp = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
model_id: opts.model || 'eleven_multilingual_v2',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
writeFileSync(outputPath, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get TTS provider function by name. */
|
||||||
|
export function getTtsProvider(name) {
|
||||||
|
switch (name) {
|
||||||
|
case 'openai': return openaiTtsProvider;
|
||||||
|
case 'elevenlabs': return elevenlabsTtsProvider;
|
||||||
|
case 'edge': default: return edgeTtsProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TTS audio helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio duration in seconds using ffprobe.
|
||||||
|
* @param {string} filePath — path to audio file
|
||||||
|
* @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it)
|
||||||
|
* @returns {number} duration in seconds
|
||||||
|
*/
|
||||||
|
export function getAudioDuration(filePath, ffmpegPath) {
|
||||||
|
const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
|
||||||
|
const out = execFileSync(ffprobePath, [
|
||||||
|
'-v', 'error', '-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
|
||||||
|
], { encoding: 'utf8', timeout: 10000 }).trim();
|
||||||
|
return parseFloat(out) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a silence mp3 file of given duration.
|
||||||
|
* @param {string} outputPath — path for the output mp3 file
|
||||||
|
* @param {number} seconds — duration in seconds
|
||||||
|
* @param {string} ffmpegPath — path to ffmpeg binary
|
||||||
|
*/
|
||||||
|
export function generateSilence(outputPath, seconds, ffmpegPath) {
|
||||||
|
execFileSync(ffmpegPath, [
|
||||||
|
'-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
|
||||||
|
'-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
|
||||||
|
], { stdio: 'pipe', timeout: 10000 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
// web-test spreadsheet v1.20 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы).
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page, ensureConnected } from '../core/state.mjs';
|
||||||
|
import { detectFormScript } from '../../dom.mjs';
|
||||||
|
import { waitForStable } from '../core/wait.mjs';
|
||||||
|
import { getFormState } from '../forms/state.mjs';
|
||||||
|
import { returnFormState } from '../core/helpers.mjs';
|
||||||
|
import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs';
|
||||||
|
import { checkForErrors } from '../core/errors.mjs';
|
||||||
|
|
||||||
|
// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan spreadsheet iframes for the current form and collect all cells.
|
||||||
|
* Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> }
|
||||||
|
* where frameIndex is the Playwright frames[] index (1-based, 0 = main).
|
||||||
|
*/
|
||||||
|
async function scanSpreadsheetCells(formNum) {
|
||||||
|
const prefix = `form${formNum ?? 0}_`;
|
||||||
|
const iframeHandles = await page.$$('iframe');
|
||||||
|
|
||||||
|
const allCells = new Map();
|
||||||
|
const frameMap = new Map(); // key 'r_c' → Playwright Frame object
|
||||||
|
|
||||||
|
for (const handle of iframeHandles) {
|
||||||
|
const ok = await handle.evaluate((f, pfx) => {
|
||||||
|
if (f.offsetWidth < 100) return false;
|
||||||
|
let el = f.parentElement;
|
||||||
|
for (let d = 0; el && d < 30; d++, el = el.parentElement) {
|
||||||
|
if (el.id && el.id.startsWith(pfx)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, prefix);
|
||||||
|
if (!ok) continue;
|
||||||
|
|
||||||
|
const frame = await handle.contentFrame();
|
||||||
|
if (!frame) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cells = await frame.evaluate(`(() => {
|
||||||
|
const cells = [];
|
||||||
|
document.querySelectorAll('div[x]').forEach(d => {
|
||||||
|
const span = d.querySelector('span');
|
||||||
|
const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || '';
|
||||||
|
if (!text) return;
|
||||||
|
const rowDiv = d.parentElement;
|
||||||
|
const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null;
|
||||||
|
const col = d.getAttribute('x');
|
||||||
|
if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text });
|
||||||
|
});
|
||||||
|
return cells;
|
||||||
|
})()`);
|
||||||
|
for (const cell of cells) {
|
||||||
|
const key = `${cell.r}_${cell.c}`;
|
||||||
|
if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) {
|
||||||
|
allCells.set(key, cell);
|
||||||
|
frameMap.set(key, frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* skip inaccessible frames */ }
|
||||||
|
}
|
||||||
|
return { allCells, frameMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build structured mapping from raw cells: headers, column map, data/totals row indices.
|
||||||
|
* Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap }
|
||||||
|
* or null if header detection fails.
|
||||||
|
*/
|
||||||
|
function buildSpreadsheetMapping(allCells) {
|
||||||
|
const rowMap = new Map();
|
||||||
|
let maxCol = 0;
|
||||||
|
for (const cell of allCells.values()) {
|
||||||
|
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
|
||||||
|
rowMap.get(cell.r).set(cell.c, cell.t);
|
||||||
|
if (cell.c > maxCol) maxCol = cell.c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
|
||||||
|
const rows = sortedRows.map(r => {
|
||||||
|
const cm = rowMap.get(r);
|
||||||
|
const arr = [];
|
||||||
|
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
|
||||||
|
return arr;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78"
|
||||||
|
// Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification.
|
||||||
|
const isNumericVal = (c) => {
|
||||||
|
if (!c || !/\d/.test(c)) return false;
|
||||||
|
const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, '');
|
||||||
|
return /^\d[\d,]*$/.test(s);
|
||||||
|
};
|
||||||
|
// Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus).
|
||||||
|
// Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51".
|
||||||
|
const isDataNumericVal = (c) => {
|
||||||
|
if (!isNumericVal(c)) return false;
|
||||||
|
return /[\s\u00a0,]/.test(c) || /^-/.test(c);
|
||||||
|
};
|
||||||
|
const hasNumber = (row) => row.some(c => isNumericVal(c));
|
||||||
|
const nonEmpty = (row) => row.filter(c => c !== '').length;
|
||||||
|
|
||||||
|
// Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx.
|
||||||
|
// Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor).
|
||||||
|
const buildRichMapping = (detailIdx, firstDataIdx) => {
|
||||||
|
let groupIdx = -1;
|
||||||
|
if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1;
|
||||||
|
|
||||||
|
const detailRow = rows[detailIdx];
|
||||||
|
const groupRow = groupIdx >= 0 ? rows[groupIdx] : null;
|
||||||
|
|
||||||
|
// Detect optional third header level above group row (bounds carry-forward)
|
||||||
|
let superRow = null;
|
||||||
|
if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) {
|
||||||
|
superRow = rows[groupIdx - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build column names (group + detail merge)
|
||||||
|
const groupFilled = new Array(maxCol + 1).fill('');
|
||||||
|
if (groupRow) {
|
||||||
|
let cur = '';
|
||||||
|
for (let c = 0; c <= maxCol; c++) {
|
||||||
|
if (groupRow[c]) {
|
||||||
|
cur = groupRow[c];
|
||||||
|
} else if (superRow && superRow[c]) {
|
||||||
|
// New top-level header starts here — stop carry-forward
|
||||||
|
cur = '';
|
||||||
|
}
|
||||||
|
groupFilled[c] = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailCounts = {};
|
||||||
|
for (let c = 0; c <= maxCol; c++) {
|
||||||
|
const n = detailRow[c];
|
||||||
|
if (n) detailCounts[n] = (detailCounts[n] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect DCS column codes (К1, К2, ...) — always prefix with group when present
|
||||||
|
const detailNonEmpty = detailRow.filter(c => c);
|
||||||
|
const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c));
|
||||||
|
|
||||||
|
const colNames = [];
|
||||||
|
for (let c = 0; c <= maxCol; c++) {
|
||||||
|
const detail = detailRow[c];
|
||||||
|
const group = groupFilled[c];
|
||||||
|
const sup = superRow ? superRow[c] : '';
|
||||||
|
if (detail) {
|
||||||
|
// Prefer group prefix; fall back to superRow for DCS code columns without sub-group
|
||||||
|
const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : '');
|
||||||
|
const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === ''));
|
||||||
|
colNames.push(needPrefix ? `${prefix} / ${detail}` : detail);
|
||||||
|
} else if (group) {
|
||||||
|
colNames.push(group);
|
||||||
|
} else if (sup) {
|
||||||
|
colNames.push(sup);
|
||||||
|
} else {
|
||||||
|
colNames.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colMap = new Map();
|
||||||
|
for (let c = 0; c < colNames.length; c++) {
|
||||||
|
if (colNames[c]) colMap.set(colNames[c], c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify data rows: separate data indices and totals index
|
||||||
|
const dataRowIndices = [];
|
||||||
|
let totalsRowIdx = -1;
|
||||||
|
for (let i = firstDataIdx; i < rows.length; i++) {
|
||||||
|
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||||||
|
const first = rows[i][0]?.trim().toLowerCase();
|
||||||
|
if (first === 'итого' || first === 'всего') {
|
||||||
|
totalsRowIdx = i;
|
||||||
|
} else {
|
||||||
|
dataRowIndices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const superRowIdx = superRow ? groupIdx - 1 : -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows, sortedRows, maxCol, colNames, colMap,
|
||||||
|
headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx,
|
||||||
|
dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx,
|
||||||
|
rowMap, hasNumber, nonEmpty,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Level 1: DCS-code row anchor ---
|
||||||
|
// ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker.
|
||||||
|
// Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб").
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const detailNonEmpty = rows[i].filter(c => c);
|
||||||
|
if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) {
|
||||||
|
// Find first non-empty row after the К-codes row as data start
|
||||||
|
let firstDataIdx = rows.length;
|
||||||
|
for (let j = i + 1; j < rows.length; j++) {
|
||||||
|
if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; }
|
||||||
|
}
|
||||||
|
return buildRichMapping(i, firstDataIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) ---
|
||||||
|
let firstDataIdx = rows.length;
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (firstDataIdx === rows.length) {
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstDataIdx < rows.length) {
|
||||||
|
let detailIdx = -1;
|
||||||
|
for (let i = firstDataIdx - 1; i >= 0; i--) {
|
||||||
|
if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Level 3: single-row header fallback (text-only data, query console) ---
|
||||||
|
// First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition.
|
||||||
|
let headerIdx = -1;
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; }
|
||||||
|
}
|
||||||
|
// Single-column tables: accept nonEmpty >= 1
|
||||||
|
if (headerIdx === -1 && maxCol === 0) {
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total }
|
||||||
|
|
||||||
|
const detailRow = rows[headerIdx];
|
||||||
|
const colNames = [];
|
||||||
|
for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null);
|
||||||
|
const colMap = new Map();
|
||||||
|
for (let c = 0; c < colNames.length; c++) {
|
||||||
|
if (colNames[c]) colMap.set(colNames[c], c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRowIndices = [];
|
||||||
|
let totalsRowIdx = -1;
|
||||||
|
for (let i = headerIdx + 1; i < rows.length; i++) {
|
||||||
|
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||||||
|
const first = rows[i][0]?.trim().toLowerCase();
|
||||||
|
if (first === 'итого' || first === 'всего') {
|
||||||
|
totalsRowIdx = i;
|
||||||
|
} else {
|
||||||
|
dataRowIndices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows, sortedRows, maxCol, colNames, colMap,
|
||||||
|
headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1,
|
||||||
|
dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx,
|
||||||
|
rowMap, hasNumber, nonEmpty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll SpreadsheetDocument to make a cell visible using arrow keys.
|
||||||
|
* Uses native platform scroll — keeps headers, data, and scrollbar synchronized.
|
||||||
|
*
|
||||||
|
* How it works:
|
||||||
|
* 1. Check target cell visibility via Playwright boundingBox (page-level coords).
|
||||||
|
* 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay.
|
||||||
|
* This is the same native click that clickSpreadsheetCell uses — it gives keyboard
|
||||||
|
* focus to the spreadsheet and keeps headers/data/scrollbar in sync.
|
||||||
|
* (frame.locator().click() bypasses overlay → desyncs frozen headers;
|
||||||
|
* page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.)
|
||||||
|
* 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport.
|
||||||
|
*
|
||||||
|
* @param {Frame} frame - Playwright Frame containing the spreadsheet cells
|
||||||
|
* @param {number} physRow - physical row (y attribute) in the frame
|
||||||
|
* @param {number} physCol - physical column (x attribute) in the frame
|
||||||
|
* @param {Locator} cellLoc - Playwright locator for the target cell (from caller)
|
||||||
|
*/
|
||||||
|
async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) {
|
||||||
|
const pageVw = await page.evaluate('window.innerWidth');
|
||||||
|
// Get iframe bounds — the actual visible region on page.
|
||||||
|
// The iframe may extend behind the section panel on the left, so cells with
|
||||||
|
// x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel.
|
||||||
|
const frameElm = await frame.frameElement();
|
||||||
|
const frameBox = await frameElm.boundingBox();
|
||||||
|
const visLeft = frameBox ? frameBox.x : 0;
|
||||||
|
const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw;
|
||||||
|
|
||||||
|
const getBox = async () => {
|
||||||
|
try { return await cellLoc.boundingBox({ timeout: 500 }); }
|
||||||
|
catch { return null; }
|
||||||
|
};
|
||||||
|
const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight;
|
||||||
|
|
||||||
|
let box = await getBox();
|
||||||
|
if (!box) return; // cell not in DOM
|
||||||
|
if (isFullyVisible(box)) return;
|
||||||
|
|
||||||
|
const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft';
|
||||||
|
|
||||||
|
// Find a fully-visible cell to click for focus.
|
||||||
|
// Prefer cells in the target row (scrollable area), fall back to any row.
|
||||||
|
const targetRowSel = `div[y="${physRow}"] div[x]`;
|
||||||
|
const anyRowSel = 'div[x]';
|
||||||
|
let focusClicked = false;
|
||||||
|
for (const sel of [targetRowSel, anyRowSel]) {
|
||||||
|
const locs = frame.locator(sel);
|
||||||
|
const count = await locs.count();
|
||||||
|
const candidates = [];
|
||||||
|
for (let ci = 0; ci < count; ci++) {
|
||||||
|
const b = await locs.nth(ci).boundingBox();
|
||||||
|
if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) {
|
||||||
|
candidates.push({ ci, box: b });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) continue;
|
||||||
|
candidates.sort((a, b) => a.box.x - b.box.x);
|
||||||
|
// ArrowRight → rightmost fully-visible (each press scrolls right immediately)
|
||||||
|
// ArrowLeft → leftmost fully-visible (each press scrolls left immediately)
|
||||||
|
const pick = direction === 'ArrowRight'
|
||||||
|
? candidates[candidates.length - 1]
|
||||||
|
: candidates[0];
|
||||||
|
// Native click through overlay — gives keyboard focus + no header desync.
|
||||||
|
await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
focusClicked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!focusClicked) return; // no visible cells — can't scroll
|
||||||
|
|
||||||
|
await scrollHorizontallyByKey({
|
||||||
|
page, direction,
|
||||||
|
isFullyVisible: async () => {
|
||||||
|
const b = await getBox();
|
||||||
|
return !!b && isFullyVisible(b);
|
||||||
|
},
|
||||||
|
getCenterX: async () => {
|
||||||
|
const b = await getBox();
|
||||||
|
return b ? b.x + b.width / 2 : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a cell in SpreadsheetDocument by logical coordinates.
|
||||||
|
* target: { row: number|'totals'|{colName: value}, column: string }
|
||||||
|
* Internal helper — called from clickElement when first arg is an object.
|
||||||
|
*/
|
||||||
|
export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
|
||||||
|
if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.');
|
||||||
|
|
||||||
|
const mapping = buildSpreadsheetMapping(allCells);
|
||||||
|
if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.');
|
||||||
|
|
||||||
|
const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping;
|
||||||
|
|
||||||
|
// Resolve column (exact → endsWith " / X" → includes)
|
||||||
|
let colName = target.column;
|
||||||
|
if (!colMap.has(colName)) {
|
||||||
|
const available = colNames.filter(n => n);
|
||||||
|
const suffix = ' / ' + colName;
|
||||||
|
const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName));
|
||||||
|
if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`);
|
||||||
|
colName = match;
|
||||||
|
}
|
||||||
|
const physCol = colMap.get(colName);
|
||||||
|
|
||||||
|
// Resolve row → index into rows[] array
|
||||||
|
let rowIdx;
|
||||||
|
const row = target.row;
|
||||||
|
if (row === 'totals') {
|
||||||
|
if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.');
|
||||||
|
rowIdx = totalsRowIdx;
|
||||||
|
} else if (typeof row === 'number') {
|
||||||
|
if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`);
|
||||||
|
rowIdx = dataRowIndices[row];
|
||||||
|
} else if (typeof row === 'object') {
|
||||||
|
// Filter: { colName: value } — find first data row where column matches
|
||||||
|
const filterEntries = Object.entries(row);
|
||||||
|
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
|
||||||
|
const resolveCol = (name) => {
|
||||||
|
if (colMap.has(name)) return colMap.get(name);
|
||||||
|
const suffix = ' / ' + name;
|
||||||
|
const available = colNames.filter(n => n);
|
||||||
|
const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name));
|
||||||
|
return m ? colMap.get(m) : null;
|
||||||
|
};
|
||||||
|
rowIdx = dataRowIndices.find(i => {
|
||||||
|
return filterEntries.every(([fCol, fVal]) => {
|
||||||
|
const fColIdx = resolveCol(fCol);
|
||||||
|
if (fColIdx == null) return false;
|
||||||
|
const cellText = norm(rows[i][fColIdx]);
|
||||||
|
const search = norm(fVal);
|
||||||
|
return cellText === search || cellText.includes(search);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`);
|
||||||
|
} else {
|
||||||
|
throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map rows[] index → physical row number
|
||||||
|
const physRow = sortedRows[rowIdx];
|
||||||
|
const cellKey = `${physRow}_${physCol}`;
|
||||||
|
const frame = frameMap.get(cellKey);
|
||||||
|
if (!frame) {
|
||||||
|
// Cell exists in mapping but might be empty — try clicking anyway
|
||||||
|
throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`);
|
||||||
|
}
|
||||||
|
// Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs.
|
||||||
|
const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first();
|
||||||
|
// Scroll cell into view using arrow keys — the only reliable way to scroll
|
||||||
|
// 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar.
|
||||||
|
await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv);
|
||||||
|
const box = await cellDiv.boundingBox();
|
||||||
|
if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`);
|
||||||
|
|
||||||
|
const x = box.x + box.width / 2;
|
||||||
|
const y = box.y + box.height / 2;
|
||||||
|
const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null;
|
||||||
|
if (modKey) await page.keyboard.down(modKey);
|
||||||
|
if (dbl) {
|
||||||
|
await page.mouse.dblclick(x, y);
|
||||||
|
} else {
|
||||||
|
await page.mouse.click(x, y);
|
||||||
|
}
|
||||||
|
if (modKey) await page.keyboard.up(modKey);
|
||||||
|
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({ clicked: { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search spreadsheet iframes for a cell matching text (for text fallback in clickElement).
|
||||||
|
* Returns { frameIndex, physRow, physCol, box } or null if not found.
|
||||||
|
*/
|
||||||
|
export async function findSpreadsheetCellByText(formNum, searchText) {
|
||||||
|
const { allCells, frameMap } = await scanSpreadsheetCells(formNum);
|
||||||
|
if (allCells.size === 0) return null;
|
||||||
|
|
||||||
|
const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || '';
|
||||||
|
const target = norm(searchText);
|
||||||
|
|
||||||
|
// Exact match first, then includes
|
||||||
|
let found = null;
|
||||||
|
for (const [key, cell] of allCells) {
|
||||||
|
if (norm(cell.t) === target) { found = { key, cell }; break; }
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
for (const [key, cell] of allCells) {
|
||||||
|
if (norm(cell.t).includes(target)) { found = { key, cell }; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
const frame = frameMap.get(found.key);
|
||||||
|
if (!frame) return null;
|
||||||
|
|
||||||
|
// Scroll cell into view using native arrow-key mechanism
|
||||||
|
const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first();
|
||||||
|
await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv);
|
||||||
|
const box = await cellDiv.boundingBox();
|
||||||
|
if (!box) return null;
|
||||||
|
|
||||||
|
return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read report output (SpreadsheetDocumentField) rendered in iframes.
|
||||||
|
* 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes.
|
||||||
|
* Each cell is a div[x] inside a row div[y], text content in <span>.
|
||||||
|
*
|
||||||
|
* Returns structured data:
|
||||||
|
* { title, headers, data: [{col: val}], totals: {col: val}, total }
|
||||||
|
* If header detection fails, falls back to { rows: string[][], total }.
|
||||||
|
*/
|
||||||
|
export async function readSpreadsheet() {
|
||||||
|
ensureConnected();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
|
||||||
|
const { allCells } = await scanSpreadsheetCells(formNum);
|
||||||
|
|
||||||
|
if (allCells.size === 0) {
|
||||||
|
// Check for state window messages (info bar) that explain why the report is empty
|
||||||
|
const err = await checkForErrors();
|
||||||
|
const hint = err?.stateText?.length ? err.stateText.join('; ') : '';
|
||||||
|
throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = buildSpreadsheetMapping(allCells);
|
||||||
|
if (!mapping) {
|
||||||
|
// Fallback: return raw rows
|
||||||
|
const rowMap = new Map();
|
||||||
|
let maxCol = 0;
|
||||||
|
for (const cell of allCells.values()) {
|
||||||
|
if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map());
|
||||||
|
rowMap.get(cell.r).set(cell.c, cell.t);
|
||||||
|
if (cell.c > maxCol) maxCol = cell.c;
|
||||||
|
}
|
||||||
|
const sortedRows = [...rowMap.keys()].sort((a, b) => a - b);
|
||||||
|
const rows = sortedRows.map(r => {
|
||||||
|
const cm = rowMap.get(r);
|
||||||
|
const arr = [];
|
||||||
|
for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || '');
|
||||||
|
return arr;
|
||||||
|
});
|
||||||
|
return { rows, total: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping;
|
||||||
|
|
||||||
|
// Convert data rows to objects
|
||||||
|
const data = [];
|
||||||
|
let totals = null;
|
||||||
|
const toObj = (row) => {
|
||||||
|
const obj = {};
|
||||||
|
for (let c = 0; c < colNames.length; c++) {
|
||||||
|
if (colNames[c] && row[c]) obj[colNames[c]] = row[c];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = dataStartIdx; i < rows.length; i++) {
|
||||||
|
if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue;
|
||||||
|
const first = rows[i][0]?.trim().toLowerCase();
|
||||||
|
if (first === 'итого' || first === 'всего') {
|
||||||
|
totals = toObj(rows[i]);
|
||||||
|
} else {
|
||||||
|
data.push(toObj(rows[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta: title, params, filters from rows before header (superRow is part of header, not meta)
|
||||||
|
const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx);
|
||||||
|
let title = '';
|
||||||
|
const meta = [];
|
||||||
|
for (let i = 0; i < metaEnd; i++) {
|
||||||
|
const parts = rows[i].filter(c => c);
|
||||||
|
if (!parts.length) continue;
|
||||||
|
if (!title) { title = parts.join(' '); continue; }
|
||||||
|
meta.push(parts.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title || undefined,
|
||||||
|
meta: meta.length ? meta : undefined,
|
||||||
|
headers: colNames.filter(n => n),
|
||||||
|
data,
|
||||||
|
totals: totals || undefined,
|
||||||
|
total: data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// web-test table/click-cell v1.4 — click a cell in a form grid by (row, column).
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// Routed from core/click.mjs when the user calls clickElement({row, column}) and
|
||||||
|
// the form has no SpreadsheetDocument (or `table` matches a grid).
|
||||||
|
//
|
||||||
|
// Key behaviors:
|
||||||
|
// - `row` can be a number (index in current DOM window) or `{col: value}` filter.
|
||||||
|
// - `scroll: true | number` enables reveal-loop via PageDown when a filter row
|
||||||
|
// isn't visible. End detected by snapshot stability between PageDowns.
|
||||||
|
// - Horizontal scroll mirrors SpreadsheetDocument: focus a visible cell in the
|
||||||
|
// target row, press ArrowRight/Left until the target column is in viewport.
|
||||||
|
//
|
||||||
|
// 1С virtualization quirks worth knowing:
|
||||||
|
// - DOM holds a window of ~N visible rows. PageDown's first press moves the
|
||||||
|
// cursor inside the window; subsequent presses swap the window contents.
|
||||||
|
// - scrollTop/scrollLeft are always 0; absolute X of cells shifts on horizontal
|
||||||
|
// scroll. So scroll progress must be inferred from cell coordinates / snapshot
|
||||||
|
// diffs, never from scrollTop/Height.
|
||||||
|
// - Frozen columns (.gridBoxFix) stay pinned at the left, overlap with scrolled
|
||||||
|
// cells — DOM scripts handle the partition; engine just consumes their results.
|
||||||
|
|
||||||
|
import { page } from '../core/state.mjs';
|
||||||
|
import { waitForStable } from '../core/wait.mjs';
|
||||||
|
import { modifierClick, returnFormState, isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||||
|
import { scrollHorizontallyByKey } from '../core/scroll-horiz.mjs';
|
||||||
|
import {
|
||||||
|
findGridCellScript, findFocusCellScript, snapshotGridScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
|
||||||
|
const REVEAL_DEFAULT_LIMIT = 50;
|
||||||
|
const PD_WAIT_MS = 300;
|
||||||
|
const FOCUS_WAIT_MS = 150;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard: a 'pic:N' filter value is a readTable picture token, not real cell text.
|
||||||
|
* Picture cells render an icon (no text), so they can't select a row — fail fast
|
||||||
|
* with guidance instead of a confusing 'row_not_found'.
|
||||||
|
*/
|
||||||
|
function assertNotPictureFilter(filter) {
|
||||||
|
for (const [k, v] of Object.entries(filter)) {
|
||||||
|
if (typeof v === 'string' && /^pic:\d+$/.test(v.trim())) {
|
||||||
|
throw new Error(`clickElement: "${v}" is a readTable picture value (column "${k}"), not selectable text — it can't be used as a row filter. Filter by a text column (e.g. name/number) instead.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `{ col: value }` row filter to a numeric index into the grid's current
|
||||||
|
* DOM window (`body.querySelectorAll('.gridLine')`). Reused by fillTableRow so it
|
||||||
|
* can target an existing row by cell values, mirroring clickElement.
|
||||||
|
*
|
||||||
|
* The filter matches across ALL columns (AND). `findGridCellScript` requires a
|
||||||
|
* `column`, so we pass the first filter key as a placeholder — it only affects the
|
||||||
|
* returned coordinates (which we ignore), not row selection. The matched row
|
||||||
|
* guarantees that key's cell is in the DOM, so no `cell_not_in_dom` for it.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.formNum
|
||||||
|
* @param {string} [args.gridSelector] - CSS selector for the target grid (same grid the caller edits)
|
||||||
|
* @param {object} args.filter - `{ col: value }` (one or more columns)
|
||||||
|
* @param {string} [args.gridName] - for diagnostics in error messages
|
||||||
|
* @param {boolean|number} [args.scroll] - reveal-loop beyond the DOM window (true = 50 PageDowns, number = limit)
|
||||||
|
* @returns {Promise<number>} resolved row index
|
||||||
|
*/
|
||||||
|
export async function resolveRowIndexByFilter({ formNum, gridSelector, filter, gridName, scroll }) {
|
||||||
|
assertNotPictureFilter(filter);
|
||||||
|
const target = { row: filter, column: Object.keys(filter)[0] };
|
||||||
|
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||||
|
if (cell?.error === 'row_not_found' && scroll) {
|
||||||
|
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
|
||||||
|
}
|
||||||
|
if (cell?.error) throw cellError(cell, target, gridName, scroll, 'fillTableRow');
|
||||||
|
return cell.rowIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a cell in a form grid by (row, column). Called from core/click.mjs.
|
||||||
|
*
|
||||||
|
* @param {object} target - { row: number|{col:value}, column: string }
|
||||||
|
* @param {object} ctx
|
||||||
|
* @param {number} ctx.formNum
|
||||||
|
* @param {string} ctx.gridSelector - CSS selector for the target grid
|
||||||
|
* @param {string} [ctx.gridName] - for diagnostics
|
||||||
|
* @param {string} [ctx.modifier] - 'ctrl' | 'shift' for multi-select
|
||||||
|
* @param {boolean} [ctx.dblclick]
|
||||||
|
* @param {boolean|number} [ctx.scroll] - true = up to 50 PageDowns, number = exact limit
|
||||||
|
*/
|
||||||
|
export async function clickGridCell(target, ctx) {
|
||||||
|
const { formNum, gridSelector, gridName, modifier, dblclick, scroll } = ctx;
|
||||||
|
|
||||||
|
if (target?.row && typeof target.row === 'object') assertNotPictureFilter(target.row);
|
||||||
|
|
||||||
|
// 1. Try to find the cell in current DOM window.
|
||||||
|
let cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||||
|
|
||||||
|
// 2. Reveal loop: only for filter-based row search with scroll opt-in.
|
||||||
|
if (cell?.error === 'row_not_found' && scroll && target.row && typeof target.row === 'object') {
|
||||||
|
cell = await revealAndFindCell({ formNum, gridSelector, target, scroll });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell?.error) throw cellError(cell, target, gridName, scroll);
|
||||||
|
|
||||||
|
// 3. Horizontal scroll if cell is off-viewport.
|
||||||
|
if (!cell.visible) {
|
||||||
|
await scrollGridToCell({ formNum, gridSelector, target, cell });
|
||||||
|
cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||||
|
if (cell?.error) {
|
||||||
|
throw new Error(`clickElement: cell vanished after horizontal scroll: ${cell.error}`);
|
||||||
|
}
|
||||||
|
if (!cell.visible) {
|
||||||
|
// Scroll loop bailed out before reaching the target. Don't silently click
|
||||||
|
// at off-screen coordinates — that would report a false success.
|
||||||
|
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
|
||||||
|
throw new Error(`clickElement: horizontal scroll could not reach column "${cell.columnText}"${ctxMsg} (cell still at x=${cell.cellX}, viewport ends at ${cell.gridRight}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Click.
|
||||||
|
await modifierClick(cell.x, cell.y, modifier, { dbl: !!dblclick });
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({
|
||||||
|
clicked: {
|
||||||
|
kind: 'gridCell',
|
||||||
|
row: target.row,
|
||||||
|
column: cell.columnText,
|
||||||
|
...(dblclick ? { dblclick: true } : {}),
|
||||||
|
...(modifier ? { modifier } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellError(cell, target, gridName, scroll, who = 'clickElement') {
|
||||||
|
const ctxMsg = gridName ? ` in table "${gridName}"` : '';
|
||||||
|
if (cell.error === 'row_not_found') {
|
||||||
|
const hint = scroll
|
||||||
|
? ' (reveal-loop exhausted)'
|
||||||
|
: ' — pass { scroll: true } to scan beyond the current DOM window';
|
||||||
|
return new Error(`${who}: row matching ${JSON.stringify(target.row)} not found${ctxMsg}${hint}.`);
|
||||||
|
}
|
||||||
|
if (cell.error === 'column_not_found' || cell.error === 'filter_column_not_found') {
|
||||||
|
return new Error(`${who}: column "${cell.column}" not found${ctxMsg}. Available: ${(cell.available || []).join(', ')}`);
|
||||||
|
}
|
||||||
|
if (cell.error === 'row_out_of_range') {
|
||||||
|
return new Error(`${who}: row index ${cell.row} out of range${ctxMsg} (loaded: ${cell.loaded}). Note: row index is into current DOM window, not absolute — long lists are virtualized.`);
|
||||||
|
}
|
||||||
|
return new Error(`${who}: cannot resolve cell ${JSON.stringify(target)}${ctxMsg}: ${cell.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Press PageDown in a loop, scanning DOM each iteration for the target row.
|
||||||
|
* Bail when the row is found, snapshots stop changing (end of list), or limit hit.
|
||||||
|
* page.mouse.click on a safe cell first — PageDown needs keyboard focus on gridBody.
|
||||||
|
*/
|
||||||
|
async function revealAndFindCell({ formNum, gridSelector, target, scroll }) {
|
||||||
|
const limit = typeof scroll === 'number' ? scroll : REVEAL_DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
const focusPt = await page.evaluate(findFocusCellScript(gridSelector));
|
||||||
|
if (!focusPt) return { error: 'no_focusable_cell' };
|
||||||
|
await page.mouse.click(focusPt.x, focusPt.y);
|
||||||
|
await page.waitForTimeout(FOCUS_WAIT_MS);
|
||||||
|
// Click on a Number/Date cell auto-enters edit mode in 1С; PageDown there
|
||||||
|
// is a no-op. Exit edit mode before driving the reveal loop.
|
||||||
|
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevSnap = await page.evaluate(snapshotGridScript(gridSelector));
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
await page.keyboard.press('PageDown');
|
||||||
|
await page.waitForTimeout(PD_WAIT_MS);
|
||||||
|
|
||||||
|
const cell = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||||
|
if (!cell?.error) return cell;
|
||||||
|
|
||||||
|
const snap = await page.evaluate(snapshotGridScript(gridSelector));
|
||||||
|
// Reached the end of the list. Primary signal: nothing remains below
|
||||||
|
// (`hasBelow === false`) — the reliable cross-grid-type signal. Content
|
||||||
|
// stability is only a fallback when hasBelow is unknown: it compares the
|
||||||
|
// full-row text (snapshotGridScript joins every cell), so a low-cardinality
|
||||||
|
// first column (e.g. all "Товар 0X") can't look "stable" mid-scroll.
|
||||||
|
const reachedEnd = snap && (
|
||||||
|
snap.hasBelow === false
|
||||||
|
|| (snap.hasBelow == null
|
||||||
|
&& snap.firstText === prevSnap?.firstText
|
||||||
|
&& snap.lastText === prevSnap?.lastText
|
||||||
|
&& snap.selIdx === prevSnap?.selIdx
|
||||||
|
&& snap.lineCount === prevSnap?.lineCount)
|
||||||
|
);
|
||||||
|
if (reachedEnd) return { error: 'row_not_found', filter: target.row };
|
||||||
|
prevSnap = snap;
|
||||||
|
}
|
||||||
|
return { error: 'row_not_found', filter: target.row };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the grid horizontally so the target cell falls inside the viewport.
|
||||||
|
* Focuses an edge cell in the target row (rightmost-visible for ArrowRight,
|
||||||
|
* leftmost-visible for ArrowLeft) so the next arrow key immediately scrolls.
|
||||||
|
*
|
||||||
|
* Frozen columns (gridBoxFix) are excluded from focus candidates — they don't
|
||||||
|
* drive the scrollable viewport. The DOM script handles that detail.
|
||||||
|
*/
|
||||||
|
async function scrollGridToCell({ formNum, gridSelector, target, cell }) {
|
||||||
|
const direction = cell.cellX > cell.gridRight ? 'ArrowRight'
|
||||||
|
: cell.cellRight < cell.gridX ? 'ArrowLeft'
|
||||||
|
: (cell.cellRight > cell.gridRight ? 'ArrowRight' : 'ArrowLeft');
|
||||||
|
|
||||||
|
const focusPt = await page.evaluate(
|
||||||
|
findFocusCellScript(gridSelector, { rowIdx: cell.rowIdx, direction })
|
||||||
|
);
|
||||||
|
if (!focusPt) throw new Error('clickElement: no visible cell to focus for horizontal scroll');
|
||||||
|
await page.mouse.click(focusPt.x, focusPt.y);
|
||||||
|
await page.waitForTimeout(FOCUS_WAIT_MS);
|
||||||
|
// Click on a Number/Date cell auto-enters edit mode in 1С; arrow keys there
|
||||||
|
// navigate text inside the input rather than scrolling the viewport. Exit first.
|
||||||
|
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
await scrollHorizontallyByKey({
|
||||||
|
page,
|
||||||
|
direction,
|
||||||
|
isFullyVisible: async () => {
|
||||||
|
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||||
|
return !!c && !c.error && c.visible;
|
||||||
|
},
|
||||||
|
getCenterX: async () => {
|
||||||
|
const c = await page.evaluate(findGridCellScript(formNum, gridSelector, target));
|
||||||
|
return c && !c.error ? c.x : null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// web-test table/click-row v1.0 — click handlers for grid row targets: gridGroup, gridTreeNode, gridRow.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// All handlers are called by core/click.mjs dispatcher after target is found.
|
||||||
|
// Each takes (target, ctx) where ctx = { formNum, modifier, dblclick, toggle, expand, ... }
|
||||||
|
// and returns a form state with `clicked: { kind, name, ... }`.
|
||||||
|
|
||||||
|
import { waitForStable } from '../core/wait.mjs';
|
||||||
|
import { modifierClick, returnFormState } from '../core/helpers.mjs';
|
||||||
|
import { getGridToggleIcon, shouldClickToggle } from './grid-toggle.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for gridGroup / gridParent targets (hierarchy mode).
|
||||||
|
* With `expand`/`toggle` — click the level-indicator icon to expand/collapse the group.
|
||||||
|
* Without — dblclick the row to enter the group / go up to parent.
|
||||||
|
*/
|
||||||
|
export async function clickGridGroupTarget(target, ctx) {
|
||||||
|
const { formNum, modifier, toggle, expand } = ctx;
|
||||||
|
if (expand != null || toggle) {
|
||||||
|
// Expand/collapse group — click the triangle icon (.gridListH/.gridListV).
|
||||||
|
// expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click.
|
||||||
|
const levelIconInfo = await getGridToggleIcon(target, formNum, {
|
||||||
|
iconSelector: '.gridListH, .gridListV',
|
||||||
|
isExpandedExpr: "icon.classList.contains('gridListV')",
|
||||||
|
});
|
||||||
|
const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle);
|
||||||
|
if (shouldClick) {
|
||||||
|
if (levelIconInfo) {
|
||||||
|
await modifierClick(levelIconInfo.x, levelIconInfo.y, modifier);
|
||||||
|
} else {
|
||||||
|
// Fallback: dblclick (standard hierarchy navigation)
|
||||||
|
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await waitForStable(formNum);
|
||||||
|
return returnFormState({
|
||||||
|
clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
|
||||||
|
hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Default: dblclick to enter group / go up to parent
|
||||||
|
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||||
|
await waitForStable(formNum);
|
||||||
|
return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for gridTreeNode targets (tree-style grid).
|
||||||
|
* With `expand`/`toggle` — click the tree icon to expand/collapse.
|
||||||
|
* Without — single-click to select the row (no expand).
|
||||||
|
*/
|
||||||
|
export async function clickGridTreeNodeTarget(target, ctx) {
|
||||||
|
const { formNum, modifier, toggle, expand } = ctx;
|
||||||
|
if (expand != null || toggle) {
|
||||||
|
// Expand/collapse tree node — click the tree icon [tree="true"].
|
||||||
|
const treeIconInfo = await getGridToggleIcon(target, formNum, {
|
||||||
|
iconSelector: '.gridBoxImg [tree="true"]',
|
||||||
|
isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")',
|
||||||
|
});
|
||||||
|
const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle);
|
||||||
|
if (shouldClick) {
|
||||||
|
if (treeIconInfo) {
|
||||||
|
await modifierClick(treeIconInfo.x, treeIconInfo.y, modifier);
|
||||||
|
} else {
|
||||||
|
// Fallback: dblclick on row (works for trees without clickable +/- icons)
|
||||||
|
await modifierClick(target.x, target.y, modifier, { dbl: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await waitForStable(formNum);
|
||||||
|
return returnFormState({
|
||||||
|
clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
|
||||||
|
hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Default: select row (click text, no expand/collapse)
|
||||||
|
await modifierClick(target.x, target.y, modifier);
|
||||||
|
await waitForStable(formNum);
|
||||||
|
return returnFormState({
|
||||||
|
clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) },
|
||||||
|
hint: 'Row selected. Use { expand: true } to expand/collapse.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for gridRow targets (flat list row).
|
||||||
|
* Single click selects the row; `dblclick: true` opens the item.
|
||||||
|
*/
|
||||||
|
export async function clickGridRowTarget(target, ctx) {
|
||||||
|
const { modifier, dblclick } = ctx;
|
||||||
|
await modifierClick(target.x, target.y, modifier, { dbl: !!dblclick });
|
||||||
|
await waitForStable();
|
||||||
|
return returnFormState({
|
||||||
|
clicked: { kind: 'gridRow', name: target.name, ...(dblclick ? { dblclick: true } : {}), ...(modifier ? { modifier } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// web-test table/filter v1.19 — filterList / unfilterList — simple search + advanced-column filter badges.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
detectFormScript, readSubmenuScript,
|
||||||
|
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
|
||||||
|
findFirstGridCellCoordsScript, findColumnFirstCellCoordsScript,
|
||||||
|
readFieldSelectorInfoScript, pickFieldInSelectorDropdownScript,
|
||||||
|
readFilterDialogInfoScript, findFilterBadgeCloseScript, findFirstFilterBadgeCloseScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||||
|
import { waitForStable, waitForCondition } from '../core/wait.mjs';
|
||||||
|
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||||
|
import { safeClick, returnFormState } from '../core/helpers.mjs';
|
||||||
|
import { selectValue, fillReferenceField } from '../forms/select-value.mjs';
|
||||||
|
import { pasteText } from '../core/clipboard.mjs';
|
||||||
|
import { getFormState } from '../forms/state.mjs';
|
||||||
|
import { clickElement } from '../core/click.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter the current list by field value, or search via search bar.
|
||||||
|
*
|
||||||
|
* Without field: simple search via the search bar (filters by all columns, no badge).
|
||||||
|
* With field: advanced search — clicks target column cell to auto-populate FieldSelector,
|
||||||
|
* opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge.
|
||||||
|
* Handles text, reference (with Tab autocomplete), and date fields automatically.
|
||||||
|
* Multiple filters can be chained by calling filterList multiple times.
|
||||||
|
*
|
||||||
|
* @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016")
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата")
|
||||||
|
* @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact)
|
||||||
|
*/
|
||||||
|
export async function filterList(text, { field, exact } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error('filterList: no form found');
|
||||||
|
|
||||||
|
if (!field) {
|
||||||
|
// --- Simple search: fill search input + Enter ---
|
||||||
|
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
|
||||||
|
|
||||||
|
if (searchInfo) {
|
||||||
|
await page.click(`[id="${searchInfo.id}"]`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await pasteText(text);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await waitForStable(formNum);
|
||||||
|
|
||||||
|
return returnFormState({ filtered: { type: 'search', text } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No search input — Ctrl+F opens advanced search on such forms.
|
||||||
|
// Click first grid cell then fall through to advanced search path below.
|
||||||
|
const firstCell = await page.evaluate(findFirstGridCellCoordsScript(formNum));
|
||||||
|
if (!firstCell) throw new Error('filterList: no search input and no grid found on this form');
|
||||||
|
await page.mouse.click(firstCell.x, firstCell.y);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти ---
|
||||||
|
// Clicking a cell in the target column makes it active, so when Alt+F opens the
|
||||||
|
// advanced search dialog, FieldSelector is auto-populated with the correct field name.
|
||||||
|
// This avoids changing FieldSelector programmatically (which can cause errors).
|
||||||
|
const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim());
|
||||||
|
|
||||||
|
// 1. Click a cell in the target column to activate it (auto-populates FieldSelector).
|
||||||
|
// If the column isn't visible in the grid, click any cell and use DLB fallback later.
|
||||||
|
let needDlb = false;
|
||||||
|
const gridEl = await page.evaluate(findColumnFirstCellCoordsScript(formNum, field));
|
||||||
|
if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`);
|
||||||
|
needDlb = !!gridEl.needDlb;
|
||||||
|
await page.mouse.click(gridEl.x, gridEl.y);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// 2. Open advanced search dialog via Alt+F (with fallback to Еще menu)
|
||||||
|
await page.keyboard.press('Alt+f');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
let dialogForm = await page.evaluate(detectFormScript());
|
||||||
|
if (dialogForm === formNum) {
|
||||||
|
// Alt+F didn't open dialog — fallback to Еще → Расширенный поиск
|
||||||
|
await clickElement('Еще');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const menu = await page.evaluate(readSubmenuScript());
|
||||||
|
const searchItem = Array.isArray(menu) && menu.find(i =>
|
||||||
|
i.name.replace(/ /g, ' ').toLowerCase().includes('расширенный поиск'));
|
||||||
|
if (!searchItem) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
throw new Error('filterList: advanced search dialog could not be opened');
|
||||||
|
}
|
||||||
|
await page.mouse.click(searchItem.x, searchItem.y);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
dialogForm = await page.evaluate(detectFormScript());
|
||||||
|
if (dialogForm === formNum) {
|
||||||
|
throw new Error('filterList: advanced search dialog did not open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown
|
||||||
|
// Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field)
|
||||||
|
if (needDlb && field) {
|
||||||
|
const fsInfo = await page.evaluate(readFieldSelectorInfoScript(dialogForm));
|
||||||
|
|
||||||
|
if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) {
|
||||||
|
await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const ddResult = await page.evaluate(pickFieldInSelectorDropdownScript(field));
|
||||||
|
|
||||||
|
if (ddResult.error) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`);
|
||||||
|
}
|
||||||
|
await page.mouse.click(ddResult.x, ddResult.y);
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Read dialog state and fill Pattern
|
||||||
|
// Detect field type by Pattern's sibling buttons:
|
||||||
|
// - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value)
|
||||||
|
// - iDLB on Pattern → reference field (paste + Tab for autocomplete)
|
||||||
|
// - neither → plain text field (just paste)
|
||||||
|
const dialogInfo = await page.evaluate(readFilterDialogInfoScript(dialogForm));
|
||||||
|
|
||||||
|
if (dialogInfo.isDate) {
|
||||||
|
// Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste)
|
||||||
|
if (isDateValue && dialogInfo.patternValue !== text.trim()) {
|
||||||
|
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Home');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page.keyboard.press('Shift+End');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await pasteText(text);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Text or reference field: fill Pattern via clipboard paste
|
||||||
|
await page.click(`[id="${dialogInfo.patternId}"]`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await pasteText(text);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
if (dialogInfo.isRef) {
|
||||||
|
// Reference field: Tab triggers autocomplete to resolve text → reference value
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3b. Switch CompareType if exact match requested (text fields only).
|
||||||
|
// Date/number: always exact, CompareType disabled. Reference: default exact (selects ref).
|
||||||
|
if (exact && !dialogInfo.isDate && !dialogInfo.isRef) {
|
||||||
|
const exactRadio = await page.evaluate(findCompareTypeRadioScript(dialogForm, 2));
|
||||||
|
if (exactRadio && !exactRadio.already) {
|
||||||
|
await page.mouse.click(exactRadio.x, exactRadio.y);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked)
|
||||||
|
const findBtnCoords = await page.evaluate(findNamedButtonScript('Найти'));
|
||||||
|
if (findBtnCoords) {
|
||||||
|
await page.mouse.click(findBtnCoords.x, findBtnCoords.y);
|
||||||
|
} else {
|
||||||
|
await clickElement('Найти');
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти).
|
||||||
|
// Check the specific dialog form — not generic modalSurface — to avoid closing parent modals
|
||||||
|
// (e.g. a selection form that opened this advanced search).
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm));
|
||||||
|
if (!dialogVisible) break;
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
await waitForStable(formNum);
|
||||||
|
|
||||||
|
return returnFormState({ filtered: { type: 'advanced', field, text, exact: !!exact } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove active filters/search from the current list.
|
||||||
|
*
|
||||||
|
* Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field).
|
||||||
|
* With field: clicks the × button on the specific filter badge (selective removal).
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×)
|
||||||
|
*/
|
||||||
|
export async function unfilterList({ field } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error('unfilterList: no form found');
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
// --- Selective: click × on specific filter badge ---
|
||||||
|
const closeBtn = await page.evaluate(findFilterBadgeCloseScript(formNum, field));
|
||||||
|
|
||||||
|
if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`);
|
||||||
|
await page.mouse.click(closeBtn.x, closeBtn.y);
|
||||||
|
await waitForStable(formNum);
|
||||||
|
|
||||||
|
return returnFormState({ unfiltered: { field: closeBtn.field } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Clear ALL filters ---
|
||||||
|
|
||||||
|
// 1. Remove all advanced filter badges (.trainItem × buttons)
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
const badge = await page.evaluate(findFirstFilterBadgeCloseScript(formNum));
|
||||||
|
if (!badge) break;
|
||||||
|
await page.mouse.click(badge.x, badge.y);
|
||||||
|
await waitForStable(formNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cancel active search via Ctrl+Q
|
||||||
|
await page.keyboard.press('Control+q');
|
||||||
|
await waitForStable(formNum);
|
||||||
|
|
||||||
|
// 3. Clear simple search field if it has a value
|
||||||
|
const searchInfo = await page.evaluate(findSearchInputScript(formNum));
|
||||||
|
|
||||||
|
if (searchInfo?.value) {
|
||||||
|
await page.click(`[id="${searchInfo.id}"]`);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await waitForStable(formNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnFormState({ unfiltered: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/
|
||||||
|
// collapse toggles. Used by clickElement's gridGroup/gridParent and
|
||||||
|
// gridTreeNode branches; the actual mouse click stays in the caller because
|
||||||
|
// it depends on the caller-local modifier-key handling.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import { page } from '../core/state.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locate the toggle icon for the grid row at `target.y`. Inspects the row
|
||||||
|
* under that Y-coordinate inside the resolved grid, returns the icon's
|
||||||
|
* center coordinates and current expanded state — or `null` if no toggle
|
||||||
|
* icon is present (e.g. leaf node or detached row).
|
||||||
|
*
|
||||||
|
* @param {{y:number, gridId?:string}} target
|
||||||
|
* @param {number} formNum
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.iconSelector — CSS selector inside .gridLine
|
||||||
|
* (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes)
|
||||||
|
* @param {string} opts.isExpandedExpr — JS expression evaluated in browser
|
||||||
|
* context where `icon` is the matched element; must yield a boolean
|
||||||
|
* (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')")
|
||||||
|
* @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>}
|
||||||
|
*/
|
||||||
|
export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) {
|
||||||
|
return await page.evaluate(`(() => {
|
||||||
|
const p = ${JSON.stringify(`form${formNum}_`)};
|
||||||
|
const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)};
|
||||||
|
const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid');
|
||||||
|
const body = grid?.querySelector('.gridBody');
|
||||||
|
if (!body) return null;
|
||||||
|
const targetY = ${target.y};
|
||||||
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
|
for (const line of lines) {
|
||||||
|
const lr = line.getBoundingClientRect();
|
||||||
|
if (targetY < lr.top || targetY > lr.bottom) continue;
|
||||||
|
const icon = line.querySelector(${JSON.stringify(iconSelector)});
|
||||||
|
if (icon) {
|
||||||
|
const r = icon.getBoundingClientRect();
|
||||||
|
const isExpanded = ${isExpandedExpr};
|
||||||
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard expand/toggle decision: should we click the toggle icon?
|
||||||
|
* - `toggle:true` → always click.
|
||||||
|
* - `expand:true` → click only if not already expanded.
|
||||||
|
* - `expand:false` → click only if currently expanded.
|
||||||
|
* - If no icon found (`iconInfo` is null) → click anyway (caller falls back to dblclick).
|
||||||
|
*
|
||||||
|
* @param {{isExpanded:boolean}|null} iconInfo
|
||||||
|
* @param {boolean|undefined} expand
|
||||||
|
* @param {boolean|undefined} toggle
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function shouldClickToggle(iconInfo, expand, toggle) {
|
||||||
|
return toggle || !iconInfo
|
||||||
|
|| (expand === true && !iconInfo.isExpanded)
|
||||||
|
|| (expand === false && iconInfo.isExpanded);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// web-test table/grid v1.20 — Form-grid operations: read table rows, fill rows, delete rows.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM):
|
||||||
|
// табличные части документов, формы списков, ТЧ настроек и т.п.
|
||||||
|
// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs).
|
||||||
|
|
||||||
|
import { page, ensureConnected } from '../core/state.mjs';
|
||||||
|
import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs';
|
||||||
|
import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs';
|
||||||
|
import { isInputFocusedInGrid } from '../core/helpers.mjs';
|
||||||
|
import { dismissPendingErrors } from '../core/errors.mjs';
|
||||||
|
import { waitForStable } from '../core/wait.mjs';
|
||||||
|
import { clickElement } from '../core/click.mjs';
|
||||||
|
import { returnFormState } from '../core/helpers.mjs';
|
||||||
|
|
||||||
|
/** Read structured table data with pagination. Returns columns, rows, total count. */
|
||||||
|
export async function readTable({ maxRows = 20, offset = 0, table } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error('readTable: no form found');
|
||||||
|
let gridSelector;
|
||||||
|
if (table) {
|
||||||
|
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||||
|
if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||||
|
gridSelector = resolved.gridSelector;
|
||||||
|
}
|
||||||
|
return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a row from the current table part.
|
||||||
|
* Single click to select the row, then Delete key to remove it.
|
||||||
|
*
|
||||||
|
* @param {number} row - 0-based row index to delete
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||||
|
* @returns {object} form state with { deleted, rowsBefore, rowsAfter }
|
||||||
|
*/
|
||||||
|
export async function deleteTableRow(row, { tab, table } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error('deleteTableRow: no form found');
|
||||||
|
|
||||||
|
// Pre-resolve grid when table is specified
|
||||||
|
let gridSelector;
|
||||||
|
if (table) {
|
||||||
|
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||||
|
if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||||
|
gridSelector = resolved.gridSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Switch tab if requested
|
||||||
|
if (tab) {
|
||||||
|
await clickElement(tab);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find the target row and click to select it
|
||||||
|
const cellCoords = await page.evaluate(findDeleteRowCoordsScript(gridSelector, row));
|
||||||
|
|
||||||
|
if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||||
|
|
||||||
|
const rowsBefore = cellCoords.total;
|
||||||
|
|
||||||
|
// Pre-click Escape: leftover edit-mode from a prior fillTableRow Tab-navigation.
|
||||||
|
// Without it the next mouse click may not select the row reliably (the active
|
||||||
|
// edit input intercepts the event timing).
|
||||||
|
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single click to select the row
|
||||||
|
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
// Post-click Escape: clicking a Number/Date cell auto-enters edit mode in 1С.
|
||||||
|
// Delete in edit mode clears the cell buffer instead of deleting the row, so
|
||||||
|
// we exit edit first. The row remains selected after Escape — Delete acts on it.
|
||||||
|
if (await isInputFocusedInGrid({ gridSelector })) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Press Delete to remove the row
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await waitForStable();
|
||||||
|
|
||||||
|
// 4. Count rows after deletion
|
||||||
|
const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector));
|
||||||
|
|
||||||
|
return returnFormState({ deleted: row, rowsBefore, rowsAfter });
|
||||||
|
}
|
||||||
@@ -0,0 +1,957 @@
|
|||||||
|
// web-test table/row-fill v1.23 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
import {
|
||||||
|
page, ensureConnected, normYo, highlightMode, ACTION_WAIT,
|
||||||
|
} from '../core/state.mjs';
|
||||||
|
import {
|
||||||
|
detectFormScript, resolveGridScript, readTableScript,
|
||||||
|
countGridRowsScript, isTreeGridScript, findGridHeadCenterCoordsScript,
|
||||||
|
getSelectedOrLastRowIndexScript,
|
||||||
|
isNotInListCloudVisibleScript, clickShowAllInNotInListCloudScript,
|
||||||
|
sortFieldKeysByColindexScript, findCellCoordsByFieldsScript,
|
||||||
|
findNextCellCoordsByKeyScript, findCheckboxAtPointScript,
|
||||||
|
findRowCommitClickCoordsScript, getGridEditCheckScript,
|
||||||
|
readActiveGridCellScript, getElementCenterCoordsByIdScript,
|
||||||
|
} from '../../dom.mjs';
|
||||||
|
import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs';
|
||||||
|
import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs';
|
||||||
|
import { highlight, unhighlight } from '../recording/highlight.mjs';
|
||||||
|
import {
|
||||||
|
safeClick, findFieldInputId, returnFormState,
|
||||||
|
detectNewForm as helperDetectNewForm,
|
||||||
|
isInputFocused, isInputFocusedInGrid, findOpenPopup,
|
||||||
|
readEdd, isEddVisible, clickEddItemViaDispatch,
|
||||||
|
} from '../core/helpers.mjs';
|
||||||
|
import { clickElement } from '../core/click.mjs';
|
||||||
|
import { resolveRowIndexByFilter } from './click-cell.mjs';
|
||||||
|
import {
|
||||||
|
pickFromSelectionForm, isTypeDialog, pickFromTypeDialog,
|
||||||
|
fillReferenceField, selectValue,
|
||||||
|
} from '../forms/select-value.mjs';
|
||||||
|
import { pasteText } from '../core/clipboard.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill a choice cell (_CB iCB, buttonKind==='choice') whose INPUT is already focused.
|
||||||
|
*
|
||||||
|
* Two kinds of cell carry the same choice button and are INDISTINGUISHABLE in the DOM
|
||||||
|
* (both `editInput`, readOnly:false):
|
||||||
|
* (a) editable value cell (Произвольный/примитив, РедактированиеТекста=Истина) — typed text sticks;
|
||||||
|
* (b) pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь) — typed text is rejected.
|
||||||
|
* The only reliable discriminator is behavioral: paste and watch the input value.
|
||||||
|
* stuck → editable cell → leave value in the INPUT (caller's Tab/commit persists it), method 'direct';
|
||||||
|
* rejected → F4 → form: isTypeDialog ? pickFromTypeDialog ('choice') : pickFromSelectionForm ('form').
|
||||||
|
*
|
||||||
|
* Does NOT navigate between cells — caller owns Tab/dblclick/row-commit.
|
||||||
|
*
|
||||||
|
* @param {number} formNum base form number (for new-form detection)
|
||||||
|
* @param {string} text value to fill
|
||||||
|
* @param {Object} [opts]
|
||||||
|
* @param {string|null} [opts.type] explicit type for composite/value-list pick
|
||||||
|
* @param {string} [opts.fieldLabel] field name for diagnostics / selection-form search
|
||||||
|
* @returns {{ ok, method, error?, message?, value? }}
|
||||||
|
*/
|
||||||
|
async function fillChoiceCell(formNum, text, { type = null, fieldLabel = '' } = {}) {
|
||||||
|
const norm = (s) => normYo((s || '').toLowerCase());
|
||||||
|
const before = await page.evaluate(`document.activeElement?.value || ''`);
|
||||||
|
// Re-fill guard: cell already holds the target (paste wouldn't change it → false "rejected").
|
||||||
|
if (before && norm(before).includes(norm(text))) {
|
||||||
|
return { ok: true, method: 'skip', value: before };
|
||||||
|
}
|
||||||
|
// Paste, then poll. Three outcomes, distinguished BEHAVIORALLY (not by value equality):
|
||||||
|
// (1) EDD autocomplete appears → reference/list cell → pick from the dropdown;
|
||||||
|
// (2) input changes to non-empty, no EDD → editable cell → leave value, method 'direct';
|
||||||
|
// (3) input unchanged (rejected) → НачалоВыбора pick-from-list → F4 selection form.
|
||||||
|
// A value-equality check on `after` is UNRELIABLE: numeric/date masks reformat the pasted
|
||||||
|
// text (grouping nbsp, decimal comma, padding) — e.g. "1234.56" → "1 234,56", "0,000"
|
||||||
|
// baseline. So we test "did the input change to non-empty" + "no autocomplete", never
|
||||||
|
// "does after contain text" (that false-negatives on reformatting → F4 → stray calculator).
|
||||||
|
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||||
|
let after = before, changed = false, eddSeen = false;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
if (await isEddVisible()) { eddSeen = true; break; }
|
||||||
|
after = await page.evaluate(`document.activeElement?.value || ''`);
|
||||||
|
if (after !== before && after !== '') changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eddSeen) {
|
||||||
|
// Reference/list cell — pick a MATCHING item from the autocomplete. Only accept an
|
||||||
|
// exact (parenthetical-stripped) or substring match; never blind-pick items[0] — for a
|
||||||
|
// non-existent value 1C still lists unrelated entries, and picking the first silently
|
||||||
|
// writes the wrong reference. No match → fall through to the F4 selection form, which
|
||||||
|
// searches the full list and returns not_found if the value is truly absent.
|
||||||
|
const edd = await readEdd();
|
||||||
|
const items = (edd.items || []).map(i => i.name)
|
||||||
|
.filter(i => !/^Создать[\s:]/.test(i) && !/не найдено/i.test(i) && !/показать все/i.test(i));
|
||||||
|
const tgt = norm(text);
|
||||||
|
const pick = items.find(i => norm(i.replace(/\s*\([^)]*\)\s*$/, '')) === tgt)
|
||||||
|
|| items.find(i => norm(i).includes(tgt));
|
||||||
|
if (pick) {
|
||||||
|
await clickEddItemViaDispatch(pick);
|
||||||
|
await waitForStable();
|
||||||
|
return { ok: true, method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') };
|
||||||
|
}
|
||||||
|
// No matching item — dismiss the autocomplete and fall through to the F4 selection form.
|
||||||
|
await page.keyboard.press('Escape'); await page.waitForTimeout(200);
|
||||||
|
} else if (changed) {
|
||||||
|
// Editable cell — value lives in the INPUT; caller's Tab / end-of-row commit persists it.
|
||||||
|
return { ok: true, method: 'direct', value: after };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text rejected (pick-from-list cell) — nothing typed to clear (field is not text-editable).
|
||||||
|
// Dismiss any autocomplete hint, then open the choice form via F4.
|
||||||
|
if (await isEddVisible()) { await page.keyboard.press('Escape'); await page.waitForTimeout(200); }
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
let choiceForm = null;
|
||||||
|
for (let cw = 0; cw < 8; cw++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
choiceForm = await helperDetectNewForm(formNum);
|
||||||
|
if (choiceForm !== null) break;
|
||||||
|
}
|
||||||
|
if (choiceForm === null) {
|
||||||
|
// F4 safety net: on an editable numeric/date cell mis-routed here, F4 opens a
|
||||||
|
// calculator/calendar (NOT a selection form). Close it — never leave the popup open
|
||||||
|
// (it blocks the UI) — and salvage: if the cell now holds a value, count it as 'direct'.
|
||||||
|
if (await findOpenPopup()) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); if (!(await findOpenPopup())) break; }
|
||||||
|
const nowVal = await page.evaluate(`document.activeElement?.value || ''`);
|
||||||
|
if (nowVal && nowVal !== before) return { ok: true, method: 'direct', value: nowVal };
|
||||||
|
}
|
||||||
|
return { ok: false, error: 'no_selection_form', message: `Cell "${fieldLabel || text}": F4 did not open a choice form` };
|
||||||
|
}
|
||||||
|
if (await isTypeDialog(choiceForm)) {
|
||||||
|
try {
|
||||||
|
await pickFromTypeDialog(choiceForm, type || text);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: 'not_found', message: e.message };
|
||||||
|
}
|
||||||
|
await waitForStable(formNum);
|
||||||
|
// A value form opened after the type pick → composite-value cell needs { value, type }.
|
||||||
|
const valForm = await helperDetectNewForm(formNum);
|
||||||
|
if (valForm !== null) {
|
||||||
|
await page.keyboard.press('Escape'); await page.waitForTimeout(300);
|
||||||
|
return { ok: false, error: 'type_required', message: `Cell "${fieldLabel || text}" expects { value, type }` };
|
||||||
|
}
|
||||||
|
return { ok: true, method: 'choice', value: text };
|
||||||
|
}
|
||||||
|
const pr = await pickFromSelectionForm(choiceForm, fieldLabel || text, text, formNum);
|
||||||
|
return pr.ok ? { ok: true, method: 'form' } : { ok: false, error: pr.error, message: pr.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill cells in the current table row via Tab navigation.
|
||||||
|
* Grid cells are only accessible sequentially (Tab) — no random access.
|
||||||
|
*
|
||||||
|
* After "Добавить", 1C enters inline edit mode on the first cell.
|
||||||
|
* All inputs in the row are created hidden (offsetWidth=0); only the active one is visible.
|
||||||
|
* Tab moves through cells in a fixed order determined by the form configuration.
|
||||||
|
*
|
||||||
|
* @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура")
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {string} [options.tab] - Switch to this form tab before operating
|
||||||
|
* @param {boolean} [options.add] - Click "Добавить" to create a new row first
|
||||||
|
* @param {number|Object} [options.row] - Edit existing row: 0-based DOM-window index, or
|
||||||
|
* a `{ col: value }` filter (one or more columns, AND-matched) to locate the row by cell values
|
||||||
|
* @param {boolean|number} [options.scroll] - When `row` is a filter, scan beyond the current
|
||||||
|
* DOM window via PageDown (true = up to 50 presses, number = exact limit)
|
||||||
|
* @returns {{ filled[], notFilled[]?, form }}
|
||||||
|
*/
|
||||||
|
export async function fillTableRow(fields, { tab, add, row, table, scroll } = {}) {
|
||||||
|
ensureConnected();
|
||||||
|
await dismissPendingErrors();
|
||||||
|
const formNum = await page.evaluate(detectFormScript());
|
||||||
|
if (formNum === null) throw new Error('fillTableRow: no form found');
|
||||||
|
|
||||||
|
// Pre-resolve grid when table is specified
|
||||||
|
let gridSelector;
|
||||||
|
if (table) {
|
||||||
|
const resolved = await page.evaluate(resolveGridScript(formNum, table));
|
||||||
|
if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`);
|
||||||
|
gridSelector = resolved.gridSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Switch tab if requested
|
||||||
|
if (tab) {
|
||||||
|
await clickElement(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Resolve a { col: value } row filter to a numeric DOM-window index (mirrors
|
||||||
|
// clickElement). After this, `row` is a number and all downstream code/recursion
|
||||||
|
// works unchanged. Filter targets an EXISTING row — incompatible with `add`.
|
||||||
|
if (row != null && typeof row === 'object') {
|
||||||
|
row = await resolveRowIndexByFilter({ formNum, gridSelector, filter: row, gridName: table, scroll });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add new row if requested
|
||||||
|
let addedRowIdx = -1;
|
||||||
|
if (add) {
|
||||||
|
// Count rows before add — new row will be appended at this index
|
||||||
|
addedRowIdx = await page.evaluate(countGridRowsScript(gridSelector));
|
||||||
|
await clickElement('Добавить', { table });
|
||||||
|
// Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait
|
||||||
|
for (let aw = 0; aw < 6; aw++) {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
if (await isInputFocusedInGrid()) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Enter edit mode on existing row by dblclick
|
||||||
|
if (row != null) {
|
||||||
|
// Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right
|
||||||
|
const sortedKeys = await page.evaluate(
|
||||||
|
sortFieldKeysByColindexScript(gridSelector, Object.keys(fields).map(k => k.toLowerCase())));
|
||||||
|
if (sortedKeys) {
|
||||||
|
// Rebuild fields in sorted order
|
||||||
|
const sortedFields = {};
|
||||||
|
for (const kl of sortedKeys) {
|
||||||
|
const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl);
|
||||||
|
if (origKey) sortedFields[origKey] = fields[origKey];
|
||||||
|
}
|
||||||
|
// Add any keys not matched in header (preserve original order for those)
|
||||||
|
for (const k of Object.keys(fields)) {
|
||||||
|
if (!(k in sortedFields)) sortedFields[k] = fields[k];
|
||||||
|
}
|
||||||
|
fields = sortedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellCoords = await page.evaluate(
|
||||||
|
findCellCoordsByFieldsScript(gridSelector, row, Object.keys(fields).map(k => k.toLowerCase())));
|
||||||
|
|
||||||
|
if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`);
|
||||||
|
|
||||||
|
// Skip if cell already contains the desired value (single-field optimization)
|
||||||
|
const firstKey0 = Object.keys(fields)[0];
|
||||||
|
const rawFirstVal = fields[firstKey0];
|
||||||
|
const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === ''
|
||||||
|
? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal));
|
||||||
|
let firstFieldSkipped = false;
|
||||||
|
if (cellCoords.currentText && firstVal0 &&
|
||||||
|
cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) {
|
||||||
|
firstFieldSkipped = true;
|
||||||
|
if (Object.keys(fields).length === 1) {
|
||||||
|
return returnFormState({ filled: [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click first (tree grids enter edit on single click; dblclick toggles expand/collapse).
|
||||||
|
// Then escalate: dblclick → F4 if needed.
|
||||||
|
await page.mouse.click(cellCoords.x, cellCoords.y);
|
||||||
|
|
||||||
|
// Clear cell via Shift+F4 if value is empty
|
||||||
|
if (firstVal0 === '') {
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
// Check if click opened a selection form — close it first
|
||||||
|
let openedForm = await helperDetectNewForm(formNum);
|
||||||
|
if (openedForm !== null) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
} else {
|
||||||
|
// No form opened — need to enter edit mode first (dblclick), then close any form that opens
|
||||||
|
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
openedForm = await helperDetectNewForm(formNum);
|
||||||
|
if (openedForm !== null) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.keyboard.press('Shift+F4');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }];
|
||||||
|
// If more fields remain, process them on the same row
|
||||||
|
const remaining = { ...fields };
|
||||||
|
delete remaining[firstKey0];
|
||||||
|
if (Object.keys(remaining).length > 0) {
|
||||||
|
const more = await fillTableRow(remaining, { row, table });
|
||||||
|
results.push(...more.filled);
|
||||||
|
}
|
||||||
|
return returnFormState({ filled: results });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if clicked cell is a checkbox (toggle-on-click, no edit mode)
|
||||||
|
const checkboxInfo = await page.evaluate(findCheckboxAtPointScript(cellCoords.x, cellCoords.y));
|
||||||
|
if (checkboxInfo !== null) {
|
||||||
|
// Checkbox cell found — click directly on the checkbox icon (not cell center)
|
||||||
|
const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim());
|
||||||
|
if (checkboxInfo.checked !== desired) {
|
||||||
|
await page.mouse.click(checkboxInfo.x, checkboxInfo.y);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }];
|
||||||
|
await waitForStable(formNum);
|
||||||
|
// If more fields remain, process them on the same row
|
||||||
|
const remaining = { ...fields };
|
||||||
|
delete remaining[firstKey0];
|
||||||
|
if (Object.keys(remaining).length > 0) {
|
||||||
|
const more = await fillTableRow(remaining, { row, table });
|
||||||
|
results.push(...more.filled);
|
||||||
|
}
|
||||||
|
return returnFormState({ filled: results });
|
||||||
|
}
|
||||||
|
|
||||||
|
let inEdit = false;
|
||||||
|
let directEditForm = null;
|
||||||
|
for (let dw = 0; dw < 4; dw++) {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
inEdit = await isInputFocused();
|
||||||
|
if (inEdit) break;
|
||||||
|
directEditForm = await helperDetectNewForm(formNum);
|
||||||
|
if (directEditForm !== null) break;
|
||||||
|
}
|
||||||
|
// Click didn't enter edit — try dblclick (works for flat grids)
|
||||||
|
if (!inEdit && directEditForm === null) {
|
||||||
|
await page.mouse.dblclick(cellCoords.x, cellCoords.y);
|
||||||
|
for (let dw = 0; dw < 4; dw++) {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
inEdit = await isInputFocused();
|
||||||
|
if (inEdit) break;
|
||||||
|
directEditForm = await helperDetectNewForm(formNum);
|
||||||
|
if (directEditForm !== null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Still nothing — try F4 (opens selection for direct-edit cells)
|
||||||
|
if (!inEdit && directEditForm === null) {
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
for (let fw = 0; fw < 8; fw++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
inEdit = await isInputFocused();
|
||||||
|
if (inEdit) break;
|
||||||
|
directEditForm = await helperDetectNewForm(formNum);
|
||||||
|
if (directEditForm !== null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When click entered INPUT mode but no selection form yet — try F4 only for tree grids
|
||||||
|
// (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop)
|
||||||
|
if (inEdit && directEditForm === null) {
|
||||||
|
const isTreeGrid = await page.evaluate(isTreeGridScript(gridSelector));
|
||||||
|
if (isTreeGrid) {
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
for (let fw = 0; fw < 8; fw++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
directEditForm = await helperDetectNewForm(formNum);
|
||||||
|
if (directEditForm !== null) break;
|
||||||
|
}
|
||||||
|
// If F4 didn't open a selection form, fall through to Tab loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing).
|
||||||
|
// Handle each field by picking from selection form, then dblclick next cell.
|
||||||
|
if (directEditForm !== null) {
|
||||||
|
const pending = new Map();
|
||||||
|
for (const [key, val] of Object.entries(fields)) {
|
||||||
|
if (val && typeof val === 'object' && 'value' in val) {
|
||||||
|
pending.set(key, { value: String(val.value), type: val.type || null, filled: false });
|
||||||
|
} else {
|
||||||
|
pending.set(key, { value: String(val), type: null, filled: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Helper: handle type dialog + pick from selection form
|
||||||
|
async function directEditPick(openedForm, key, info) {
|
||||||
|
let selForm = openedForm;
|
||||||
|
// Check if opened form is a type selection dialog (composite type field)
|
||||||
|
if (await isTypeDialog(selForm)) {
|
||||||
|
if (info.type) {
|
||||||
|
await pickFromTypeDialog(selForm, info.type);
|
||||||
|
await waitForStable(selForm);
|
||||||
|
// After type selection, detect the actual selection form
|
||||||
|
selForm = await helperDetectNewForm(formNum);
|
||||||
|
if (selForm === null) {
|
||||||
|
return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No type given — treat as a choice cell: the value IS the list item
|
||||||
|
// ("Выбрать тип"). Pick it; if a value form follows, it was genuinely a
|
||||||
|
// composite-value cell that needs {value, type}.
|
||||||
|
try {
|
||||||
|
await pickFromTypeDialog(selForm, info.value);
|
||||||
|
} catch (e) {
|
||||||
|
return { field: key, ok: false, error: 'not_found', message: e.message };
|
||||||
|
}
|
||||||
|
await waitForStable(formNum);
|
||||||
|
const after = await helperDetectNewForm(formNum);
|
||||||
|
if (after !== null) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
return { field: key, ok: false, error: 'type_required', message: `Cell "${key}" expects { value, type }` };
|
||||||
|
}
|
||||||
|
return { field: key, ok: true, method: 'choice' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
|
||||||
|
return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, ok: false, error: pr.error, message: pr.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// First field: selection form is already open from the dblclick above
|
||||||
|
const firstKey = Object.keys(fields)[0];
|
||||||
|
const firstInfo = pending.get(firstKey);
|
||||||
|
if (firstFieldSkipped) {
|
||||||
|
firstInfo.filled = true;
|
||||||
|
results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText });
|
||||||
|
// Close the selection form that opened from the click
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await waitForStable(formNum);
|
||||||
|
} else {
|
||||||
|
const pickResult = await directEditPick(directEditForm, firstKey, firstInfo);
|
||||||
|
firstInfo.filled = true;
|
||||||
|
results.push(pickResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining fields: dblclick on each column cell individually
|
||||||
|
for (const [key, info] of pending) {
|
||||||
|
if (info.filled) continue;
|
||||||
|
// Find column for this key and dblclick on it
|
||||||
|
const nextCoords = await page.evaluate(findNextCellCoordsByKeyScript(gridSelector, row, key));
|
||||||
|
if (!nextCoords) {
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: key, ok: false, error: 'column_not_found', message: `Column for "${key}" not found` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip if cell already contains the desired value
|
||||||
|
if (nextCoords.currentText && info.value &&
|
||||||
|
nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) {
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await page.mouse.dblclick(nextCoords.x, nextCoords.y);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
// Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator
|
||||||
|
const inInputAfterDblclick = await isInputFocusedInGrid();
|
||||||
|
// Also check if a selection form already appeared
|
||||||
|
let selForm = await helperDetectNewForm(formNum);
|
||||||
|
if (selForm === null && inInputAfterDblclick) {
|
||||||
|
// Choice cell (bare _CB iCB) — editable value (text sticks) or pick-from-list
|
||||||
|
// (text rejected → F4 form). fillChoiceCell discriminates; row commit persists 'direct'.
|
||||||
|
const activeCell = await page.evaluate(readActiveGridCellScript());
|
||||||
|
if (activeCell.buttonKind === 'choice') {
|
||||||
|
const r = await fillChoiceCell(formNum, info.value, { type: info.type, fieldLabel: key });
|
||||||
|
info.filled = true;
|
||||||
|
results.push(r.ok
|
||||||
|
? { field: key, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
|
||||||
|
: { field: key, ok: false, error: r.error, message: r.message });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Plain text/numeric field — fill via clipboard paste
|
||||||
|
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
// Dismiss EDD autocomplete if it appeared
|
||||||
|
if (await isEddVisible()) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: key, ok: true, method: 'paste' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Poll for selection form (with F4 fallback if dblclick didn't open it)
|
||||||
|
if (selForm === null) {
|
||||||
|
for (let attempt = 0; attempt < 2 && selForm === null; attempt++) {
|
||||||
|
if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback
|
||||||
|
for (let sw = 0; sw < 6; sw++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
selForm = await helperDetectNewForm(formNum);
|
||||||
|
if (selForm !== null) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selForm === null) {
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: key, ok: false, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pr = await directEditPick(selForm, key, info);
|
||||||
|
info.filled = true;
|
||||||
|
results.push(pr);
|
||||||
|
}
|
||||||
|
// Commit the edit: click on a different row (Escape cancels in tree grids).
|
||||||
|
// Find the first visible row that is NOT the edited row and click it.
|
||||||
|
const commitCoords = await page.evaluate(findRowCommitClickCoordsScript(gridSelector, row));
|
||||||
|
if (commitCoords) {
|
||||||
|
await page.mouse.click(commitCoords.x, commitCoords.y);
|
||||||
|
} else {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
}
|
||||||
|
await waitForStable(formNum);
|
||||||
|
return returnFormState({ filled: results });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`);
|
||||||
|
} else {
|
||||||
|
// No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent)
|
||||||
|
const editCheck = await page.evaluate(getGridEditCheckScript());
|
||||||
|
|
||||||
|
if (!editCheck.inEdit) {
|
||||||
|
throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Prepare pending fields for fuzzy matching
|
||||||
|
const pending = new Map();
|
||||||
|
for (const [key, val] of Object.entries(fields)) {
|
||||||
|
if (val === null || val === undefined || val === '') {
|
||||||
|
pending.set(key, { value: '', type: null, filled: false });
|
||||||
|
} else if (val && typeof val === 'object' && 'value' in val) {
|
||||||
|
const innerVal = val.value;
|
||||||
|
pending.set(key, {
|
||||||
|
value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal),
|
||||||
|
type: val.type || null, filled: false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pending.set(key, { value: String(val), type: null, filled: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const MAX_ITER = 40;
|
||||||
|
let prevCellId = null;
|
||||||
|
let nonInputCount = 0;
|
||||||
|
let firstCellId = null;
|
||||||
|
|
||||||
|
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||||||
|
// Read focused element (INPUT or TEXTAREA inside grid = editable cell)
|
||||||
|
const cell = await page.evaluate(readActiveGridCellScript());
|
||||||
|
|
||||||
|
if (cell.tag !== 'INPUT' || !cell.fullName) {
|
||||||
|
// Not in an editable grid cell — Tab past (ERP has DIV focus between cells)
|
||||||
|
nonInputCount++;
|
||||||
|
// If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows
|
||||||
|
const onlyCheckboxLeft = [...pending.values()].every(p => p.filled ||
|
||||||
|
['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim()));
|
||||||
|
if (nonInputCount > 3 || onlyCheckboxLeft) break;
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nonInputCount = 0;
|
||||||
|
|
||||||
|
// Track first cell to detect wrap-around (Tab looped back to row start)
|
||||||
|
if (firstCellId === null) firstCellId = cell.id;
|
||||||
|
else if (cell.id === firstCellId) break; // wrapped around — all cells visited
|
||||||
|
|
||||||
|
// Stuck detection: same cell twice in a row → force Tab
|
||||||
|
if (cell.id === prevCellId) {
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
prevCellId = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prevCellId = cell.id;
|
||||||
|
|
||||||
|
// Fuzzy match cell name to user field: exact → suffix → includes → no-space includes
|
||||||
|
const cellLower = cell.fullName.toLowerCase();
|
||||||
|
let matchedKey = null;
|
||||||
|
for (const [key, info] of pending) {
|
||||||
|
if (info.filled) continue;
|
||||||
|
const kl = key.toLowerCase();
|
||||||
|
if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) {
|
||||||
|
matchedKey = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// CamelCase cell names have no spaces/dashes — try matching without spaces and dashes
|
||||||
|
const klNoSpace = kl.replace(/[\s\-]+/g, '');
|
||||||
|
if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) {
|
||||||
|
matchedKey = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: match by column header text (handles metadata typos in cell id)
|
||||||
|
if (!matchedKey && cell.headerText) {
|
||||||
|
const htLower = cell.headerText.toLowerCase();
|
||||||
|
for (const [key, info] of pending) {
|
||||||
|
if (info.filled) continue;
|
||||||
|
const kl = key.toLowerCase();
|
||||||
|
if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) {
|
||||||
|
matchedKey = key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedKey) {
|
||||||
|
// Skip this cell
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = pending.get(matchedKey);
|
||||||
|
const text = info.value;
|
||||||
|
|
||||||
|
// Clear cell if value is empty (Shift+F4 = native 1C clear)
|
||||||
|
if (text === '') {
|
||||||
|
await page.keyboard.press('Shift+F4');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' });
|
||||||
|
if ([...pending.values()].every(p => p.filled)) break;
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user specified a type, always clear and use type selection flow
|
||||||
|
if (info.type) {
|
||||||
|
await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('F4');
|
||||||
|
// Poll for type dialog form to appear
|
||||||
|
let typeForm = null;
|
||||||
|
for (let tw = 0; tw < 6; tw++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
typeForm = await helperDetectNewForm(formNum);
|
||||||
|
if (typeForm !== null) break;
|
||||||
|
}
|
||||||
|
if (typeForm !== null && await isTypeDialog(typeForm)) {
|
||||||
|
await pickFromTypeDialog(typeForm, info.type);
|
||||||
|
await waitForStable(typeForm);
|
||||||
|
// After type selection, check if a selection form opened (ref types)
|
||||||
|
const selForm = await helperDetectNewForm(formNum);
|
||||||
|
if (selForm === null) {
|
||||||
|
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||||||
|
let hasPopup = null;
|
||||||
|
for (let pw = 0; pw < 5; pw++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
hasPopup = await findOpenPopup();
|
||||||
|
if (hasPopup) break;
|
||||||
|
}
|
||||||
|
if (hasPopup) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
// Poll for popup to disappear
|
||||||
|
for (let dw = 0; dw < 4; dw++) {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
if (!(await findOpenPopup())) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure we are in an editable INPUT for this cell
|
||||||
|
const inInput = await isInputFocused({ allowTextarea: true });
|
||||||
|
if (!inInput) {
|
||||||
|
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
|
||||||
|
if (cellRect) {
|
||||||
|
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||||||
|
// Poll for INPUT focus
|
||||||
|
for (let fw = 0; fw < 4; fw++) {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
if (await isInputFocused({ allowTextarea: true })) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||||
|
info.filled = true;
|
||||||
|
results.push(pickResult.ok
|
||||||
|
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||||||
|
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: pickResult.error, message: pickResult.message });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// F4 opened something but not a type dialog — close and report
|
||||||
|
if (typeForm !== null) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: 'type_dialog_failed',
|
||||||
|
message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` });
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choice cell (_CB iCB): either an editable value cell (text sticks → direct input) or a
|
||||||
|
// pick-from-list cell (НачалоВыбора / РедактированиеТекста=Ложь → text rejected → F4 form).
|
||||||
|
// fillChoiceCell discriminates behaviorally; both kinds are indistinguishable in the DOM.
|
||||||
|
if (cell.buttonKind === 'choice') {
|
||||||
|
const r = await fillChoiceCell(formNum, text, { type: info.type, fieldLabel: matchedKey });
|
||||||
|
info.filled = true;
|
||||||
|
results.push(r.ok
|
||||||
|
? { field: matchedKey, cell: cell.fullName, ok: true, method: r.method, ...(r.value !== undefined ? { value: r.value } : {}) }
|
||||||
|
: { field: matchedKey, cell: cell.fullName, ok: false, error: r.error, message: r.message });
|
||||||
|
// 'direct' leaves text in the INPUT — caller's Tab (or end-of-row commit on the last field) persists it.
|
||||||
|
if ([...pending.values()].every(p => p.filled)) break;
|
||||||
|
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Fill this cell: clipboard paste (trusted event) ===
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await pasteText(text);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Check if paste was rejected (composite-type cell blocks text input until type is selected)
|
||||||
|
const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`);
|
||||||
|
if (!inputAfterPaste && text) {
|
||||||
|
// No type specified — can't fill this composite-type cell
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: 'type_required',
|
||||||
|
message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` });
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for EDD autocomplete (indicates reference field)
|
||||||
|
const edd = await readEdd();
|
||||||
|
const eddItems = edd.visible ? edd.items.map(i => i.name) : null;
|
||||||
|
|
||||||
|
if (eddItems && eddItems.length > 0) {
|
||||||
|
// Reference field with autocomplete — click best match
|
||||||
|
// Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...)
|
||||||
|
// but keep standalone enum values like "Создать" (no space/colon after)
|
||||||
|
const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i));
|
||||||
|
|
||||||
|
if (realItems.length > 0) {
|
||||||
|
const tgt = normYo(text.toLowerCase());
|
||||||
|
let pick = realItems.find(i =>
|
||||||
|
normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt);
|
||||||
|
if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt));
|
||||||
|
|
||||||
|
if (pick) {
|
||||||
|
// Click EDD item via dispatchEvent (bypasses div.surface overlay)
|
||||||
|
await clickEddItemViaDispatch(pick);
|
||||||
|
await waitForStable();
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: true,
|
||||||
|
method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') });
|
||||||
|
} else {
|
||||||
|
// EDD listed items but NONE matches the requested value. Do NOT blind-pick the
|
||||||
|
// first item — when the typed text has no hit, 1C still shows unrelated entries
|
||||||
|
// (recent/full list), so items[0] would silently write the wrong reference.
|
||||||
|
// Dismiss, clear the typed text, report not_found.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: 'not_found', message: `No match for "${text}" in autocomplete` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only "Создать:" items — value not found in autocomplete
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: 'not_found', message: `No match for "${text}"` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done? If so, don't Tab (avoids creating a new row after last cell)
|
||||||
|
if ([...pending.values()].every(p => p.filled)) break;
|
||||||
|
// Tab to move to next cell
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No EDD — press Tab to commit the value
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Check for "нет в списке" cloud popup (reference field, value not found)
|
||||||
|
const notInList = await page.evaluate(isNotInListCloudVisibleScript());
|
||||||
|
|
||||||
|
if (notInList) {
|
||||||
|
// Cloud has "Показать все" link — try to open selection form via it
|
||||||
|
const clickedShowAll = await page.evaluate(clickShowAllInNotInListCloudScript());
|
||||||
|
|
||||||
|
if (clickedShowAll) {
|
||||||
|
await waitForStable(formNum);
|
||||||
|
// Check if selection form opened
|
||||||
|
const selForm = await helperDetectNewForm(formNum, { strict: true });
|
||||||
|
|
||||||
|
if (selForm !== null) {
|
||||||
|
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||||
|
info.filled = true;
|
||||||
|
if (pickResult.ok) {
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Not found in selection form — fall through to clear + skip
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: pickResult.error, message: pickResult.message });
|
||||||
|
} else {
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: 'not_found', message: `Value "${text}" not in list` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: 'not_found', message: `Value "${text}" not in list` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1C won't let us Tab away from an invalid ref value.
|
||||||
|
// Must clear the field first, then Tab to move on.
|
||||||
|
// Escape dismisses the cloud; Ctrl+A + Delete clears the text.
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs)
|
||||||
|
const newForm = await helperDetectNewForm(formNum);
|
||||||
|
|
||||||
|
if (newForm !== null) {
|
||||||
|
if (await isTypeDialog(newForm)) {
|
||||||
|
// Composite-type cell — need type to proceed
|
||||||
|
if (info.type) {
|
||||||
|
await pickFromTypeDialog(newForm, info.type);
|
||||||
|
await waitForStable(newForm);
|
||||||
|
// After type selection, the actual selection form should open
|
||||||
|
const selForm = await helperDetectNewForm(formNum);
|
||||||
|
if (selForm === null) {
|
||||||
|
// Primitive type — poll for calculator/calendar popup or settle on INPUT
|
||||||
|
let hasPopup = null;
|
||||||
|
for (let pw = 0; pw < 5; pw++) {
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
hasPopup = await findOpenPopup();
|
||||||
|
if (hasPopup) break;
|
||||||
|
}
|
||||||
|
if (hasPopup) {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
for (let dw = 0; dw < 4; dw++) {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
if (!(await findOpenPopup())) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inInput = await isInputFocused({ allowTextarea: true });
|
||||||
|
if (!inInput) {
|
||||||
|
const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id));
|
||||||
|
if (cellRect) {
|
||||||
|
await page.mouse.dblclick(cellRect.x, cellRect.y);
|
||||||
|
for (let fw = 0; fw < 4; fw++) {
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
if (await isInputFocused({ allowTextarea: true })) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pasteText(text, { confirm: ['Control+a', 'Control+v'] });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum);
|
||||||
|
info.filled = true;
|
||||||
|
results.push(pickResult.ok
|
||||||
|
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type }
|
||||||
|
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: pickResult.error, message: pickResult.message });
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// No type specified — close dialog, clear cell, report error
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: 'type_required',
|
||||||
|
message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not a type dialog — normal selection form
|
||||||
|
const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum);
|
||||||
|
info.filled = true;
|
||||||
|
results.push(pickResult.ok
|
||||||
|
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
|
||||||
|
: { field: matchedKey, cell: cell.fullName, ok: false,
|
||||||
|
error: pickResult.error, message: pickResult.message });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain field — value committed via Tab
|
||||||
|
info.filled = true;
|
||||||
|
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' });
|
||||||
|
|
||||||
|
// All done?
|
||||||
|
if ([...pending.values()].every(p => p.filled)) break;
|
||||||
|
// Tab already pressed — we're on next cell
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the new row: click on the grid header to exit edit mode.
|
||||||
|
// Clicking a different data row would re-enter edit mode on that row.
|
||||||
|
// Without this commit click, the row stays in "uncommitted add" state
|
||||||
|
// and a subsequent Escape (e.g. from closeForm) would cancel the entire row.
|
||||||
|
const commitTarget = await page.evaluate(findGridHeadCenterCoordsScript(gridSelector));
|
||||||
|
if (commitTarget) {
|
||||||
|
await page.mouse.click(commitTarget.x, commitTarget.y);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
} else {
|
||||||
|
// Fallback: Tab out of the last cell to commit the row
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss any leftover error modals
|
||||||
|
const err = await checkForErrors();
|
||||||
|
if (err?.modal) {
|
||||||
|
try {
|
||||||
|
const btn = await page.$('a.press.pressDefault');
|
||||||
|
if (btn) { await btn.click(); await page.waitForTimeout(500); }
|
||||||
|
} catch { /* OK */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key);
|
||||||
|
|
||||||
|
// Retry unfilled checkbox fields via direct click (Tab skips checkbox cells)
|
||||||
|
if (notFilled.length > 0) {
|
||||||
|
const checkboxFields = {};
|
||||||
|
for (const key of notFilled) {
|
||||||
|
const val = String(pending.get(key).value).toLowerCase().trim();
|
||||||
|
if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) {
|
||||||
|
checkboxFields[key] = pending.get(key).value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(checkboxFields).length > 0) {
|
||||||
|
// Use row index: addedRowIdx (from add mode) or fallback to selected row
|
||||||
|
const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(getSelectedOrLastRowIndexScript(gridSelector))
|
||||||
|
);
|
||||||
|
if (currentRow >= 0) {
|
||||||
|
const more = await fillTableRow(checkboxFields, { row: currentRow, table });
|
||||||
|
results.push(...more.filled);
|
||||||
|
for (const key of Object.keys(checkboxFields)) {
|
||||||
|
const idx = notFilled.indexOf(key);
|
||||||
|
if (idx >= 0) notFilled.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extras = { filled: results };
|
||||||
|
if (notFilled.length > 0) extras.notFilled = notFilled;
|
||||||
|
return returnFormState(extras);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.startsWith('fillTableRow:')) throw e;
|
||||||
|
throw new Error(`fillTableRow: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,378 +1,65 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// web-test run v1.3 — CLI runner for 1C web client automation
|
// web-test run v1.18 — CLI entry-point (распилено по cli/)
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
/**
|
/**
|
||||||
* CLI runner for 1C web client automation.
|
* CLI runner for 1C web client automation.
|
||||||
*
|
*
|
||||||
* Architecture: `start` launches browser + HTTP server in one process.
|
* Architecture: `start` launches browser + HTTP server in one process.
|
||||||
* `exec`, `shot`, `stop` send requests to the running server.
|
* `exec`, `shot`, `stop` send requests to the running server.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node src/run.mjs start <url> — launch browser, connect to 1C, serve requests
|
* node src/run.mjs start <url> — launch browser, connect to 1C, serve requests
|
||||||
* node src/run.mjs run <url> <file|-> — autonomous: connect, execute script, disconnect
|
* node src/run.mjs run <url> <file|-> — autonomous: connect, execute script, disconnect
|
||||||
* node src/run.mjs exec <file|-> — run script against existing session
|
* node src/run.mjs exec <file|-> — run script against existing session
|
||||||
* node src/run.mjs shot [file] — take screenshot
|
* node src/run.mjs shot [file] — take screenshot
|
||||||
* node src/run.mjs stop — logout + close browser
|
* node src/run.mjs stop — logout + close browser
|
||||||
* node src/run.mjs status — check session
|
* node src/run.mjs status — check session
|
||||||
*/
|
* node src/run.mjs test <dir|file>... [--url] — run regression tests
|
||||||
import http from 'http';
|
*
|
||||||
import * as browser from './browser.mjs';
|
* Внутренности живут в cli/: util, session, exec-context, server,
|
||||||
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
* commands/{start,run,exec,shot,stop,status,test}, test-runner/*.
|
||||||
import { resolve, dirname } from 'path';
|
*/
|
||||||
import { fileURLToPath } from 'url';
|
import * as browser from './browser.mjs';
|
||||||
|
import { usage } from './cli/util.mjs';
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
import { cmdStart } from './cli/commands/start.mjs';
|
||||||
const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json');
|
import { cmdRun } from './cli/commands/run.mjs';
|
||||||
|
import { cmdExec } from './cli/commands/exec.mjs';
|
||||||
const [,, cmd, ...rawArgs] = process.argv;
|
import { cmdShot } from './cli/commands/shot.mjs';
|
||||||
const flags = { noRecord: rawArgs.includes('--no-record') };
|
import { cmdStop } from './cli/commands/stop.mjs';
|
||||||
const args = rawArgs.filter(a => !a.startsWith('--'));
|
import { cmdStatus } from './cli/commands/status.mjs';
|
||||||
|
import { cmdTest } from './cli/commands/test.mjs';
|
||||||
switch (cmd) {
|
|
||||||
case 'start': await cmdStart(args[0]); break;
|
const [,, cmd, ...rawArgs] = process.argv;
|
||||||
case 'run': await cmdRun(args[0], args[1]); break;
|
const flags = {
|
||||||
case 'exec': await cmdExec(args[0], flags); break;
|
noRecord: rawArgs.includes('--no-record'),
|
||||||
case 'shot': await cmdShot(args[0]); break;
|
execTimeoutMs: parseExecTimeoutMs(rawArgs),
|
||||||
case 'stop': await cmdStop(); break;
|
};
|
||||||
case 'status': cmdStatus(); break;
|
const args = rawArgs.filter(a => !a.startsWith('--'));
|
||||||
default: usage();
|
|
||||||
}
|
// Clipboard preservation: default ON. Disabled by --no-preserve-clipboard CLI flag
|
||||||
|
// or WEB_TEST_PRESERVE_CLIPBOARD=0 env. cmdTest may further disable via config.
|
||||||
|
const preserveClipboard = !rawArgs.includes('--no-preserve-clipboard')
|
||||||
// ============================================================
|
&& process.env.WEB_TEST_PRESERVE_CLIPBOARD !== '0';
|
||||||
// start: launch browser + HTTP server
|
browser.setPreserveClipboard(preserveClipboard);
|
||||||
// ============================================================
|
|
||||||
|
function parseExecTimeoutMs(argv) {
|
||||||
async function cmdStart(url) {
|
const DEFAULT_MS = 30 * 60 * 1000;
|
||||||
if (!url) die('Usage: node src/run.mjs start <url>');
|
const flagMs = argv.find(a => a.startsWith('--timeout='));
|
||||||
|
if (flagMs) return Math.max(1, Number(flagMs.slice('--timeout='.length))) || DEFAULT_MS;
|
||||||
// Connect to 1C
|
const flagMin = argv.find(a => a.startsWith('--timeout-min='));
|
||||||
const state = await browser.connect(url);
|
if (flagMin) return Math.max(1, Number(flagMin.slice('--timeout-min='.length))) * 60 * 1000 || DEFAULT_MS;
|
||||||
|
const env = process.env.WEB_TEST_EXEC_TIMEOUT_MS;
|
||||||
// Start HTTP server for exec/shot/stop
|
if (env) return Math.max(1, Number(env)) || DEFAULT_MS;
|
||||||
const httpServer = http.createServer(handleRequest);
|
return DEFAULT_MS;
|
||||||
httpServer.listen(0, '127.0.0.1', () => {
|
}
|
||||||
const port = httpServer.address().port;
|
|
||||||
const session = {
|
switch (cmd) {
|
||||||
port,
|
case 'start': await cmdStart(args[0]); break;
|
||||||
url,
|
case 'run': await cmdRun(args[0], args[1]); break;
|
||||||
pid: process.pid,
|
case 'exec': await cmdExec(args[0], flags); break;
|
||||||
startedAt: new Date().toISOString()
|
case 'shot': await cmdShot(args[0]); break;
|
||||||
};
|
case 'stop': await cmdStop(); break;
|
||||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
|
case 'status': cmdStatus(); break;
|
||||||
out({ ok: true, message: 'Browser ready', port, ...state });
|
case 'test': await cmdTest(rawArgs); break;
|
||||||
});
|
default: usage();
|
||||||
|
}
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
await browser.disconnect();
|
|
||||||
cleanup();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRequest(req, res) {
|
|
||||||
try {
|
|
||||||
if (req.method === 'POST' && req.url === '/exec') {
|
|
||||||
const code = await readBody(req);
|
|
||||||
const noRecord = req.headers['x-no-record'] === '1';
|
|
||||||
const result = await executeScript(code, { noRecord });
|
|
||||||
json(res, result);
|
|
||||||
|
|
||||||
} else if (req.method === 'GET' && req.url === '/shot') {
|
|
||||||
const png = await browser.screenshot();
|
|
||||||
res.writeHead(200, { 'Content-Type': 'image/png' });
|
|
||||||
res.end(png);
|
|
||||||
|
|
||||||
} else if (req.method === 'POST' && req.url === '/stop') {
|
|
||||||
json(res, { ok: true, message: 'Stopping' });
|
|
||||||
await browser.disconnect();
|
|
||||||
cleanup();
|
|
||||||
process.exit(0);
|
|
||||||
|
|
||||||
} else if (req.method === 'GET' && req.url === '/status') {
|
|
||||||
json(res, { ok: true, connected: browser.isConnected() });
|
|
||||||
|
|
||||||
} else {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end('Not found');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
json(res, { ok: false, error: e.message }, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeScript(code, { noRecord } = {}) {
|
|
||||||
const output = [];
|
|
||||||
const origLog = console.log;
|
|
||||||
const origErr = console.error;
|
|
||||||
console.log = (...a) => output.push(a.map(String).join(' '));
|
|
||||||
console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' '));
|
|
||||||
|
|
||||||
const t0 = Date.now();
|
|
||||||
try {
|
|
||||||
// Build sandbox: all browser.mjs exports + useful Node globals
|
|
||||||
const exports = {};
|
|
||||||
for (const [k, v] of Object.entries(browser)) {
|
|
||||||
if (k !== 'default') exports[k] = v;
|
|
||||||
}
|
|
||||||
exports.writeFileSync = writeFileSync;
|
|
||||||
exports.readFileSync = readFileSync;
|
|
||||||
|
|
||||||
// --no-record: stub recording/narration functions to return safe defaults
|
|
||||||
if (noRecord) {
|
|
||||||
const noop = async () => {};
|
|
||||||
exports.startRecording = noop;
|
|
||||||
exports.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
|
|
||||||
exports.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
|
|
||||||
for (const fn of ['showCaption', 'hideCaption']) {
|
|
||||||
exports[fn] = noop;
|
|
||||||
}
|
|
||||||
exports.isRecording = () => false;
|
|
||||||
exports.getCaptions = () => [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap action functions to auto-detect 1C errors (modal, balloon)
|
|
||||||
// and stop execution immediately with diagnostic info
|
|
||||||
const ACTION_FNS = [
|
|
||||||
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
|
|
||||||
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
|
|
||||||
'closeForm', 'filterList', 'unfilterList'
|
|
||||||
];
|
|
||||||
for (const name of ACTION_FNS) {
|
|
||||||
if (typeof exports[name] !== 'function') continue;
|
|
||||||
const orig = exports[name];
|
|
||||||
exports[name] = async (...args) => {
|
|
||||||
const result = await orig(...args);
|
|
||||||
const errors = result?.errors;
|
|
||||||
if (errors?.modal || errors?.balloon) {
|
|
||||||
// Screenshot while the error modal is still visible (before fetchErrorStack closes it)
|
|
||||||
let errorShot;
|
|
||||||
try {
|
|
||||||
const png = await exports.screenshot();
|
|
||||||
errorShot = resolve(__dirname, '..', 'error-shot.png');
|
|
||||||
writeFileSync(errorShot, png);
|
|
||||||
} catch {}
|
|
||||||
// Try to fetch call stack for modal errors before throwing
|
|
||||||
let stack = null;
|
|
||||||
if (errors?.modal && typeof exports.fetchErrorStack === 'function') {
|
|
||||||
try {
|
|
||||||
stack = await exports.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
|
|
||||||
} catch { /* don't fail if stack fetch fails */ }
|
|
||||||
}
|
|
||||||
const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
|
|
||||||
const err = new Error(msg);
|
|
||||||
err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize Windows backslash paths to prevent JS parse errors
|
|
||||||
// (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence")
|
|
||||||
code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/'));
|
|
||||||
|
|
||||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
||||||
const fn = new AsyncFunction(...Object.keys(exports), code);
|
|
||||||
await fn(...Object.values(exports));
|
|
||||||
|
|
||||||
console.log = origLog;
|
|
||||||
console.error = origErr;
|
|
||||||
return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) };
|
|
||||||
} catch (e) {
|
|
||||||
console.log = origLog;
|
|
||||||
console.error = origErr;
|
|
||||||
|
|
||||||
// Auto-stop recording if active (prevents "Already recording" on next exec)
|
|
||||||
if (browser.isRecording()) {
|
|
||||||
try { await browser.stopRecording(); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error screenshot (skip if already taken before fetchErrorStack closed the modal)
|
|
||||||
let shotFile = e.onecError?.screenshot;
|
|
||||||
if (!shotFile) {
|
|
||||||
try {
|
|
||||||
const png = await browser.screenshot();
|
|
||||||
shotFile = resolve(__dirname, '..', 'error-shot.png');
|
|
||||||
writeFileSync(shotFile, png);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) };
|
|
||||||
|
|
||||||
// Enrich with 1C error context if available
|
|
||||||
if (e.onecError) {
|
|
||||||
result.step = e.onecError.step;
|
|
||||||
result.stepArgs = e.onecError.args;
|
|
||||||
result.onecErrors = e.onecError.errors;
|
|
||||||
result.formState = e.onecError.formState;
|
|
||||||
if (e.onecError.stack) result.stack = e.onecError.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// run: autonomous connect → execute → disconnect (no server)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
async function cmdRun(url, fileOrDash) {
|
|
||||||
if (!url || !fileOrDash) die('Usage: node src/run.mjs run <url> <file|->');
|
|
||||||
|
|
||||||
const code = fileOrDash === '-'
|
|
||||||
? await readStdin()
|
|
||||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
|
||||||
|
|
||||||
await browser.connect(url);
|
|
||||||
const result = await executeScript(code);
|
|
||||||
await browser.disconnect();
|
|
||||||
|
|
||||||
out(result);
|
|
||||||
if (!result.ok) process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// exec: send script to running server
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
async function cmdExec(fileOrDash, flags = {}) {
|
|
||||||
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|-> [--no-record]');
|
|
||||||
|
|
||||||
let code = fileOrDash === '-'
|
|
||||||
? await readStdin()
|
|
||||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
|
||||||
|
|
||||||
const sess = loadSession();
|
|
||||||
const headers = {};
|
|
||||||
if (flags.noRecord) headers['x-no-record'] = '1';
|
|
||||||
const result = await new Promise((resolve, reject) => {
|
|
||||||
const req = http.request({
|
|
||||||
hostname: '127.0.0.1', port: sess.port, path: '/exec',
|
|
||||||
method: 'POST', timeout: 30 * 60 * 1000, headers,
|
|
||||||
}, res => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', chunk => data += chunk);
|
|
||||||
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(data)); } });
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.on('timeout', () => { req.destroy(new Error('Exec timeout (10 min)')); });
|
|
||||||
req.write(code);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
out(result);
|
|
||||||
if (!result.ok) process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// shot: take screenshot via server
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
async function cmdShot(file) {
|
|
||||||
const sess = loadSession();
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`);
|
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.text();
|
|
||||||
die(`Screenshot failed: ${err}`);
|
|
||||||
}
|
|
||||||
const buf = Buffer.from(await resp.arrayBuffer());
|
|
||||||
const outFile = file || 'shot.png';
|
|
||||||
writeFileSync(outFile, buf);
|
|
||||||
out({ ok: true, file: outFile });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// stop: send stop to server
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
async function cmdStop() {
|
|
||||||
const sess = loadSession();
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' });
|
|
||||||
const result = await resp.json();
|
|
||||||
out(result);
|
|
||||||
} catch {
|
|
||||||
// Server may have already exited before responding
|
|
||||||
out({ ok: true, message: 'Stopped' });
|
|
||||||
}
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// status: check session
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function cmdStatus() {
|
|
||||||
if (!existsSync(SESSION_FILE)) {
|
|
||||||
out({ ok: false, message: 'No active session' });
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
|
||||||
out({ ok: true, ...sess });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// helpers
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
function loadSession() {
|
|
||||||
if (!existsSync(SESSION_FILE)) {
|
|
||||||
die('No active session. Run: node src/run.mjs start <url>');
|
|
||||||
}
|
|
||||||
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup() {
|
|
||||||
try { unlinkSync(SESSION_FILE); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readBody(req) {
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of req) chunks.push(chunk);
|
|
||||||
return Buffer.concat(chunks).toString('utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readStdin() {
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
||||||
return Buffer.concat(chunks).toString('utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function elapsed(t0) {
|
|
||||||
return Math.round((Date.now() - t0) / 100) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
function json(res, obj, status = 200) {
|
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(obj, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
function out(obj) {
|
|
||||||
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function die(msg) {
|
|
||||||
process.stderr.write(msg + '\n');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage() {
|
|
||||||
die(`Usage: node src/run.mjs <command> [args]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
start <url> Launch browser and connect to 1C web client
|
|
||||||
run <url> <file|-> Autonomous: connect, execute script, disconnect
|
|
||||||
exec <file|-> [options] Execute script (file path or - for stdin)
|
|
||||||
shot [file] Take screenshot (default: shot.png)
|
|
||||||
stop Logout and close browser
|
|
||||||
status Check session status
|
|
||||||
|
|
||||||
Options for exec:
|
|
||||||
--no-record Skip video recording (record() becomes no-op)`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -49,3 +49,4 @@ __pycache__/
|
|||||||
.opencode/
|
.opencode/
|
||||||
.roo/
|
.roo/
|
||||||
.windsurf/
|
.windsurf/
|
||||||
|
debug-templates.txt
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ python tools/cc-1c-skills/scripts/switch.py
|
|||||||
| Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) |
|
| Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) |
|
||||||
| Тестирование (Web) | `/web-test` | Взаимодействие с веб-клиентом 1С — навигация, формы, таблицы, отчёты, тестирование | [Подробнее](docs/web-test-guide.md) |
|
| Тестирование (Web) | `/web-test` | Взаимодействие с веб-клиентом 1С — навигация, формы, таблицы, отчёты, тестирование | [Подробнее](docs/web-test-guide.md) |
|
||||||
| Запись видео (Web) | `/web-test` | Запись видеоинструкций с субтитрами, подсветкой и TTS-озвучкой | [Подробнее](docs/web-test-recording-guide.md) |
|
| Запись видео (Web) | `/web-test` | Запись видеоинструкций с субтитрами, подсветкой и TTS-озвучкой | [Подробнее](docs/web-test-recording-guide.md) |
|
||||||
|
| Регресс прикладного решения (Web) | `/web-test` | Автоматический регресс конфигурации: тесты, проверки, отчёты, прогон после правок | [Подробнее](docs/web-test-regression-guide.md) |
|
||||||
| Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — |
|
| Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — |
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
@@ -255,6 +256,7 @@ docs/
|
|||||||
├── web-guide.md # Гайд: веб-публикация через Apache
|
├── web-guide.md # Гайд: веб-публикация через Apache
|
||||||
├── web-test-guide.md # Гайд: тестирование через веб-клиент
|
├── web-test-guide.md # Гайд: тестирование через веб-клиент
|
||||||
├── web-test-recording-guide.md # Гайд: запись видеоинструкций
|
├── web-test-recording-guide.md # Гайд: запись видеоинструкций
|
||||||
|
├── web-test-regression-guide.md # Гайд: регресс прикладного решения
|
||||||
├── 1c-epf-spec.md # Спецификация XML-формата (EPF)
|
├── 1c-epf-spec.md # Спецификация XML-формата (EPF)
|
||||||
├── 1c-erf-spec.md # Спецификация XML-формата (ERF)
|
├── 1c-erf-spec.md # Спецификация XML-формата (ERF)
|
||||||
├── 1c-form-spec.md # Спецификация управляемых форм
|
├── 1c-form-spec.md # Спецификация управляемых форм
|
||||||
|
|||||||
@@ -530,6 +530,26 @@ DataCompositionSchema
|
|||||||
|
|
||||||
Стандартные варианты периодов (`v8:StandardPeriodVariant`): `Custom`, `Today`, `ThisWeek`, `ThisMonth`, `ThisQuarter`, `ThisYear`, `LastMonth`, `LastQuarter`, `LastYear` и др.
|
Стандартные варианты периодов (`v8:StandardPeriodVariant`): `Custom`, `Today`, `ThisWeek`, `ThisMonth`, `ThisQuarter`, `ThisYear`, `LastMonth`, `LastQuarter`, `LastYear` и др.
|
||||||
|
|
||||||
|
#### Значение-список (несколько значений по умолчанию)
|
||||||
|
|
||||||
|
Значением параметра может быть список — несколько элементов `<value>` подряд внутри
|
||||||
|
`<parameter>`, при `<valueListAllowed>true</valueListAllowed>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<parameter>
|
||||||
|
<name>ВидыСубконто</name>
|
||||||
|
<valueType>
|
||||||
|
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные</v8:Type>
|
||||||
|
</valueType>
|
||||||
|
<value xsi:type="dcscor:DesignTimeValue">ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты</value>
|
||||||
|
<value xsi:type="dcscor:DesignTimeValue">ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры</value>
|
||||||
|
<useRestriction>true</useRestriction>
|
||||||
|
<valueListAllowed>true</valueListAllowed>
|
||||||
|
</parameter>
|
||||||
|
```
|
||||||
|
|
||||||
|
Порядок элементов: `name, title, valueType, value*, useRestriction, …, valueListAllowed`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Макеты областей (template)
|
## 9. Макеты областей (template)
|
||||||
|
|||||||
@@ -355,6 +355,18 @@ Pages поддерживает `pagesRepresentation`: `None`, `TabsOnTop`, `Tabs
|
|||||||
{ "picField": "Фото", "path": "Фотография" }
|
{ "picField": "Фото", "path": "Фотография" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для поля, привязанного к булеву/числу (иконка-индикатор в колонке), задайте картинку значения через `valuesPicture` — без неё иконка не отрисуется:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "picField": "Картинка", "path": "Таблица.Картинка",
|
||||||
|
"valuesPicture": "StdPicture.Favorites", "loadTransparent": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Свойство | Тип | Описание |
|
||||||
|
|----------|-----|----------|
|
||||||
|
| `valuesPicture` | string | Ссылка на картинку значения (`StdPicture.*`, `CommonPicture.*`) |
|
||||||
|
| `loadTransparent` | bool | Скрыть кадр «нет значения». Выводится только при `true` |
|
||||||
|
|
||||||
#### calendar — CalendarField
|
#### calendar — CalendarField
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+413
-46
@@ -124,7 +124,8 @@
|
|||||||
"Организация: CatalogRef.Организации @dimension",
|
"Организация: CatalogRef.Организации @dimension",
|
||||||
"Служебное: string #noFilter #noOrder",
|
"Служебное: string #noFilter #noOrder",
|
||||||
"Счёт: CatalogRef.Хозрасчетный @account",
|
"Счёт: CatalogRef.Хозрасчетный @account",
|
||||||
"Сумма: decimal(15,2) @balance"
|
"Сумма: decimal(15,2) @balance",
|
||||||
|
"СуммаНач: decimal(15,2) @balance balanceGroupName=Сумма balanceType=OpeningBalance"
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -140,15 +141,24 @@
|
|||||||
"restrict": ["noFilter", "noGroup"],
|
"restrict": ["noFilter", "noGroup"],
|
||||||
"attrRestrict": ["noFilter"],
|
"attrRestrict": ["noFilter"],
|
||||||
"appearance": { "Формат": "ЧДЦ=2" },
|
"appearance": { "Формат": "ЧДЦ=2" },
|
||||||
"presentationExpression": "Формат(Сумма, \"ЧДЦ=2\")"
|
"presentationExpression": "Формат(Сумма, \"ЧДЦ=2\")",
|
||||||
|
"orderExpression": { "expression": "ЕстьNULL(Поле.Порядок, 10000)", "orderType": "Asc", "autoOrder": false },
|
||||||
|
// или массив (если на поле несколько <orderExpression> для multi-sort fallback):
|
||||||
|
// "orderExpression": [{...}, {...}]
|
||||||
|
"availableValues": [
|
||||||
|
{ "value": 1, "presentation": { "ru": "Доход", "en": "Income" } },
|
||||||
|
{ "value": 2, "presentation": { "ru": "Расход", "en": "Expense" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`availableValues` — список допустимых значений поля с (опциональной multilang) подписью. Типы значений автоопределяются (`bool`/`decimal`/`dateTime`/`string`); можно указать `valueType` явно. Аналогичное поле существует на `parameters` — см. раздел 6.
|
||||||
|
|
||||||
### Парсинг shorthand
|
### Парсинг shorthand
|
||||||
|
|
||||||
1. Разделить по пробелам; найти `@`-роли и `#`-ограничения
|
1. Извлечь `@`-роли (regex `@(\w+)`), `#`-ограничения (`#(\w+)`), KV-пары роли (`(\w+)=(\S+)`)
|
||||||
2. Остаток до первого `:` — `dataPath` (и `field` по умолчанию)
|
2. Остаток до первого `:` — `dataPath` (и `field` по умолчанию)
|
||||||
3. После `:` до `@`/`#` — тип
|
3. После `:` — тип
|
||||||
|
|
||||||
### Типы
|
### Типы
|
||||||
|
|
||||||
@@ -166,9 +176,12 @@
|
|||||||
| `EnumRef.XXX` | `d5p1:EnumRef.XXX` | inline xmlns:d5p1 |
|
| `EnumRef.XXX` | `d5p1:EnumRef.XXX` | inline xmlns:d5p1 |
|
||||||
| `ChartOfAccountsRef.XXX` | `d5p1:ChartOfAccountsRef.XXX` | inline xmlns:d5p1 |
|
| `ChartOfAccountsRef.XXX` | `d5p1:ChartOfAccountsRef.XXX` | inline xmlns:d5p1 |
|
||||||
| `StandardPeriod` | `v8:StandardPeriod` | — |
|
| `StandardPeriod` | `v8:StandardPeriod` | — |
|
||||||
|
| `DocumentRef` (без `.XXX`) | `<v8:TypeSet xmlns:d5p1=...>d5p1:DocumentRef</v8:TypeSet>` | композитный тип-набор (все ссылки указанного класса) |
|
||||||
|
|
||||||
> **Ссылочные типы** (`CatalogRef.XXX`, `DocumentRef.XXX` и др.) эмитируются с inline namespace declaration: `<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.XXX</v8:Type>`. Использование префикса `cfg:` вместо `d5p1:` с объявлением namespace приводит к ошибке XDTO. Сборка EPF со ссылочными типами требует базу с соответствующей конфигурацией (не пустую).
|
> **Ссылочные типы** (`CatalogRef.XXX`, `DocumentRef.XXX` и др.) эмитируются с inline namespace declaration: `<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.XXX</v8:Type>`. Использование префикса `cfg:` вместо `d5p1:` с объявлением namespace приводит к ошибке XDTO. Сборка EPF со ссылочными типами требует базу с соответствующей конфигурацией (не пустую).
|
||||||
|
|
||||||
|
> **TypeSet (тип-набор)** — голое имя без точки (`CatalogRef`, `DocumentRef`, `EnumRef`, `ChartOfAccountsRef`, `ChartOfCharacteristicTypesRef`, `ChartOfCalculationTypesRef`, `BusinessProcessRef`, `TaskRef`, `ExchangePlanRef`, `InformationRegisterRef`, `AnyRef`) — указывает на **все** ссылки этого класса конфигурации (а не на конкретный объект). Эмитится как `<v8:TypeSet>` вместо `<v8:Type>`. Используется в параметрах типа «исключаемые документы» и подобных.
|
||||||
|
|
||||||
### Синонимы типов
|
### Синонимы типов
|
||||||
|
|
||||||
Все имена типов регистронезависимые. Поддерживаются русские и альтернативные имена:
|
Все имена типов регистронезависимые. Поддерживаются русские и альтернативные имена:
|
||||||
@@ -192,22 +205,36 @@
|
|||||||
|
|
||||||
### Роли
|
### Роли
|
||||||
|
|
||||||
| DSL shorthand | Объектная форма | XML |
|
Принимаются четыре формы:
|
||||||
|---------------|----------------|-----|
|
|
||||||
| `@dimension` | `"role": "dimension"` или `{"dimension": true}` | `<dcscom:dimension>true</dcscom:dimension>` |
|
|
||||||
| `@account` | `"role": "account"` или `{"account": true}` | `<dcscom:account>true</dcscom:account>` |
|
|
||||||
| `@balance` | `"role": "balance"` или `{"balance": true}` | `<dcscom:balance>true</dcscom:balance>` |
|
|
||||||
| `@period` | `"role": "period"` или `{"period": true}` | `<dcscom:periodNumber>1</dcscom:periodNumber>` + `<dcscom:periodType>Main</dcscom:periodType>` |
|
|
||||||
|
|
||||||
Объектная форма с доп. полями:
|
|
||||||
```json
|
```json
|
||||||
"role": {
|
"role": "dimension" // одиночный флаг
|
||||||
"account": true,
|
"role": ["dimension", "required"] // массив флагов
|
||||||
"accountTypeExpression": "Счёт.ВидСчёта",
|
"role": "balance balanceGroupName=Сумма balanceType=OpeningBalance" // shorthand
|
||||||
"balanceGroup": "/Остатки"
|
"role": { "balance": true, "balanceGroupName": "Сумма", "balanceType": "OpeningBalance" }
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Shorthand-формат может быть встроен прямо в shorthand поля:
|
||||||
|
|
||||||
|
```
|
||||||
|
"Сумма: decimal(15,2) @balance balanceGroupName=Сумма balanceType=OpeningBalance"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Парсинг shorthand**: `@(\w+)` → boolean флаги; `(\w+)=(\S+)` → строковые KV; остаток — `dataPath[: type]`.
|
||||||
|
|
||||||
|
**Поддерживаемые ключи**:
|
||||||
|
|
||||||
|
| Категория | Ключи |
|
||||||
|
|-----------|-------|
|
||||||
|
| `@`-флаги (boolean) | `@dimension`, `@account`, `@balance`, `@period`, `@required`, `@autoOrder`, `@ignoreNullValues` |
|
||||||
|
| Строковые KV | `balanceGroupName`, `balanceType` (`OpeningBalance`/`ClosingBalance`), `parentDimension`, `accountTypeExpression`, `expression`, `orderType` (`Asc`/`Desc`), `periodNumber`, `periodType` |
|
||||||
|
|
||||||
|
Whitelist'а нет — любой `<dcscom:KEY>` принимается; перечисленные — типичные. `@period` — sugar для `periodNumber=1` + `periodType=Main` (можно переопределить явно).
|
||||||
|
|
||||||
|
**XML-выход**: `<dcscom:KEY>true</dcscom:KEY>` для флагов; `<dcscom:KEY>VALUE</dcscom:KEY>` для KV.
|
||||||
|
|
||||||
|
> Устаревший ключ `balanceGroup` в object-форме принимается как alias для `balanceGroupName` (имя элемента в реальном XML — `balanceGroupName`).
|
||||||
|
|
||||||
### Ограничения
|
### Ограничения
|
||||||
|
|
||||||
| DSL shorthand | Объектная форма | XML useRestriction |
|
| DSL shorthand | Объектная форма | XML useRestriction |
|
||||||
@@ -312,6 +339,12 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
|
|
||||||
**Парсинг:** `"A: T = V"` → `name=A`, `type=T`, `value=V`. Значение `LastMonth` и другие варианты периодов → `v8:StandardPeriod` с `v8:variant`.
|
**Парсинг:** `"A: T = V"` → `name=A`, `type=T`, `value=V`. Значение `LastMonth` и другие варианты периодов → `v8:StandardPeriod` с `v8:variant`.
|
||||||
|
|
||||||
|
`<default>` может быть **списком** — несколько значений через запятую (с `'...'` для запятой внутри значения). В этом случае эмитятся несколько `<value>`, а `valueListAllowed=true` выводится автоматически (явный `@valueList` не нужен). Эквивалент объектной формы `"value": [ ... ]`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"parameters": ["Виды: ChartOfCharacteristicTypesRef.ВидыСубконтоХозрасчетные = ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Контрагенты, ПланВидовХарактеристик.ВидыСубконтоХозрасчетные.Договоры"]
|
||||||
|
```
|
||||||
|
|
||||||
### @autoDates
|
### @autoDates
|
||||||
|
|
||||||
Флаг `@autoDates` в shorthand параметра автоматически генерирует два дополнительных параметра:
|
Флаг `@autoDates` в shorthand параметра автоматически генерирует два дополнительных параметра:
|
||||||
@@ -371,7 +404,7 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
| `name` | Имя параметра |
|
| `name` | Имя параметра |
|
||||||
| `title` | Заголовок (умолч. = name) |
|
| `title` | Заголовок (умолч. = name) |
|
||||||
| `type` | Тип (см. таблицу типов) |
|
| `type` | Тип (см. таблицу типов) |
|
||||||
| `value` | Значение по умолчанию |
|
| `value` | Значение по умолчанию (скаляр; для `valueListAllowed=true` — массив значений по умолчанию: `[ "ПланСчетов.Хозрасчетный.X", "...Y", "...Z" ]`) |
|
||||||
| `expression` | Выражение для вычисления |
|
| `expression` | Выражение для вычисления |
|
||||||
| `availableAsField` | `false` — скрыть из полей |
|
| `availableAsField` | `false` — скрыть из полей |
|
||||||
| `valueListAllowed` | `true` — разрешить список значений |
|
| `valueListAllowed` | `true` — разрешить список значений |
|
||||||
@@ -379,7 +412,9 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
| `useRestriction` | `true` — скрыть от пользователя |
|
| `useRestriction` | `true` — скрыть от пользователя |
|
||||||
| `use` | `"Always"`, `"Auto"` |
|
| `use` | `"Always"`, `"Auto"` |
|
||||||
| `denyIncompleteValues` | `true` — запретить произвольные значения (только из availableValues) |
|
| `denyIncompleteValues` | `true` — запретить произвольные значения (только из availableValues) |
|
||||||
| `availableValues` | Массив `[{value, presentation}]` — допустимые значения с представлениями |
|
| `availableValues` | Массив `[{value, presentation}]` — допустимые значения с представлениями. Типы (bool/int/decimal) сохраняются нативно в JSON |
|
||||||
|
| `inputParameters` | Параметры ввода (например `ФорматРедактирования`) — массив `[{parameter, value, valueType?, choiceParameters?, choiceParameterLinks?}]`. `valueType: {uri, name}` сохраняет кастомный xsi:type с локальным xmlns (например `d6p1:FoldersAndItemsUse`). В `choiceParameters[i].values` элементы — bool/int/double/string; compile эмитит соответствующий xsi:type (`xs:boolean` / `xs:decimal` / `dcscor:DesignTimeValue`) |
|
||||||
|
| `nilValue` | `true` — эмитить `<value xsi:nil="true"/>` для параметров с явным типом (decimal/string/dateTime), где XML-сериализатор обычно ставит типизированный default. Bit-perfect round-trip |
|
||||||
|
|
||||||
### availableValues
|
### availableValues
|
||||||
|
|
||||||
@@ -463,11 +498,16 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
|
|
||||||
| Поле | XML |
|
| Поле | XML |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| `source` | `<sourceDataSet>` |
|
| `source` / `sourceDataSet` | `<sourceDataSet>` |
|
||||||
| `dest` | `<destinationDataSet>` |
|
| `dest` / `destinationDataSet` | `<destinationDataSet>` |
|
||||||
| `sourceExpr` | `<sourceExpression>` |
|
| `sourceExpr` / `sourceExpression` | `<sourceExpression>` |
|
||||||
| `destExpr` | `<destinationExpression>` |
|
| `destExpr` / `destinationExpression` | `<destinationExpression>` |
|
||||||
| `parameter` | `<parameter>` (опц.) |
|
| `parameter` | `<parameter>` (опц.) |
|
||||||
|
| `parameterListAllowed` | `<parameterListAllowed>true</parameterListAllowed>` (опц., bool) |
|
||||||
|
| `startExpression` | `<startExpression>` (опц.) |
|
||||||
|
| `linkConditionExpression` | `<linkConditionExpression>` (опц.) |
|
||||||
|
|
||||||
|
decompile эмитит длинные имена (`sourceDataSet` и т.д.); compile принимает обе формы.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -478,30 +518,38 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
"name": "Основной",
|
"name": "Основной",
|
||||||
"presentation": "Основной вариант",
|
"presentation": "Основной вариант",
|
||||||
"settings": {
|
"settings": {
|
||||||
|
"userFields": [...],
|
||||||
"selection": [...],
|
"selection": [...],
|
||||||
"filter": [...],
|
"filter": [...],
|
||||||
"order": [...],
|
"order": [...],
|
||||||
"conditionalAppearance": [...],
|
"conditionalAppearance": [...],
|
||||||
"outputParameters": {...},
|
"outputParameters": {...},
|
||||||
"dataParameters": [...],
|
"dataParameters": [...],
|
||||||
"structure": [...]
|
"structure": [...],
|
||||||
|
"additionalProperties": { "ВариантНаименование": "...", "Адрес": "..." }
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`additionalProperties` — словарь служебных свойств варианта (`<dcsset:additionalProperties>` в XML), значения сохраняются как `xs:string`. Платформа использует его для типа «имя варианта», URL временного хранилища и т.п. — для bit-perfect round-trip сохраняется как есть, обычно модели заполнять не нужно.
|
||||||
|
|
||||||
### selection
|
### selection
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"selection": [
|
"selection": [
|
||||||
"Наименование",
|
"Наименование",
|
||||||
{ "field": "Количество", "title": "Кол-во" },
|
{ "field": "Количество", "title": "Кол-во" },
|
||||||
|
{ "field": "Контрагент", "viewMode": "Inaccessible" },
|
||||||
|
{ "field": "Скрытое", "use": false },
|
||||||
|
{ "auto": true, "use": false },
|
||||||
"Auto"
|
"Auto"
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
- Строка → `SelectedItemField`
|
- Строка → `SelectedItemField`
|
||||||
- `"Auto"` → `SelectedItemAuto` (только на уровне группировок; на верхнем уровне settings игнорируется)
|
- `"Auto"` → `SelectedItemAuto` (только на уровне группировок; на верхнем уровне settings игнорируется)
|
||||||
- Объект с `field`/`title` → `SelectedItemField` с `lwsTitle`
|
- Объект с `field` + опц. `title`/`viewMode`/`use` → `SelectedItemField`. `use: false` = поле выборки отключено (видно в UI, но не применяется)
|
||||||
|
- Объект `{ auto: true, use: false }` → отключённый `SelectedItemAuto`
|
||||||
- Объект с `folder`/`items` → `SelectedItemFolder` — группа полей с заголовком и `placement=Auto`:
|
- Объект с `folder`/`items` → `SelectedItemFolder` — группа полей с заголовком и `placement=Auto`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -513,6 +561,12 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Опциональное поле `placement` (`Auto` / `Horizontally` / `Vertically` / `Special`) задаёт расположение элементов внутри группы (по умолчанию `Auto`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"folder": "Экономия ФОТ", "items": ["ЭкономияФОТ", "ЭкономияФОТПроцент"], "placement": "Horizontally"}
|
||||||
|
```
|
||||||
|
|
||||||
### filter
|
### filter
|
||||||
|
|
||||||
#### Shorthand-строка
|
#### Shorthand-строка
|
||||||
@@ -532,8 +586,9 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
- `@off` → `use=false`
|
- `@off` → `use=false`
|
||||||
- `@user` → `userSettingID=auto` (генерировать GUID)
|
- `@user` → `userSettingID=auto` (генерировать GUID)
|
||||||
- `@quickAccess` → `viewMode=QuickAccess`
|
- `@quickAccess` → `viewMode=QuickAccess`
|
||||||
- `@normal` → `viewMode=Normal`
|
- `@normal` → `viewMode=Normal` (явный — для bit-perfect, см. [viewMode](#viewmode-режим-доступности))
|
||||||
- `@inaccessible` → `viewMode=Inaccessible`
|
- `@inaccessible` → `viewMode=Inaccessible`
|
||||||
|
- Типы значений автоопределяются: `true`/`false` → `xs:boolean`, дата `2024-01-01T00:00:00` → `xs:dateTime`, числа → `xs:decimal`, `Перечисление.X.Y`/`Справочник.X.Y`/`ПланСчетов.X.Y` и др. → `dcscor:DesignTimeValue`, остальное → `xs:string`
|
||||||
- Типы значений автоопределяются: `true`/`false` → boolean, `2024-01-01T00:00:00` → dateTime, числа → decimal, `Перечисление.*`/`Справочник.*`/`ПланСчетов.*`/`Документ.*` → DesignTimeValue, прочее → string
|
- Типы значений автоопределяются: `true`/`false` → boolean, `2024-01-01T00:00:00` → dateTime, числа → decimal, `Перечисление.*`/`Справочник.*`/`ПланСчетов.*`/`Документ.*` → DesignTimeValue, прочее → string
|
||||||
- OrGroup: `{"group": "Or", "items": ["условие1", "условие2"]}` — объединяет условия через ИЛИ
|
- OrGroup: `{"group": "Or", "items": ["условие1", "условие2"]}` — объединяет условия через ИЛИ
|
||||||
|
|
||||||
@@ -543,10 +598,13 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
"filter": [
|
"filter": [
|
||||||
{ "field": "Организация", "op": "=", "use": false, "userSettingID": "auto" },
|
{ "field": "Организация", "op": "=", "use": false, "userSettingID": "auto" },
|
||||||
{ "field": "Дата", "op": ">=", "value": "0001-01-01T00:00:00", "valueType": "xs:dateTime" },
|
{ "field": "Дата", "op": ">=", "value": "0001-01-01T00:00:00", "valueType": "xs:dateTime" },
|
||||||
|
{ "field": "СуммаДт", "op": "=", "value": "СуммаКт", "valueType": "dcscor:Field" },
|
||||||
|
{ "field": "Статус", "op": "in", "value": [1, 3, 5] },
|
||||||
|
{ "field": "Контрагенты", "op": "in", "value": [], "userSettingID": "auto" },
|
||||||
{ "group": "Or", "items": [
|
{ "group": "Or", "items": [
|
||||||
{ "field": "Статус", "op": "=", "value": true, "valueType": "xs:boolean" },
|
{ "field": "Статус", "op": "=", "value": true, "valueType": "xs:boolean" },
|
||||||
{ "field": "Пометка", "op": "filled" }
|
{ "field": "Пометка", "op": "filled" }
|
||||||
]}
|
], "userSettingID": "auto" }
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -554,14 +612,19 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| `field` | Имя поля |
|
| `field` | Имя поля |
|
||||||
| `op` | Оператор (см. таблицу) |
|
| `op` | Оператор (см. таблицу) |
|
||||||
| `value` | Правая часть (опц.) |
|
| `value` | Правая часть (опц.). См. формы ниже |
|
||||||
| `valueType` | xsi:type для значения (опц.) |
|
| `valueType` | xsi:type для значения (опц.). `"dcscor:Field"` = field-to-field comparison (значение — имя другого поля). Для массива `value: [...]` применяется ко всем элементам — нужен когда auto-detect ошибается (например `Перечисление.X.Y` должно остаться `xs:string`, а не `dcscor:DesignTimeValue`) |
|
||||||
| `use` | Включён (`true` по умолчанию) |
|
| `use` | Включён (`true` по умолчанию) |
|
||||||
| `presentation` | Текст подсказки |
|
| `presentation` | Текст подсказки |
|
||||||
| `viewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` |
|
| `viewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` |
|
||||||
| `userSettingID` | `"auto"` → генерировать GUID |
|
| `userSettingID` | `"auto"` → генерировать GUID |
|
||||||
| `userSettingPresentation` | Отображаемое имя настройки (LocalStringType) |
|
| `userSettingPresentation` | Отображаемое имя настройки (LocalStringType) |
|
||||||
|
|
||||||
|
**Формы `value`:**
|
||||||
|
- Скаляр (`"X"`, `5`, `true`, `"2024-01-01T00:00:00"`) — single `<right>` (стандартный случай). Тип определяется автоматически: bool / число / дата / строка.
|
||||||
|
- Массив `[a, b, c]` — несколько `<right>` подряд (для `in`/`notIn` с конкретными значениями).
|
||||||
|
- Пустой массив `[]` — `<right xsi:type="v8:ValueListType">` placeholder (типичный паттерн для `in` с пользовательскими настройками — значения заполнит пользователь через UI).
|
||||||
|
|
||||||
Операторы:
|
Операторы:
|
||||||
|
|
||||||
| DSL | XML comparisonType |
|
| DSL | XML comparisonType |
|
||||||
@@ -581,18 +644,24 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
| `filled` | `Filled` |
|
| `filled` | `Filled` |
|
||||||
| `notFilled` | `NotFilled` |
|
| `notFilled` | `NotFilled` |
|
||||||
|
|
||||||
Группа условий: `{ "group": "And"|"Or"|"Not", "items": [...] }` → `FilterItemGroup` с `groupType`.
|
Группа условий: `{ "group": "And"|"Or"|"Not", "items": [...] }` → `FilterItemGroup` с `groupType`. Группа также принимает item-level поля `presentation`, `viewMode`, `userSettingID`, `userSettingPresentation` — для регистрации группы как пункта пользовательских настроек.
|
||||||
|
|
||||||
### order
|
### order
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"order": ["Количество desc", "Наименование", "Auto"]
|
"order": [
|
||||||
|
"Количество desc",
|
||||||
|
"Наименование",
|
||||||
|
{ "field": "Контрагент", "direction": "desc", "viewMode": "Inaccessible" },
|
||||||
|
"Auto"
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `"Field"` → `OrderItemField`, `orderType=Asc`
|
- `"Field"` → `OrderItemField`, `orderType=Asc`
|
||||||
- `"Field desc"` → `OrderItemField`, `orderType=Desc`
|
- `"Field desc"` → `OrderItemField`, `orderType=Desc`
|
||||||
- `"Field asc"` → `OrderItemField`, `orderType=Asc`
|
- `"Field asc"` → `OrderItemField`, `orderType=Asc`
|
||||||
- `"Auto"` → `OrderItemAuto` (только на уровне группировок; на верхнем уровне settings игнорируется)
|
- `"Auto"` → `OrderItemAuto` (только на уровне группировок; на верхнем уровне settings игнорируется)
|
||||||
|
- Объект `{ field, direction?, viewMode?, use? }` — нужен, когда требуется задать `viewMode`, или отключить сортировку через `use: false` (см. [viewMode](#viewmode-режим-доступности))
|
||||||
|
|
||||||
### conditionalAppearance
|
### conditionalAppearance
|
||||||
|
|
||||||
@@ -604,14 +673,16 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
"selection": ["Сумма"],
|
"selection": ["Сумма"],
|
||||||
"filter": ["Сумма > 1000"],
|
"filter": ["Сумма > 1000"],
|
||||||
"appearance": { "ЦветТекста": "style:ПросроченныеДанныеЦвет" },
|
"appearance": { "ЦветТекста": "style:ПросроченныеДанныеЦвет" },
|
||||||
"presentation": "Выделять крупные суммы",
|
"presentation": { "ru": "Выделять крупные суммы", "en": "Highlight large amounts" },
|
||||||
"viewMode": "Normal",
|
"viewMode": "Normal",
|
||||||
"userSettingID": "auto"
|
"userSettingID": "auto"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filter": ["Статус notFilled"],
|
"filter": ["Статус notFilled"],
|
||||||
"appearance": { "Текст": "Не указано", "ЦветТекста": "web:Gray" },
|
"appearance": { "Текст": "Не указано", "ЦветТекста": "web:Gray" },
|
||||||
"presentation": "Скрывать пустые статусы"
|
"presentation": "Скрывать пустые статусы",
|
||||||
|
"use": false,
|
||||||
|
"useInDontUse": ["group", "fieldsHeader"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -621,15 +692,25 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
| `selection` | Массив полей, к которым применяется. Пусто/опущено = все поля |
|
| `selection` | Массив полей, к которым применяется. Пусто/опущено = все поля |
|
||||||
| `filter` | Условия (shorthand-строки или объекты, как в settings filter) |
|
| `filter` | Условия (shorthand-строки или объекты, как в settings filter) |
|
||||||
| `appearance` | Объект `{ "Параметр": "Значение" }` |
|
| `appearance` | Объект `{ "Параметр": "Значение" }` |
|
||||||
| `presentation` | Описание правила |
|
| `presentation` | Описание правила (строка или multilang dict `{ru, en}`) |
|
||||||
| `use` | Включено (`true` по умолчанию) |
|
| `use` | Включено (`true` по умолчанию). `false` = правило отключено |
|
||||||
| `viewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` |
|
| `viewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` |
|
||||||
| `userSettingID` | `"auto"` → генерировать GUID |
|
| `userSettingID` | `"auto"` → генерировать GUID |
|
||||||
|
| `userSettingPresentation` | Имя в пользовательских настройках (string или multilang) |
|
||||||
|
| `useInDontUse` | Массив контекстов где правило **НЕ** применяется. Возможные имена: `group`, `hierarchicalGroup`, `overall`, `fieldsHeader`, `header`, `parameters`, `filter`, `resourceFieldsHeader`, `overallHeader`, `overallResourceFieldsHeader` |
|
||||||
|
|
||||||
**Типы значений appearance** определяются автоматически:
|
**Типы значений appearance** определяются автоматически:
|
||||||
- `style:XXX`, `web:XXX`, `win:XXX` → `v8ui:Color`
|
- `style:XXX` → `v8ui:Color` (палитра темы платформы, namespace `http://v8.1c.ru/8.1/data/ui/style`)
|
||||||
|
- `web:XXX` → `v8ui:Color` (web-имена цветов, namespace `http://v8.1c.ru/8.1/data/ui/colors/web`)
|
||||||
|
- `win:XXX` → `v8ui:Color` (системные цвета Windows, namespace `http://v8.1c.ru/8.1/data/ui/colors/windows`)
|
||||||
|
- Ключи `ЦветТекста`/`ЦветФона`/`ЦветГраницы` со значениями типа `auto` или `#XXXXXX` → `v8ui:Color`
|
||||||
|
- Ключ `Размещение` → `dcscor:DataCompositionTextPlacementType`
|
||||||
|
- Ключи `ГоризонтальноеПоложение`/`ВертикальноеПоложение` → `v8ui:HorizontalAlign`/`VerticalAlign`
|
||||||
|
- Ключ `ТипМакета` → `dcsset:DataCompositionGroupTemplateType`
|
||||||
|
- Ключи `Текст`/`Заголовок`/`Формат` → `v8:LocalStringType` (если значение строка)
|
||||||
|
- Числовые строки (`"40"`, `"15"`) → `xs:decimal`
|
||||||
- `true`/`false` → `xs:boolean`
|
- `true`/`false` → `xs:boolean`
|
||||||
- Параметр `Формат`, `Текст` или `Заголовок` → `v8:LocalStringType`
|
- Multilang dict `{ru, en}` для любого ключа → `v8:LocalStringType`
|
||||||
- Прочее → `xs:string`
|
- Прочее → `xs:string`
|
||||||
|
|
||||||
Поддержка `use=false` на уровне параметра:
|
Поддержка `use=false` на уровне параметра:
|
||||||
@@ -652,13 +733,72 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
Ключ → `dcscor:parameter`, значение → `dcscor:value`.
|
Ключ → `dcscor:parameter`, значение → `dcscor:value`.
|
||||||
|
|
||||||
Типы значений определяются автоматически:
|
Типы значений определяются автоматически:
|
||||||
- `"Заголовок"` → `v8:LocalStringType`
|
- `"Заголовок"` → `v8:LocalStringType` (примет строку или multilang dict)
|
||||||
- `"ВыводитьЗаголовок"`, `"ВыводитьПараметрыДанных"`, `"ВыводитьОтбор"` → `dcsset:DataCompositionTextOutputType`
|
- `"ВыводитьЗаголовок"`, `"ВыводитьПараметрыДанных"`, `"ВыводитьОтбор"` → `dcsset:DataCompositionTextOutputType`
|
||||||
- `"РасположениеПолейГруппировки"` → `dcsset:DataCompositionGroupFieldsPlacement`
|
- `"РасположениеПолейГруппировки"` → `dcsset:DataCompositionGroupFieldsPlacement`
|
||||||
- `"РасположениеРеквизитов"` → `dcsset:DataCompositionAttributesPlacement`
|
- `"РасположениеРеквизитов"` → `dcsset:DataCompositionAttributesPlacement`
|
||||||
- `"ГоризонтальноеРасположениеОбщихИтогов"`, `"ВертикальноеРасположениеОбщихИтогов"` → `dcscor:DataCompositionTotalPlacement`
|
- `"ГоризонтальноеРасположениеОбщихИтогов"`, `"ВертикальноеРасположениеОбщихИтогов"`, `"РасположениеОбщихИтогов"`, `"РасположениеИтогов"` → `dcscor:DataCompositionTotalPlacement`
|
||||||
|
- `"РасположениеГруппировки"` → `dcsset:DataCompositionFieldGroupPlacement`
|
||||||
|
- `"РасположениеРесурсов"` → `dcsset:DataCompositionResourcesPlacement`
|
||||||
|
- `"ТипМакета"` → `dcsset:DataCompositionGroupTemplateType`
|
||||||
|
- Multilang dict `{ru, en}` для любого ключа → `v8:LocalStringType`
|
||||||
- Прочие → `xs:string`
|
- Прочие → `xs:string`
|
||||||
|
|
||||||
|
Значение можно обернуть в `{ "value": ..., "use": false }` — отключённый параметр (платформа эмитит `<dcscor:use>false</dcscor:use>`). Такая же форма доступна в `appearance` items (см. раздел conditionalAppearance).
|
||||||
|
|
||||||
|
#### Полная wrapper-форма (bit-perfect round-trip)
|
||||||
|
|
||||||
|
Decompile сохраняет всю периферию каждого outputParameter в wrapper'е:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"value": "Custom",
|
||||||
|
"valueType": "v8:StandardPeriod", // полный xsi:type если не покрыт type-map'ом
|
||||||
|
"use": false, // <dcscor:use>false</dcscor:use>
|
||||||
|
"items": { // nested sub-параметры (ТипДиаграммы.ВидПодписей)
|
||||||
|
"ТипДиаграммы.ВидПодписей": { "value": "Value", "valueType": "v8ui:ChartLabelType" }
|
||||||
|
},
|
||||||
|
"viewMode": "Normal", // <dcsset:viewMode>Normal</dcsset:viewMode>
|
||||||
|
"userSettingID": "auto",
|
||||||
|
"userSettingPresentation": { "ru": "Тип" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrapper эмитится только при наличии extra-полей; простое `"key": "value"` остаётся как есть.
|
||||||
|
|
||||||
|
#### Шрифт (v8ui:Font) в appearance
|
||||||
|
|
||||||
|
Шрифт — объект с маркером `@type: "Font"`:
|
||||||
|
```json
|
||||||
|
"Шрифт": { "@type": "Font", "ref": "sys:DefaultGUIFont", "height": 10, "bold": "true", "italic": "false", "underline": "false", "strikeout": "false", "kind": "WindowsFont" }
|
||||||
|
```
|
||||||
|
Все атрибуты исходного XML сохраняются — для bit-perfect.
|
||||||
|
|
||||||
|
#### Граница (v8ui:Line) в appearance
|
||||||
|
|
||||||
|
Граница — объект с маркером `@type: "Line"` (атрибуты `width`/`gap` и inner `<v8ui:style>` сериализуются inline):
|
||||||
|
```json
|
||||||
|
"СтильГраницы": { "@type": "Line", "width": 0, "gap": false, "style": "None" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Стороны (`СтильГраницы.Сверху/.Снизу/.Слева/.Справа`) — nested SettingsParameterValue, кладутся в `items` (как у outputParameters wrapper):
|
||||||
|
```json
|
||||||
|
"СтильГраницы": {
|
||||||
|
"@type": "Line", "width": 0, "gap": false, "style": "None",
|
||||||
|
"items": {
|
||||||
|
"СтильГраницы.Сверху": {
|
||||||
|
"value": { "@type": "Line", "width": 1, "gap": false, "style": "Solid" },
|
||||||
|
"use": false
|
||||||
|
},
|
||||||
|
"СтильГраницы.Снизу": {
|
||||||
|
"value": { "@type": "Line", "width": 1, "gap": false, "style": "Double" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Top-level Line хранится **плоско** (`@type`/`width`/`gap`/`style` + `use?`/`items?` на одном уровне). Nested items используют универсальный wrapper `{ value, use? }` — у `value` тип любой (Line/Font/color/text). Значения `style`: `None`, `Solid`, `Double`, `LargeDashed`, `SmallDashed`, `Dotted` и т.п. (значения `v8ui:SpreadsheetDocumentCellLineType`).
|
||||||
|
|
||||||
### dataParameters
|
### dataParameters
|
||||||
|
|
||||||
#### Автогенерация
|
#### Автогенерация
|
||||||
@@ -696,11 +836,26 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| `parameter` | Имя параметра |
|
| `parameter` | Имя параметра |
|
||||||
| `value` | Значение (объект `{ "variant": "LastMonth" }` для StandardPeriod, или скаляр) |
|
| `value` | Значение (объект `{ "variant": "LastMonth" }` для StandardPeriod, или скаляр) |
|
||||||
|
| `valueType` | Полный xsi:type если кастомный (например `dcsset:DataCompositionGroupUseVariant`). Для пустого значения с `use: false` — `"xs:string"` эмитит `<value xsi:type="xs:string"/>` (placeholder отключённого параметра типа DateTime, бит-перфектный аналог `xsi:nil`) |
|
||||||
| `use` | Включён (`true` по умолчанию) |
|
| `use` | Включён (`true` по умолчанию) |
|
||||||
| `viewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` |
|
| `viewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` |
|
||||||
| `userSettingID` | `"auto"` → генерировать GUID |
|
| `userSettingID` | `"auto"` → генерировать GUID |
|
||||||
| `userSettingPresentation` | Отображаемое имя настройки (LocalStringType) |
|
| `userSettingPresentation` | Отображаемое имя настройки (LocalStringType) |
|
||||||
|
|
||||||
|
#### StandardPeriod / StandardBeginningDate — shape inference
|
||||||
|
|
||||||
|
Compile различает варианты по форме `value`:
|
||||||
|
|
||||||
|
| Форма | xsi:type | Когда |
|
||||||
|
|---|---|---|
|
||||||
|
| `{variant, startDate, endDate}` | `v8:StandardPeriod` | Custom с явными датами |
|
||||||
|
| `{variant: "ThisMonth"}` (etc) | `v8:StandardPeriod` | без дат — non-Custom варианты SP |
|
||||||
|
| `{variant, date}` | `v8:StandardBeginningDate` | Custom с явной датой |
|
||||||
|
| `{variant: "BeginningOf*"}` | `v8:StandardBeginningDate` | без даты — variant'ы начинаются с `BeginningOf` |
|
||||||
|
| `"2024-01-15T10:00:00"` (string) | `xs:dateTime` | raw datetime без обёртки |
|
||||||
|
|
||||||
|
Platform-pattern: `startDate`/`endDate`/`date` эмитятся ТОЛЬКО для `variant=Custom`. Для `ThisMonth`/`LastYear`/`BeginningOfThisDay`/... — только `<v8:variant>`.
|
||||||
|
|
||||||
### structure
|
### structure
|
||||||
|
|
||||||
#### String shorthand (рекомендуется для типичных случаев)
|
#### String shorthand (рекомендуется для типичных случаев)
|
||||||
@@ -735,16 +890,30 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| `type` | `"group"` |
|
| `type` | `"group"` |
|
||||||
| `name` | Имя группировки (опц.) |
|
| `name` | Имя группировки (опц.) |
|
||||||
| `groupBy` | Массив полей. Пусто/опущено = детальные записи |
|
| `groupBy` | Массив полей. Каждый элемент — строка (имя поля) или объект `{ field, groupType?, periodAdditionType?, periodAdditionBegin?, periodAdditionEnd? }`. Пусто/опущено = детальные записи. Object-форма нужна когда `groupType ≠ "Items"`, `periodAdditionType ≠ "None"` или задана `periodAdditionBegin/End` (см. ниже) |
|
||||||
| `groupType` | `"Items"` (умолч.), `"Hierarchy"`, `"HierarchyOnly"` |
|
| `groupType` | `"Items"` (умолч.), `"Hierarchy"`, `"HierarchyOnly"` |
|
||||||
| `selection` | Выборка (умолч. `["Auto"]`) |
|
| `selection` | Выборка (умолч. `["Auto"]`) |
|
||||||
| `filter` | Отборы (как в settings) |
|
| `filter` | Отборы (как в settings) |
|
||||||
| `order` | Сортировка (умолч. `["Auto"]`) |
|
| `order` | Сортировка (умолч. `["Auto"]`) |
|
||||||
| `outputParameters` | Параметры вывода (как в settings) |
|
| `outputParameters` | Параметры вывода (как в settings) |
|
||||||
|
| `conditionalAppearance` | Условное оформление группы (как в settings) |
|
||||||
|
| `use` | `false` = ветка структуры отключена (на самой группе) |
|
||||||
|
| `viewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` — режим доступности группы в пользовательских настройках |
|
||||||
|
| `itemsViewMode` | `"Normal"`, `"QuickAccess"`, `"Inaccessible"` — режим доступности подэлементов группы |
|
||||||
|
| `userSettingID` | `"auto"` → генерировать GUID. Регистрирует группу как пункт пользовательских настроек |
|
||||||
|
| `userSettingPresentation` | Имя в пользовательских настройках (string или multilang dict) |
|
||||||
| `children` | Вложенные элементы структуры |
|
| `children` | Вложенные элементы структуры |
|
||||||
|
|
||||||
Пустой `groupBy` (или `[]`) = детальные записи (без `groupItems` в XML).
|
Пустой `groupBy` (или `[]`) = детальные записи (без `groupItems` в XML).
|
||||||
|
|
||||||
|
**`periodAdditionBegin` / `periodAdditionEnd`** на field-объекте — даты добавочного периода (`<dcsset:periodAdditionBegin>`/`<dcsset:periodAdditionEnd>`). Compile auto-определяет xsi:type значения: строка вида `2025-01-01T00:00:00` → `xs:dateTime`, иначе (путь к параметру, например `ПараметрыДанных.ДатаНачала`) → `dcscor:Field`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "field": "ПериодМесяц",
|
||||||
|
"periodAdditionBegin": "ПараметрыДанных.ДатаНачала",
|
||||||
|
"periodAdditionEnd": "ПараметрыДанных.ДатаОкончания" }
|
||||||
|
```
|
||||||
|
|
||||||
#### Таблица (table)
|
#### Таблица (table)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -755,22 +924,188 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
{ "groupBy": ["Номенклатура"], "selection": ["Auto"], "order": ["Auto"] }
|
{ "groupBy": ["Номенклатура"], "selection": ["Auto"], "order": ["Auto"] }
|
||||||
],
|
],
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "groupBy": ["Период"], "selection": ["Auto"], "order": ["Auto"] }
|
{
|
||||||
|
"name": "Период",
|
||||||
|
"groupBy": ["Период"],
|
||||||
|
"filter": ["Сумма > 0"],
|
||||||
|
"selection": ["Auto"],
|
||||||
|
"order": ["Auto"],
|
||||||
|
"outputParameters": { "РасположениеИтогов": "None" },
|
||||||
|
"userSettingID": "auto",
|
||||||
|
"userSettingPresentation": { "ru": "Колонка с периодом" }
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Каждая `column`/`row` принимает те же поля что и `group`: `name`, `groupBy`/`groupFields`, `filter`, `order`, `selection`, `outputParameters`, `conditionalAppearance`, `children` (вложенные `StructureItemGroup`), плюс user-settings — `viewMode`, `userSettingID`, `userSettingPresentation`, `itemsViewMode` (регистрация column/row как пункта «Изменить вариант»).
|
||||||
|
|
||||||
|
На самой `table` (отдельно от column/row) также допустимы `selection`, `conditionalAppearance`, `outputParameters`, плюс user-settings: `viewMode`, `userSettingID`, `userSettingPresentation`, `itemsViewMode`, `columnsViewMode`, `rowsViewMode`, `use` (`false` = таблица отключена).
|
||||||
|
- `columnsViewMode` / `rowsViewMode` — режим доступности секции колонок / строк в пользовательских настройках (значения: `Normal` / `QuickAccess` / `Inaccessible`).
|
||||||
|
|
||||||
|
> **Внутренний паттерн**: `<dcsset:item xsi:type="dcsset:StructureItemGroup">` внутри `<dcsset:row>`/`<dcsset:column>`/`<dcsset:points>`/`<dcsset:series>` платформа всегда сериализует в **короткой форме** `<dcsset:item>` без `xsi:type`. Compile эмитит этот вариант автоматически для `children` table axis.
|
||||||
|
|
||||||
#### Диаграмма (chart)
|
#### Диаграмма (chart)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "chart",
|
"type": "chart",
|
||||||
"points": { "groupBy": ["Организация"], "order": ["Auto"] },
|
"points": { "groupBy": ["Организация"], "order": ["Auto"], "filter": [...] },
|
||||||
"series": { "groupBy": ["Месяц"], "order": ["Auto"] },
|
"series": { "groupBy": ["Месяц"], "order": ["Auto"] },
|
||||||
"selection": ["Сумма"]
|
"selection": ["Сумма"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`points` и `series` принимают те же поля что table column/row (включая `name` и user-settings).
|
||||||
|
|
||||||
|
На самой chart-item: `viewMode`, `userSettingID`, `userSettingPresentation`, `itemsViewMode`, `pointsViewMode`, `seriesViewMode`, `use: false` (диаграмма отключена). `pointsViewMode`/`seriesViewMode` — аналоги `columnsViewMode`/`rowsViewMode` у таблицы.
|
||||||
|
|
||||||
|
**Multi-series / multi-points** — `points` и `series` могут быть массивом объектов, тогда генерируется несколько `<dcsset:point>` или `<dcsset:series>` подряд (каждый со своими `groupBy`, `filter`, user-settings). Используется например для разделения данных диаграммы на несколько серий по разным фильтрам:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "chart",
|
||||||
|
"points": { "groupBy": ["Период"] },
|
||||||
|
"series": [
|
||||||
|
{ "groupBy": ["Стадия"], "filter": ["Стадия = ЗНАЧЕНИЕ(Перечисление.X.A)"],
|
||||||
|
"viewMode": "Normal", "userSettingID": "auto",
|
||||||
|
"userSettingPresentation": { "ru": "Серия A" } },
|
||||||
|
{ "groupBy": ["Стадия"], "filter": ["Стадия = ЗНАЧЕНИЕ(Перечисление.X.B)"],
|
||||||
|
"viewMode": "Normal", "userSettingID": "auto",
|
||||||
|
"userSettingPresentation": { "ru": "Серия B" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### userFields (пользовательские вычисляемые поля)
|
||||||
|
|
||||||
|
Дополнительные поля, которые пользователь может задать в режиме «Изменить вариант» через UI. Хранятся в settings варианта. Два подтипа определяются по содержимому объекта:
|
||||||
|
|
||||||
|
**Expression-форма** — поле вычисляется выражением (опционально с разделением для детальных строк и для итогов):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"userFields": [
|
||||||
|
{
|
||||||
|
"dataPath": "ПользовательскиеПоля.Поле1",
|
||||||
|
"title": { "ru": "Отработано дней", "en": "Days worked" },
|
||||||
|
"detail": {
|
||||||
|
"expression": "Выбор Когда Группа = ... Тогда ОтработаноДней Иначе 0 Конец",
|
||||||
|
"presentation": "Выбор Когда Группа = ... Тогда [Отработано дней] Иначе 0 Конец"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"expression": "Сумма(Выбор Когда Группа = ... Тогда ОтработаноДней Иначе 0 Конец)",
|
||||||
|
"presentation": "Сумма(Выбор Когда Группа = ... Тогда [Отработано дней] Иначе 0 Конец)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `dataPath` | Путь поля в формате `ПользовательскиеПоля.ПолеN` |
|
||||||
|
| `title` | Заголовок (строка или multilang dict) |
|
||||||
|
| `detail.expression` | Выражение для детальных записей |
|
||||||
|
| `detail.presentation` | Тот же expression с подстановкой `[Имя поля]` (для UI) |
|
||||||
|
| `total.expression` | Выражение для итоговой строки |
|
||||||
|
| `total.presentation` | Same для UI |
|
||||||
|
|
||||||
|
> **Пустые значения**: XML всегда содержит все четыре элемента (`detailExpression`, `detailExpressionPresentation`, `totalExpression`, `totalExpressionPresentation`) — даже если без значения (`<dcsset:totalExpression/>`). decompile сохраняет ключ с пустой строкой, compile эмитит self-closing тег для пустых строк. Это нужно для bit-perfect round-trip.
|
||||||
|
|
||||||
|
**Case-форма** — поле принимает разные значения в зависимости от условий:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"userFields": [
|
||||||
|
{
|
||||||
|
"dataPath": "ПользовательскиеПоля.Поле1",
|
||||||
|
"title": { "ru": "Вид продаж" },
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"filter": ["ХозОперация <> Перечисление.ХозяйственныеОперации.РеализацияВРозницу"],
|
||||||
|
"value": 2,
|
||||||
|
"presentation": { "ru": "Только оптовые продажи", "en": "Wholesale only" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filter": ["ХозОперация = Перечисление.ХозяйственныеОперации.РеализацияВРозницу"],
|
||||||
|
"value": 3,
|
||||||
|
"presentation": { "ru": "Только розничные продажи", "en": "Retail only" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|------|----------|
|
||||||
|
| `cases[].filter` | Условие (как в settings filter) |
|
||||||
|
| `cases[].value` | Значение поля если условие выполнено (типы автоопределяются: bool/decimal/string) |
|
||||||
|
| `cases[].presentation` | Текст значения для UI (multilang) |
|
||||||
|
|
||||||
|
Тип элемента определяется автоматически: наличие `cases` → `UserFieldCase`, иначе → `UserFieldExpression`.
|
||||||
|
|
||||||
|
### viewMode (режим доступности)
|
||||||
|
|
||||||
|
`viewMode` управляет доступностью элемента в **пользовательских настройках** отчёта («Изменить вариант…» / «Настройки»). Возможные значения:
|
||||||
|
|
||||||
|
| Значение | Семантика |
|
||||||
|
|----------|-----------|
|
||||||
|
| `"Normal"` | Пользователь видит и может править (default) |
|
||||||
|
| `"Inaccessible"` | Скрыто от пользователя, не редактируется |
|
||||||
|
| `"QuickAccess"` | Вынесено в быстрые настройки (на форму отчёта) |
|
||||||
|
| `"Auto"` | Автоматический режим (наследование от контейнера) |
|
||||||
|
|
||||||
|
Применяется в трёх контекстах:
|
||||||
|
|
||||||
|
**1. Item-level** — на отдельном элементе блока (см. описание объектной формы соответствующего раздела):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"filter": [{ "field": "X", "op": "=", "value": "Y", "viewMode": "Inaccessible" }],
|
||||||
|
"selection": [{ "field": "X", "viewMode": "Inaccessible" }],
|
||||||
|
"order": [{ "field": "X", "viewMode": "Inaccessible" }],
|
||||||
|
"conditionalAppearance": [{ "filter": [...], "appearance": {...}, "viewMode": "Inaccessible" }],
|
||||||
|
"dataParameters": [{ "parameter": "X", "viewMode": "QuickAccess" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
Shorthand-флаги `@inaccessible`, `@quickAccess` доступны для `filter` и `dataParameters` строковых форм.
|
||||||
|
|
||||||
|
**2. Block-level** — на самом блоке (внутри `settings`). Управляет доступностью всей группы как пункта пользовательских настроек:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"settings": {
|
||||||
|
"selectionViewMode": "Inaccessible",
|
||||||
|
"filterViewMode": "Inaccessible",
|
||||||
|
"orderViewMode": "Inaccessible",
|
||||||
|
"conditionalAppearanceViewMode": "Inaccessible",
|
||||||
|
"itemsViewMode": "Inaccessible",
|
||||||
|
"selectionUserSettingID": "auto",
|
||||||
|
"filterUserSettingID": "auto",
|
||||||
|
"orderUserSettingID": "auto",
|
||||||
|
"conditionalAppearanceUserSettingID": "auto",
|
||||||
|
"selection": [...],
|
||||||
|
"filter": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`itemsViewMode` на settings — общий режим для всех подэлементов варианта (`<dcsset:itemsViewMode>` в XML). `XxxUserSettingID` парят с `XxxViewMode` — platform пишет их в block-level пользовательских настроек. Пустые блоки (без items) тоже эмитятся, если есть block-level meta — например `<dcsset:conditionalAppearance><dcsset:viewMode>Normal</dcsset:viewMode></dcsset:conditionalAppearance>`.
|
||||||
|
|
||||||
|
Также `orderViewMode`/`orderUserSettingID` поддержаны на StructureItemGroup для случаев когда block-level meta лежит на nested `<dcsset:order>`.
|
||||||
|
|
||||||
|
**3. Structure item** — на элементе структуры (`group`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "group", "groupBy": ["Организация"], "viewMode": "Inaccessible", "itemsViewMode": "Inaccessible" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Table axis / chart axis** — на самой `column`/`row`/`points`/`series`. Через те же поля `viewMode`, `userSettingID`, `userSettingPresentation` (см. раздел Таблица).
|
||||||
|
|
||||||
|
#### Стратегия сохранения
|
||||||
|
|
||||||
|
Платформа эмитит `viewMode` непоследовательно: в одних местах `<viewMode>Normal</viewMode>` присутствует явно (когда элемент — пункт пользовательских настроек), в других — нет. Для bit-perfect round-trip:
|
||||||
|
|
||||||
|
- `skd-decompile` сохраняет `viewMode` в JSON **точно как было в XML**, включая явный `"Normal"` если он физически присутствовал.
|
||||||
|
- `skd-compile` эмитит `<viewMode>` только если значение задано в JSON (без `implicit Normal`-подстановки).
|
||||||
|
|
||||||
|
При компиляции JSON, написанного с нуля моделью, `viewMode` опускается → платформа применит default `Normal` при загрузке схемы.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Макеты и привязки (templates, groupTemplates)
|
## 10. Макеты и привязки (templates, groupTemplates)
|
||||||
@@ -886,13 +1221,45 @@ XML-маппинг — по `<group>` на каждый элемент:
|
|||||||
|
|
||||||
#### Расшифровка (drilldown) в параметрах шаблона
|
#### Расшифровка (drilldown) в параметрах шаблона
|
||||||
|
|
||||||
Ключ `drilldown` в параметре шаблона автоматически генерирует:
|
Ключ `drilldown` в параметре шаблона — три формы по типу значения:
|
||||||
1. `DetailsAreaTemplateParameter` с именем `Расшифровка_<значение>`, `fieldExpression` по полю `ИмяРесурса`, `mainAction=DrillDown`
|
|
||||||
2. Привязку `Расшифровка` в appearance ячеек, ссылающихся на этот параметр через `{Имя}`
|
**Форма A (без drilldown)** — обычный `ExpressionAreaTemplateParameter`:
|
||||||
|
```json
|
||||||
|
{ "name": "Дата", "expression": "Документ.Дата" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Форма B (строка, shortcut)** — `ExpressionAreaTemplateParameter` + автоматический `DetailsAreaTemplateParameter` с именем `Расшифровка_<value>`, `fieldExpression` по полю `ИмяРесурса` (`expression="<value>"`), `mainAction=DrillDown`. Ячейки `{name}` получают appearance `Расшифровка → Расшифровка_<value>` автоматически:
|
||||||
|
```json
|
||||||
|
{ "name": "Сырье", "expression": "ПоступлениеСырья", "drilldown": "ПоступлениеСырья" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Форма C (объект)** — самостоятельный `DetailsAreaTemplateParameter` с именем `name`, без `ExpressionAreaTemplateParameter`. Используется когда расшифровка ссылается на data-параметр (а не на ИмяРесурса) и/или нужен другой `mainAction` (например `OpenValue`):
|
||||||
|
```json
|
||||||
|
{ "name": "МаршрутныйЛист",
|
||||||
|
"drilldown": { "field": "МаршрутныйЛист",
|
||||||
|
"expression": "МаршрутныйЛист",
|
||||||
|
"action": "OpenValue" } }
|
||||||
|
```
|
||||||
|
`action` по умолчанию `DrillDown`.
|
||||||
|
|
||||||
|
**Override на уровне ячейки** — object-форма `{ value, drilldown }`. Используется когда несколько ячеек должны указывать на один и тот же параметр-расшифровку (объявленный формой C):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"parameters": [
|
"rows": [
|
||||||
{ "name": "Сырье", "expression": "ПоступлениеСырья", "drilldown": "ПоступлениеСырья" }
|
[ { "value": "{Номер}", "drilldown": "МаршрутныйЛист" },
|
||||||
|
{ "value": "{Дата}", "drilldown": "МаршрутныйЛист" } ]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Значение `drilldown` в ячейке — это полное имя параметра-расшифровки (как объявлено в `parameters`). Для shortcut form B override не нужен — appearance подставляется автоматически.
|
||||||
|
|
||||||
|
### fieldTemplates
|
||||||
|
|
||||||
|
Привязка именованного area-template к полю — `<fieldTemplate><field>X</field><template>Y</template></fieldTemplate>`. Когда платформа выводит значение поля `X`, используется макет `Y`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"fieldTemplates": [
|
||||||
|
{ "field": "МаршрутныйЛист", "template": "Макет1" }
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -8,6 +8,7 @@
|
|||||||
|-------|-----------|----------|
|
|-------|-----------|----------|
|
||||||
| `/skd-info` | `<TemplatePath> [-Mode] [-Name]` | Анализ структуры СКД: наборы, поля, параметры, ресурсы, варианты (11 режимов, включая full) |
|
| `/skd-info` | `<TemplatePath> [-Mode] [-Name]` | Анализ структуры СКД: наборы, поля, параметры, ресурсы, варианты (11 режимов, включая full) |
|
||||||
| `/skd-compile` | `[-DefinitionFile <json> \| -Value <json-string>] -OutputPath <Template.xml>` | Генерация Template.xml из JSON DSL: наборы, поля, итоги, параметры, варианты |
|
| `/skd-compile` | `[-DefinitionFile <json> \| -Value <json-string>] -OutputPath <Template.xml>` | Генерация Template.xml из JSON DSL: наборы, поля, итоги, параметры, варианты |
|
||||||
|
| `/skd-decompile` | `<TemplatePath> [-OutputPath <out.json>]` | Преобразование Template.xml в JSON-черновик в формате `/skd-compile` — для нового отчёта по образцу или структурной переработки существующего. Из соображений предосторожности исключён из автоматического подбора моделью — вызывается только явной командой |
|
||||||
| `/skd-edit` | `<TemplatePath> -Operation <op> -Value "<value>"` | Точечное редактирование: 26 атомарных операций (add/set/patch/modify/clear/remove) |
|
| `/skd-edit` | `<TemplatePath> -Operation <op> -Value "<value>"` | Точечное редактирование: 26 атомарных операций (add/set/patch/modify/clear/remove) |
|
||||||
| `/skd-validate` | `<TemplatePath> [-MaxErrors 20]` | Валидация структурной корректности: ~30 проверок |
|
| `/skd-validate` | `<TemplatePath> [-MaxErrors 20]` | Валидация структурной корректности: ~30 проверок |
|
||||||
|
|
||||||
@@ -15,15 +16,23 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
Описание отчёта (текст) → JSON DSL → /skd-compile → Template.xml → /skd-validate
|
Описание отчёта (текст) → JSON DSL → /skd-compile → Template.xml → /skd-validate
|
||||||
↕ /skd-edit → /skd-info
|
↑ ↕ /skd-edit → /skd-info
|
||||||
|
└──── /skd-decompile ──────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Claude формирует JSON-определение СКД (shorthand-поля, параметры, итоги, варианты)
|
1. Claude формирует JSON-определение СКД (shorthand-поля, параметры, итоги, варианты) — либо с нуля по описанию, либо `/skd-decompile` готовит черновик по существующему Template.xml
|
||||||
2. `/skd-compile` генерирует Template.xml с корректными namespace, типами, группировками
|
2. `/skd-compile` генерирует Template.xml с корректными namespace, типами, группировками
|
||||||
3. `/skd-edit` вносит точечные изменения: добавление полей, фильтров, наборов данных, вариантов, условного оформления и т.д.
|
3. `/skd-edit` вносит точечные изменения: добавление полей, фильтров, наборов данных, вариантов, условного оформления и т.д.
|
||||||
4. `/skd-validate` проверяет корректность XML
|
4. `/skd-validate` проверяет корректность XML
|
||||||
5. `/skd-info` выводит компактную сводку для визуальной проверки
|
5. `/skd-info` выводит компактную сводку для визуальной проверки
|
||||||
|
|
||||||
|
### Когда `/skd-decompile`, а когда `/skd-edit`
|
||||||
|
|
||||||
|
- **`/skd-edit`** — точечные правки готового отчёта (добавить поле, фильтр, итог, переименовать параметр). Меняет XML адресно, без полной реконструкции, не задевает непокрытые конструкции.
|
||||||
|
- **`/skd-decompile` → правка JSON → `/skd-compile`** — сценарии, где правки структурны: новый отчёт по образцу существующего, переработка варианта, перерисовка макета, перебор набора полей. Цикл переписывает Template.xml целиком.
|
||||||
|
|
||||||
|
**Полнота не гарантируется.** Известные декомпилятору непокрытые конструкции явно отмечаются маркерами в JSON и собираются в файл предупреждений — компилятор откажется собирать такой черновик, пока маркеры не разрешены вручную или не удалены. Но возможны и **тихие потери** — мелкое оформление, редкие настройки, незнакомые декомпилятору расширения. Это даёт валидный XML без части функциональности, и Конфигуратор такой результат не отбракует. Именно поэтому навык не предназначен для точечных правок (для них есть `/skd-edit`) и исключён из автоматического подбора моделью — вызывается только явной командой пользователя. Решение использовать пересобранный Template.xml — на стороне пользователя, и сверка с оригиналом перед коммитом остаётся его ответственностью.
|
||||||
|
|
||||||
## JSON DSL — компактный формат
|
## JSON DSL — компактный формат
|
||||||
|
|
||||||
СКД описываются в JSON с двумя уровнями детализации для каждой секции:
|
СКД описываются в JSON с двумя уровнями детализации для каждой секции:
|
||||||
|
|||||||
+56
-19
@@ -218,7 +218,7 @@ console.log('Расшифровка:', JSON.stringify(drilldown.rows));
|
|||||||
|
|
||||||
| Функция | Описание | Возвращает |
|
| Функция | Описание | Возвращает |
|
||||||
|---------|----------|------------|
|
|---------|----------|------------|
|
||||||
| `navigateSection(name)` | Перейти в раздел (fuzzy match) | `{ sections, commands }` |
|
| `navigateSection(name)` | Перейти в раздел (fuzzy match) | form state с `navigated`, `sections`, `commands` |
|
||||||
| `openCommand(name)` | Открыть команду из панели функций | form state |
|
| `openCommand(name)` | Открыть команду из панели функций | form state |
|
||||||
| `navigateLink(path)` | Открыть по пути метаданных (`Документ.ЗаказКлиента`) | form state |
|
| `navigateLink(path)` | Открыть по пути метаданных (`Документ.ЗаказКлиента`) | form state |
|
||||||
| `openFile(path)` | Открыть внешнюю обработку/отчёт (EPF/ERF) через «Файл → Открыть» | form state |
|
| `openFile(path)` | Открыть внешнюю обработку/отчёт (EPF/ERF) через «Файл → Открыть» | form state |
|
||||||
@@ -260,38 +260,69 @@ console.log('Расшифровка:', JSON.stringify(drilldown.rows));
|
|||||||
- `_selected: true` — строка выделена (подсвечена). Используйте с `clickElement({ modifier: 'ctrl'|'shift' })` для проверки мультиселекции
|
- `_selected: true` — строка выделена (подсвечена). Используйте с `clickElement({ modifier: 'ctrl'|'shift' })` для проверки мультиселекции
|
||||||
- На объекте результата: `hierarchical: true`, `viewMode: 'tree'`
|
- На объекте результата: `hierarchical: true`, `viewMode: 'tree'`
|
||||||
|
|
||||||
#### clickElement — клик по ячейке SpreadsheetDocument
|
**Виртуализация и `hasMore`.** 1С виртуализирует и динамические списки, и табличные части — в DOM лежит только окно видимых строк. Поля `total` / `shown` — это размер DOM-окна, а **не** размер коллекции. Чтобы понять, есть ли строки за пределами окна, используйте `hasMore`:
|
||||||
|
|
||||||
Для расшифровки отчётов первый аргумент `clickElement` принимает объект `{ row, column }` вместо текста. Координаты соответствуют выводу `readSpreadsheet()`:
|
```js
|
||||||
|
const t = await readTable();
|
||||||
|
// t.hasMore = { above: false, below: true } — открыли список, есть строки ниже
|
||||||
|
// t.hasMore = { above: true, below: false } — пролистали в конец
|
||||||
|
// t.hasMore = { above: false, below: false } — всё помещается / нет страниц
|
||||||
|
```
|
||||||
|
|
||||||
|
`hasMore.below` присутствует всегда. `hasMore.above` тоже обычно есть — определяется по кнопкам пагинации (`vertButtonScroll`, есть у большинства дин-списков) или треку скроллбара (у табчастей). Отсутствует только в редких случаях, когда у грида нет ни кнопок, ни видимого скроллбара — тогда трактуйте отсутствие как «неизвестно».
|
||||||
|
|
||||||
|
**Колонки-картинки.** Ячейки, где выводится иконка (статусы, этапы, индикатор ЭДО, скрепка «есть файл»), читаются как `'pic:<N>'` при наличии иконки (`N` — индекс кадра/состояния) и `''` при её отсутствии. Присутствие читается как truthy, разные иконки различаются по индексу:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const t = await readTable();
|
||||||
|
if (t.rows[0]['Присоединенные файлы']) { /* у строки есть прикреплённый файл */ }
|
||||||
|
t.rows[0]['ЭДО'] === 'pic:1'; // подключён к 1С-ЭДО ('pic:0' = нет)
|
||||||
|
```
|
||||||
|
|
||||||
|
Колонки без текста в заголовке (одна иконка) тоже попадают в `columns`, именуются по тултипу заголовка или `'(picture)'` — служебное имя колонки 1С в браузер не передаёт. Картиночные значения — **только для чтения и ассертов**: отбирать/фильтровать строки по `'pic:N'` нельзя (фильтр по такому значению бросает понятную ошибку, расширенный поиск 1С такое поле не покажет). Для выбора строки фильтруйте по текстовой колонке; кликать по картиночной ячейке можно по индексу строки.
|
||||||
|
|
||||||
|
#### clickElement — клик по ячейке (spreadsheet или грид формы)
|
||||||
|
|
||||||
|
Первый аргумент `clickElement` принимает объект `{ row, column }` вместо текста. Маршрутизация автоматическая: если на форме отрисован SpreadsheetDocument (отчёт) — кликаем туда (drill-down), иначе — по ячейке грида (табчасть, список). Параметр `table: 'ИмяГрида'` принудительно указывает грид, если на форме одновременно есть отчёт и таблицы.
|
||||||
|
|
||||||
|
**SpreadsheetDocument (drill-down отчёта).** Координаты соответствуют выводу `readSpreadsheet()`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const report = await readSpreadsheet();
|
const report = await readSpreadsheet();
|
||||||
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000' }
|
// report.data[0] = { 'К1': 'Материалы строительные', 'К6': '150 000' }
|
||||||
|
|
||||||
// По индексу строки данных + имя колонки
|
await clickElement({ row: 0, column: 'К6' }, { dblclick: true }); // по индексу
|
||||||
await clickElement({ row: 0, column: 'К6' }, { dblclick: true });
|
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true }); // по фильтру
|
||||||
|
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true }); // итоги
|
||||||
// По значению ячейки в строке (fuzzy match)
|
await clickElement('150 000', { dblclick: true }); // fallback: по тексту в iframe'ах
|
||||||
await clickElement({ row: { 'К1': 'Материалы' }, column: 'К6' }, { dblclick: true });
|
|
||||||
|
|
||||||
// Строка итогов
|
|
||||||
await clickElement({ row: 'totals', column: 'К6' }, { dblclick: true });
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Текстовый поиск тоже работает — если элемент не найден в основном DOM, `clickElement` ищет в SpreadsheetDocument iframe'ах:
|
**Грид формы (табчасть документа, список каталога/журнала).** Колонка вне viewport — авто-скролл по горизонтали (с учётом frozen-колонок). `scroll: true | number` включает reveal-loop через PageDown для filter-row за пределами DOM-окна:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
await clickElement('150 000', { dblclick: true }); // найдёт ячейку в отчёте
|
await clickElement({ row: 0, column: 'Количество' }, { table: 'Товары', dblclick: true });
|
||||||
|
await clickElement({ row: { 'Номенклатура': 'Бумага' }, column: 'Цена' }, { table: 'Товары' });
|
||||||
|
await clickElement(
|
||||||
|
{ row: { 'Номер': '0000-000601' }, column: 'Сумма' },
|
||||||
|
{ table: 'Реализации', scroll: true } // PageDown loop, лимит по умолчанию 50
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Подводные камни:**
|
||||||
|
- `row: <число>` — индекс в **текущем DOM-окне**, не абсолютный (1С виртуализирует длинные списки). Для произвольной строки в длинном списке — `row: { col: val }` + `scroll: true`.
|
||||||
|
- `scroll: true` идёт только **вниз** (PageDown). Для вверх — `page.keyboard.press('Home')` через `getPage()` или сначала `filterList`.
|
||||||
|
- На дубликаты при фильтре — первая подходящая строка. Уточняйте фильтр для disambiguation.
|
||||||
|
|
||||||
### Действия
|
### Действия
|
||||||
|
|
||||||
|
Все action-функции возвращают **плоский form state** (как `getFormState()`) с action-specific extras (`clicked`, `focused`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`). Errors всегда на верхнем уровне `.errors` — exec-wrapper автоматически throw'ает на soft validation errors (`modal`/`balloon`).
|
||||||
|
|
||||||
| Функция | Описание | Возвращает |
|
| Функция | Описание | Возвращает |
|
||||||
|---------|----------|------------|
|
|---------|----------|------------|
|
||||||
| `clickElement(text, {dblclick?, modifier?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке SpreadsheetDocument (см. выше) | form state или `{ submenu }` |
|
| `clickElement(text, {dblclick?, modifier?, table?, scroll?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке spreadsheet или грида формы (`table` форсит грид; `scroll: true \| number` включает reveal-loop через PageDown — см. выше). Если `text` не совпал ни с одним контролом и `table` не задан — как последний fallback фокусирует одноимённое поле ввода (без изменения значения), см. раздел про клавиши | form state (`clicked` / `focused` / `submenu`) |
|
||||||
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | `{ filled: [{field, ok, method}], form }` |
|
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | form state с `filled` |
|
||||||
| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` |
|
| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` |
|
||||||
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state |
|
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (per-field ошибки как items `ok: false`, см. ниже) + `notFilled?` |
|
||||||
| `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state |
|
| `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state |
|
||||||
| `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да". Возвращает `closed: true/false` | form state с `closed` |
|
| `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да". Возвращает `closed: true/false` | form state с `closed` |
|
||||||
| `filterList(text, {field?, exact?})` | Фильтр списка. Без field = все колонки, с field = расширенный поиск | form state |
|
| `filterList(text, {field?, exact?})` | Фильтр списка. Без field = все колонки, с field = расширенный поиск | form state |
|
||||||
@@ -333,27 +364,33 @@ await clickElement('150 000', { dblclick: true }); // найдёт ячейку
|
|||||||
|
|
||||||
## Клавиатурные сочетания
|
## Клавиатурные сочетания
|
||||||
|
|
||||||
|
Чтобы клавиша применилась к нужному полю, его сперва надо сфокусировать. `clickElement('ИмяПоля')` (без `table`) ставит фокус, ничего не меняя, и возвращает `focused: { field, id, ok }` — после этого жмём клавишу через `getPage()`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
await clickElement('Контрагент'); // фокус на ссылочное поле (focused.ok)
|
||||||
const page = await getPage();
|
const page = await getPage();
|
||||||
await page.keyboard.press('F8'); // пример: создать новый элемент в сфокусированном ссылочном поле
|
await page.keyboard.press('F4'); // открыть форму выбора
|
||||||
```
|
```
|
||||||
|
|
||||||
| Клавиша | Контекст | Действие |
|
| Клавиша | Контекст | Действие |
|
||||||
|---------|----------|----------|
|
|---------|----------|----------|
|
||||||
| `F8` | Ссылочное поле | Создать новый элемент |
|
| `F8` | Ссылочное поле | Создать новый элемент (может требовать прав/настройки в 1С) |
|
||||||
| `Shift+F4` | Любое поле | Очистить значение (автоматизировано: `fillFields({ поле: '' })`) |
|
| `Shift+F4` | Любое поле | Очистить значение (автоматизировано: `fillFields({ поле: '' })`) |
|
||||||
| `F4` | Ссылочное поле | Форма выбора |
|
| `F4` | Ссылочное поле | Форма выбора |
|
||||||
| `Alt+F` | Список/таблица | Расширенный поиск |
|
| `Alt+F` | Список/таблица | Расширенный поиск |
|
||||||
|
|
||||||
## Типичные ошибки
|
## Типичные ошибки
|
||||||
|
|
||||||
Все функции бросают исключение при ошибке (не возвращают `{ error }`). Сценарий прерывается на проблемном шаге с информативным сообщением. В интерактиве — `try/catch` для обработки.
|
Большинство функций бросают исключение при ошибке. Сценарий прерывается на проблемном шаге с информативным сообщением. В интерактиве — `try/catch` для обработки.
|
||||||
|
|
||||||
|
**Исключение — `fillTableRow`**: на per-field ошибках не throws, а возвращает их в `filled[]` как items с `ok: false` (`{ field, ok: false, error: 'code', message: '...' }`). Это позволяет частичное восстановление: например при `error: 'composite_type'` модель может retry'нуть конкретную ячейку с `{ value, type }` синтаксисом, не перезаполняя всю строку. Проверка — `r.filled.filter(f => !f.ok)`. Жёсткие ошибки (нет формы, table не найдена) и soft validation errors от 1С (balloon/modal) — всё равно throws.
|
||||||
|
|
||||||
| Проблема | Решение |
|
| Проблема | Решение |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `no form found` — форма не открыта | Добавьте `await wait(2)` после навигации |
|
| `no form found` — форма не открыта | Добавьте `await wait(2)` после навигации |
|
||||||
| `not found. Available: ...` — элемент не найден | Проверьте имя через `getFormState()`, используйте вариант из Available |
|
| `not found. Available: ...` — элемент не найден | Проверьте имя через `getFormState()`, используйте вариант из Available |
|
||||||
| `fillFields: N of M field(s) failed` | Текст ошибки содержит список проблемных полей и доступные варианты |
|
| `fillFields: N of M field(s) failed` | Текст ошибки содержит список проблемных полей и доступные варианты |
|
||||||
|
| `fillTableRow` вернул item с `ok: false` | См. поле `error` — `composite_type` → retry с `{value, type}`; `column_not_found` → проверьте имя поля через `readTable`; `not_found` → уточните значение поиска |
|
||||||
| Пустой `readSpreadsheet()` | Увеличьте `await wait(N)` перед чтением |
|
| Пустой `readSpreadsheet()` | Увеличьте `await wait(N)` перед чтением |
|
||||||
|
|
||||||
## Особенности
|
## Особенности
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# Регрессионное тестирование прикладного решения
|
||||||
|
|
||||||
|
Навык `/web-test` умеет не только разово выполнить сценарий в браузере, но и сопровождать прикладное решение полноценным набором автотестов: каждый тест — отдельный файл, с шагами, проверками, тегами, отчётом и видеозаписью падений. После каждой правки конфигурации модель прогоняет весь набор и показывает, что ожидаемо ведёт себя как раньше, а что сломалось.
|
||||||
|
|
||||||
|
```
|
||||||
|
правка конфигурации → загрузка → обновление → публикация → прогон тестов → отчёт
|
||||||
|
```
|
||||||
|
|
||||||
|
Это про прикладное решение в целом, не про разовую проверку одной формы. Для разовых сценариев («открой накладную, проверь сумму») по-прежнему удобнее интерактивный режим из [web-test-guide.md](web-test-guide.md).
|
||||||
|
|
||||||
|
## Предусловия
|
||||||
|
|
||||||
|
- База опубликована через Apache (`/web-publish`).
|
||||||
|
- Установлен Node.js 18+, зависимости подняты: `cd .claude/skills/web-test/scripts && npm install`.
|
||||||
|
- ffmpeg — нужен только если хотите видеозапись прогона как доказательство падения. Без него падения фиксируются скриншотами. Установка описана в [web-test-recording-guide.md](web-test-recording-guide.md).
|
||||||
|
|
||||||
|
## Как это устроено
|
||||||
|
|
||||||
|
Набор тестов живёт в каталоге `tests/` вашего проекта. Каждое прикладное решение — отдельная подпапка. Внутри подпапки:
|
||||||
|
|
||||||
|
- `_hooks.mjs` — подготовка стенда (восстановление базы, публикация) и общая очистка после прогона. Необязателен.
|
||||||
|
- `webtest.config.mjs` — адрес базы и набор пользователей (например, кладовщик и менеджер для процессов согласования). Необязателен — если в проекте один пользователь и один URL, можно обойтись без него.
|
||||||
|
- Сами тесты — файлы `*.test.mjs`, сгруппированные по функциональным папкам.
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
моя-конфигурация/
|
||||||
|
_hooks.mjs
|
||||||
|
webtest.config.mjs
|
||||||
|
01-вход/
|
||||||
|
01-открытие-базы.test.mjs
|
||||||
|
02-контрагенты/
|
||||||
|
01-создание.test.mjs
|
||||||
|
02-правка-телефона.test.mjs
|
||||||
|
03-поступление-товаров/
|
||||||
|
01-оформление.test.mjs
|
||||||
|
02-проведение.test.mjs
|
||||||
|
04-отчёт-остатки/
|
||||||
|
01-формирование.test.mjs
|
||||||
|
05-согласование/
|
||||||
|
01-полный-цикл.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Порядок выполнения — по алфавиту, поэтому удобно префиксовать папки и файлы номерами. Это даёт предсказуемый сценарий: сначала вход, потом справочники, потом документы, потом отчёты, в конце — процессы с несколькими пользователями.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
Самый короткий путь от нуля до зелёного теста — попросить модель пройти ваш сценарий руками и зафиксировать его как тест:
|
||||||
|
|
||||||
|
```
|
||||||
|
> Покрой регрессом справочник Контрагенты в моей конфигурации.
|
||||||
|
> Нужны проверки: создание, правка телефона, удаление.
|
||||||
|
```
|
||||||
|
|
||||||
|
Что сделает модель:
|
||||||
|
|
||||||
|
1. Соберёт информацию о справочнике через `/meta-info` и `/form-info` — посмотрит реквизиты и форму элемента, чтобы знать правильные имена полей.
|
||||||
|
2. Подключится к опубликованной базе в интерактивном режиме и **руками пройдёт** каждый сценарий — создание, правка, удаление. Это нужно, чтобы зафиксировать настоящие имена кнопок, увидеть, какие диалоги показывает 1С, понять, требуется ли подтверждение сохранения.
|
||||||
|
3. Зафиксирует пройденный сценарий как файл `tests/<ваша-конфигурация>/02-контрагенты/01-создание.test.mjs`.
|
||||||
|
4. Запустит его и покажет результат.
|
||||||
|
|
||||||
|
При следующих прогонах ничего этого делать не нужно — модель просто запустит готовый набор.
|
||||||
|
|
||||||
|
## Сценарии работы с моделью
|
||||||
|
|
||||||
|
### Покрытие регрессом доработанного объекта
|
||||||
|
|
||||||
|
```
|
||||||
|
> Я добавил в справочник Номенклатура реквизит "Цена" и "Активен".
|
||||||
|
> Покрой это регрессом — создание, редактирование, фильтрация по активности
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель:
|
||||||
|
- посмотрит структуру справочника и формы (через `/meta-info`, `/form-info`);
|
||||||
|
- интерактивно проверит, как ведут себя новые поля в браузере;
|
||||||
|
- сгенерирует 2-3 тестовых файла под папкой `02-номенклатура/`;
|
||||||
|
- прогонит — покажет, что зелёное, что красное.
|
||||||
|
|
||||||
|
### Тест процесса с несколькими пользователями
|
||||||
|
|
||||||
|
```
|
||||||
|
> Сделай тест для процесса согласования приходных накладных.
|
||||||
|
> Кладовщик создаёт накладную, менеджер утверждает,
|
||||||
|
> кладовщик видит обновлённый статус
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель настроит в `webtest.config.mjs` двух пользователей (с разными URL базы — например, `app-clerk` и `app-manager`), напишет тест, который оркестрирует переключение между ними, и положит его в `05-согласование/`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const contexts = ['кладовщик', 'менеджер'];
|
||||||
|
|
||||||
|
export default async function({ кладовщик, менеджер, step, assert }) {
|
||||||
|
await step('Кладовщик создаёт накладную', async () => {
|
||||||
|
await кладовщик.navigateSection('Склад');
|
||||||
|
await кладовщик.openCommand('Приходные накладные');
|
||||||
|
await кладовщик.clickElement('Создать');
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
await step('Менеджер утверждает', async () => {
|
||||||
|
await менеджер.navigateSection('Согласование');
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Учтите ограничение по лицензиям 1С: каждый одновременно открытый пользователь — это занятая клиентская лицензия. Если в наборе много многопользовательских тестов, а на стенде лицензий впритык, прогоны начнут спотыкаться на «свободных лицензий не осталось». Модель освобождает сессии между тестами автоматически (закрывает контексты после процессного теста), но если стенд ограничен — закладывайте это в планирование набора: один-два многопользовательских сценария вместо десяти.
|
||||||
|
|
||||||
|
### Воспроизведение ошибки тестом
|
||||||
|
|
||||||
|
```
|
||||||
|
> При проведении накладной без заполненного контрагента у нас не появляется
|
||||||
|
> ошибка валидации, документ просто проводится с пустым контрагентом — это баг.
|
||||||
|
> Зафиксируй это падающим тестом
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель воспроизведёт сценарий, напишет тест с проверкой «должна быть ошибка», получит красный — потом, когда вы поправите конфигурацию и попросите перепрогнать, тест станет зелёным. Это документирует ожидаемое поведение в виде кода.
|
||||||
|
|
||||||
|
### Прогон регресса после изменений
|
||||||
|
|
||||||
|
```
|
||||||
|
> Я обновил расширение, накатил в базу. Прогони регресс
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель запустит весь набор, дождётся завершения и расскажет:
|
||||||
|
- сколько тестов прошло, сколько упало, сколько пропущено;
|
||||||
|
- по каждому упавшему — что именно сломалось (название шага, сообщение об ошибке, ссылка на скриншот);
|
||||||
|
- классифицирует падения: это ошибка в самом тесте (нужно поправить тест), ошибка в приложении (баг, который вы внесли изменением), или нестабильность стенда (Apache не ответил вовремя, лицензия не освободилась).
|
||||||
|
|
||||||
|
```
|
||||||
|
> Прогони только тесты по контрагентам с подробным отчётом
|
||||||
|
```
|
||||||
|
|
||||||
|
Запустит подмножество — фильтр по тегу или папке, с записью JSON-отчёта.
|
||||||
|
|
||||||
|
### Подготовка автономного стенда
|
||||||
|
|
||||||
|
Если вы хотите, чтобы регресс можно было запустить «с нуля» — даже на чистой машине без подготовленной базы, — модель настроит автоматическую подготовку стенда:
|
||||||
|
|
||||||
|
```
|
||||||
|
> Сделай, чтобы перед прогоном тестов база восстанавливалась из эталона,
|
||||||
|
> а после прогона публикация снималась
|
||||||
|
```
|
||||||
|
|
||||||
|
Это пишется один раз в файле `_hooks.mjs`: при запуске тестов запускается подготовка (через навыки `/db-create`, `/db-load-xml`, `/web-publish`), а после — очистка. Внутри предусмотрено кэширование: если ничего не менялось со прошлого прогона, повторная подготовка занимает доли секунды.
|
||||||
|
|
||||||
|
## Пример организации покрытия
|
||||||
|
|
||||||
|
Допустим, у нас условное прикладное решение «Учёт поступлений товаров» — справочники контрагентов и номенклатуры, документ приходной накладной, отчёт остатков, процесс согласования с двумя пользователями. Логично организовать набор так:
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/учёт-поступлений/
|
||||||
|
_hooks.mjs # подготовка: восстановление базы + публикация
|
||||||
|
webtest.config.mjs # URL базы, контексты кладовщика и менеджера
|
||||||
|
01-вход/
|
||||||
|
01-открытие-базы.test.mjs # базовая работоспособность: вход проходит, разделы видны
|
||||||
|
02-навигация-по-разделам.test.mjs # обход всех разделов конфигурации
|
||||||
|
02-контрагенты/
|
||||||
|
01-создание.test.mjs # создание, проверка появления в списке
|
||||||
|
02-редактирование.test.mjs # правка реквизита, проверка сохранения
|
||||||
|
03-удаление.test.mjs # удаление с подтверждением
|
||||||
|
03-номенклатура/
|
||||||
|
01-создание.test.mjs
|
||||||
|
02-фильтр-по-активности.test.mjs # быстрая фильтрация списка
|
||||||
|
04-поступление-товаров/
|
||||||
|
01-оформление.test.mjs # заполнение шапки и табличной части
|
||||||
|
02-проведение.test.mjs # проведение документа, проверка движений
|
||||||
|
03-отмена-проведения.test.mjs
|
||||||
|
04-валидация-обязательных.test.mjs # негативный тест: пустой контрагент → ошибка
|
||||||
|
05-отчёт-остатки/
|
||||||
|
01-формирование.test.mjs
|
||||||
|
02-отбор-по-складу.test.mjs
|
||||||
|
03-расшифровка.test.mjs # переход из ячейки отчёта в исходный документ
|
||||||
|
06-согласование/
|
||||||
|
01-полный-цикл.test.mjs # многопользовательский тест
|
||||||
|
```
|
||||||
|
|
||||||
|
Принципы:
|
||||||
|
|
||||||
|
- **Папки — по бизнес-функции**, не по типу метаданных. Лучше `04-поступление-товаров/` (что делает пользователь), чем `документы/` (что лежит в дереве конфигурации).
|
||||||
|
- **Цифровые префиксы** — на папке и на файле. Гарантируют, что сначала отработают базовые проверки (вход, справочники), потом сложные (документы, отчёты, процессы). При падении базы остальное и так не пройдёт — нет смысла занимать стенд получасом.
|
||||||
|
- **Один файл — одна логически связанная история.** Не «всё про контрагентов в одном файле», а «отдельно создание, отдельно правка, отдельно удаление». Когда падает — сразу видно, какой именно сценарий сломан.
|
||||||
|
- **Негативные тесты тоже есть.** «Документ без контрагента не проводится» — такой же важный регресс, как и позитивный сценарий, особенно после правок в обработчиках проверки заполнения.
|
||||||
|
- **Процессные тесты — в конце.** Они самые хрупкие (зависят от двух сессий, лицензий, синхронизации) и самые длинные. Если упадут — у вас уже есть данные от предыдущих тестов.
|
||||||
|
|
||||||
|
## Анатомия одного теста
|
||||||
|
|
||||||
|
Пользователь, как правило, тест не пишет — генерирует модель. Но прочитать и поправить полезно уметь. Стандартный файл выглядит так:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const name = 'Создание контрагента';
|
||||||
|
export const tags = ['контрагенты', 'базовая-проверка'];
|
||||||
|
export const timeout = 60000;
|
||||||
|
|
||||||
|
export default async function({
|
||||||
|
navigateSection, openCommand, clickElement, fillFields,
|
||||||
|
readTable, closeForm, assert, step
|
||||||
|
}) {
|
||||||
|
await step('Открыть список контрагентов', async () => {
|
||||||
|
await navigateSection('Продажи');
|
||||||
|
await openCommand('Контрагенты');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Создать нового контрагента', async () => {
|
||||||
|
await clickElement('Создать');
|
||||||
|
await fillFields({ 'Наименование': 'ТД Тест', 'ИНН': '7707083893' });
|
||||||
|
await clickElement('Записать и закрыть');
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('Убедиться, что элемент появился в списке', async () => {
|
||||||
|
const t = await readTable();
|
||||||
|
assert.tableHasRow(t, r => r['Наименование'] === 'ТД Тест');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Что здесь есть:
|
||||||
|
|
||||||
|
- **`name`** — человекочитаемое имя теста. Появится в отчёте.
|
||||||
|
- **`tags`** — теги для фильтрации. Можно прогонять не весь набор, а только нужные: `--tags=контрагенты`.
|
||||||
|
- **`timeout`** — сколько максимум тест может идти. По умолчанию 30 секунд, для длинных сценариев увеличиваем.
|
||||||
|
- **Тело теста** — функция, которая получает API браузера (см. [SKILL.md](../.claude/skills/web-test/SKILL.md)) плюс `assert` и `step`.
|
||||||
|
- **`step('имя', async () => {...})`** — обёртка шага. Имена шагов попадают в отчёт, при падении видно, какой именно шаг сломался.
|
||||||
|
- **`assert.*`** — проверки. `assert.tableHasRow`, `assert.equal`, `assert.ok` и т.д. Если проверка не выполнилась — тест считается упавшим.
|
||||||
|
|
||||||
|
Имена шагов и теста — по-русски, описательные. Они показываются и в консоли, и в отчётах.
|
||||||
|
|
||||||
|
## Запуск и отчёты
|
||||||
|
|
||||||
|
### Простой прогон
|
||||||
|
|
||||||
|
```
|
||||||
|
> Прогони регресс
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель запустит весь набор, дождётся, покажет сводку:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Открытие базы (2.1s)
|
||||||
|
✓ Создание контрагента (8.4s)
|
||||||
|
✗ Проведение приходной накладной (12.7s)
|
||||||
|
└ Заполнить табличную часть (5.2s)
|
||||||
|
Не найден столбец "Цена" в табличной части "Товары"
|
||||||
|
screenshot: tests/учёт-поступлений/error-shot.png
|
||||||
|
|
||||||
|
23 passed, 1 failed, 0 skipped (3m 42s)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Подробный отчёт
|
||||||
|
|
||||||
|
```
|
||||||
|
> Прогони регресс и сохрани подробный отчёт
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель добавит флаг записи отчёта (JSON или Allure) — потом по нему можно листать историю прогонов, видеть длительности шагов, открывать прикреплённые скриншоты.
|
||||||
|
|
||||||
|
Allure — стандартный визуальный отчёт с категориями падений, графиками, таймлайном. Чтобы посмотреть отчёт после прогона:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Allure CLI устанавливается отдельно (npm install -g allure-commandline)
|
||||||
|
allure serve allure-results
|
||||||
|
```
|
||||||
|
|
||||||
|
### Категории падений в Allure
|
||||||
|
|
||||||
|
Без дополнительной настройки Allure складывает все упавшие тесты в один общий список «Defects». Если в прогоне упало 15 тестов, не сразу понятно, что из этого — пятнадцать разных проблем или одна и та же ошибка (например, нехватка лицензии на стенде), которая зацепила пятнадцать тестов подряд.
|
||||||
|
|
||||||
|
Чтобы Allure группировал падения по причинам, рядом с тестами кладётся каталог `_allure/` с файлом `categories.json`. Подчёркивание в имени каталога — чтобы он не воспринимался как папка с тестами; раннер копирует его содержимое в отчёт.
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/моя-конфигурация/
|
||||||
|
_allure/
|
||||||
|
categories.json # классификация падений
|
||||||
|
environment.properties # необязательно: URL, версия 1С, ветка git
|
||||||
|
executor.json # необязательно: метаданные сборки CI
|
||||||
|
_hooks.mjs
|
||||||
|
01-вход/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
`categories.json` — это список регулярных выражений, по которым ошибка теста относится к той или иной группе:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "Нехватка лицензий 1С",
|
||||||
|
"matchedStatuses": ["failed", "broken"],
|
||||||
|
"messageRegex": ".*Не обнаружено свободной лицензии.*" },
|
||||||
|
{ "name": "Ошибка приложения 1С",
|
||||||
|
"matchedStatuses": ["failed"],
|
||||||
|
"messageRegex": ".*(ВызватьИсключение|В поле введены некорректные данные|Произошла ошибка).*" },
|
||||||
|
{ "name": "Элемент не найден",
|
||||||
|
"matchedStatuses": ["failed"],
|
||||||
|
"messageRegex": ".*(clickElement|fillFields|selectValue).*not found.*" },
|
||||||
|
{ "name": "Превышен лимит времени теста",
|
||||||
|
"matchedStatuses": ["failed", "broken"],
|
||||||
|
"messageRegex": "Timeout \\(\\d+ms\\)" },
|
||||||
|
{ "name": "Несовпадение ожидания и факта",
|
||||||
|
"matchedStatuses": ["failed"],
|
||||||
|
"messageRegex": "(Expected|AssertionError).*" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Когда вы попросите модель в первый раз настроить регресс, она положит шаблонный `categories.json` со стандартными классами. По мере того как вы будете находить новые типичные причины падений (например, специфичные для вашего расширения тексты ошибок), категории дополняются.
|
||||||
|
|
||||||
|
В виджете «Categories» итогового отчёта вы увидите примерно так:
|
||||||
|
|
||||||
|
```
|
||||||
|
Нехватка лицензий 1С — 12 падений
|
||||||
|
Ошибка приложения 1С — 2 падения
|
||||||
|
Несовпадение ожидания и факта — 1 падение
|
||||||
|
```
|
||||||
|
|
||||||
|
— и сразу понятно, что 12 падений — это один стенд-баг, а двумя «ошибками приложения» нужно разобраться по существу.
|
||||||
|
|
||||||
|
Помимо `categories.json` в каталог `_allure/` можно положить ещё два стандартных файла:
|
||||||
|
|
||||||
|
- **`environment.properties`** — список `ключ=значение` (URL базы, версия платформы 1С, имя ветки git, номер сборки). Покажется в отчёте в виджете «Environment». Полезно, когда регресс гоняется на нескольких стендах или после каждого билда — видно, на чём именно был получен результат. Этот файл удобно генерировать прямо в подготовке стенда (`_hooks.mjs`), а не держать статичной копией.
|
||||||
|
- **`executor.json`** — метаданные системы сборки: ссылка на Jenkins-задачу, идентификатор запуска GitHub Actions и т.д. Нужен только если регресс запускается на сервере сборки. При локальном прогоне ничего класть не надо.
|
||||||
|
|
||||||
|
### Прогон части набора
|
||||||
|
|
||||||
|
```
|
||||||
|
> Прогони только тесты по поступлениям товаров
|
||||||
|
> Прогони только базовые проверки
|
||||||
|
> Прогони только упавший вчера тест с проведением накладной
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель выберет нужное подмножество — по папке, по тегу или по имени теста.
|
||||||
|
|
||||||
|
### Принудительная пересборка стенда
|
||||||
|
|
||||||
|
Если хотите, чтобы перед прогоном база восстановилась с нуля:
|
||||||
|
|
||||||
|
```
|
||||||
|
> Прогони регресс с полной пересборкой стенда
|
||||||
|
```
|
||||||
|
|
||||||
|
Это передаст в подготовку флаг типа `--rebuild-stand` — `_hooks.mjs` пересоздаст базу из эталона. Полезно после крупных правок или если подозреваете, что предыдущие прогоны загрязнили данные.
|
||||||
|
|
||||||
|
## Что делать, когда тест упал
|
||||||
|
|
||||||
|
Модель проанализирует падение и отнесёт его к одной из трёх категорий:
|
||||||
|
|
||||||
|
1. **Ошибка в самом тесте.** Например, переименовали реквизит — тест ищет старое имя поля. Решение: модель обновит тест.
|
||||||
|
2. **Ошибка в приложении.** Это и есть то, ради чего регресс существует: что-то поменялось в конфигурации, и сценарий, который раньше работал, теперь не отрабатывает. Модель опишет, что именно произошло, со скриншотом и трассировкой стека 1С, если ошибка была серверной.
|
||||||
|
3. **Нестабильность стенда.** Apache не ответил, не освободилась лицензия, база отвалилась. Это лечится не правкой теста, а починкой подготовки стенда в `_hooks.mjs` или, реже, повторным прогоном с одним повтором.
|
||||||
|
|
||||||
|
Просите модель не «исправь упавший тест», а «разберись с падением» — иначе она может молча подкрутить ожидание под текущее поведение, замаскировав настоящий баг.
|
||||||
|
|
||||||
|
## Полезные подробности
|
||||||
|
|
||||||
|
### Тестовые данные
|
||||||
|
|
||||||
|
В прикладном решении обычно нужны какие-то стартовые данные: пара контрагентов, номенклатура, заведённые организации. Их кладём не в каждый тест, а один раз в подготовку стенда (`_hooks.mjs`) — после восстановления базы загружаются эталонные данные, на которых работают все тесты.
|
||||||
|
|
||||||
|
Если конкретному тесту нужны свои данные (например, документ, который мы будем редактировать), он создаёт их сам в начале и убирает в конце.
|
||||||
|
|
||||||
|
### Имена документов и уникальность
|
||||||
|
|
||||||
|
Тесты прогоняются многократно. Если тест создаёт документ «Накладная-Тест», следующий прогон может натолкнуться на старую запись. Решение — добавлять к имени метку времени:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const метка = 'Тест-' + Date.now();
|
||||||
|
await fillFields({ 'Комментарий': метка });
|
||||||
|
// ...
|
||||||
|
const t = await readTable();
|
||||||
|
assert.tableHasRow(t, r => r['Комментарий'] === метка);
|
||||||
|
```
|
||||||
|
|
||||||
|
Модель это делает автоматически, но если правите тест руками — держите в голове.
|
||||||
|
|
||||||
|
### Видео при падении
|
||||||
|
|
||||||
|
Можно включить запись видео всех тестов — тогда при падении прикладывается не только скриншот, но и MP4 со всей сессией:
|
||||||
|
|
||||||
|
```
|
||||||
|
> Прогони регресс с записью видео
|
||||||
|
```
|
||||||
|
|
||||||
|
Размер прогона при этом растёт (на 2-3 минутах теста выходит 5-10 МБ), но при отладке сложного падения видео экономит кучу времени.
|
||||||
|
|
||||||
|
### Многоязычные конфигурации
|
||||||
|
|
||||||
|
Если у вас есть конфигурация с командами и реквизитами на нескольких языках, тесты пишутся под один язык (как правило, тот, в котором ведётся работа в проде). При смене языка интерфейса в браузере тесты не пройдут — модель видит другие подписи кнопок.
|
||||||
|
|
||||||
|
## Где смотреть дальше
|
||||||
|
|
||||||
|
- API браузера, которое вызывают тесты — [SKILL.md](../.claude/skills/web-test/SKILL.md).
|
||||||
|
- Подробная инструкция для модели по написанию тестов (на английском, технический документ) — [.claude/skills/web-test/regress.md](../.claude/skills/web-test/regress.md).
|
||||||
|
- Интерактивный режим без тестов — [web-test-guide.md](web-test-guide.md).
|
||||||
|
- Запись видеоинструкций — [web-test-recording-guide.md](web-test-recording-guide.md).
|
||||||
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -108,6 +108,20 @@ node tests/skills/verify-snapshots.mjs --help # полный
|
|||||||
|
|
||||||
`params` — параметры для навыка. Используются через `case.<field>` и `workPath` в `_skill.json`.
|
`params` — параметры для навыка. Используются через `case.<field>` и `workPath` в `_skill.json`.
|
||||||
|
|
||||||
|
`expect.stdoutContains` / `expect.stdoutNotContains` — строка **или массив строк**. Каждая подстрока проверяется на наличие (`stdoutContains`) или отсутствие (`stdoutNotContains`) в stdout навыка. Удобно для info-навыков: проверить, что нужная строка есть, а лишней — нет.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Представление типа у ПВХ",
|
||||||
|
"setup": "external:C:/WS/tasks/cfsrc/erp_8.3.24",
|
||||||
|
"params": { "objectPath": "ChartsOfCharacteristicTypes/ВидыСубконтоХозрасчетные" },
|
||||||
|
"expect": {
|
||||||
|
"stdoutContains": ["Представление типа: Вид субконто", "Представление объекта: Вид субконто"],
|
||||||
|
"stdoutNotContains": "Представление списка:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### С дополнительными CLI-аргументами
|
### С дополнительными CLI-аргументами
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -175,7 +189,7 @@ node tests/skills/verify-snapshots.mjs --help # полный
|
|||||||
| `outputPath` | нет | Относительный путь для навыков с `-OutputPath` |
|
| `outputPath` | нет | Относительный путь для навыков с `-OutputPath` |
|
||||||
| `args_extra` | нет | Массив дополнительных CLI-аргументов |
|
| `args_extra` | нет | Массив дополнительных CLI-аргументов |
|
||||||
| `preRun` | нет | Массив шагов подготовки (создание объектов и т.п.) |
|
| `preRun` | нет | Массив шагов подготовки (создание объектов и т.п.) |
|
||||||
| `expect` | нет | Дополнительные проверки: `files`, `stdoutContains` |
|
| `expect` | нет | Дополнительные проверки: `files`, `stdoutContains` (строка/массив), `stdoutNotContains` (строка/массив) |
|
||||||
| `expectError` | нет | `true` или строка — ожидается ошибка |
|
| `expectError` | нет | `true` или строка — ожидается ошибка |
|
||||||
|
|
||||||
## Эталоны (snapshots)
|
## Эталоны (snapshots)
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// build-webtest-db v0.2 — Собирает синтетическую web-test конфигурацию в постоянные пути
|
||||||
|
// и накатывает её в зарегистрированную базу `webtest` (см. .v8-project.json).
|
||||||
|
//
|
||||||
|
// Двойной режим:
|
||||||
|
// - CLI: node tests/skills/build-webtest-db.mjs [--runtime ...] [--skip-platform]
|
||||||
|
// - Module: import { runSteps, execSkill, getProjectInfo, ... } from './build-webtest-db.mjs'
|
||||||
|
//
|
||||||
|
// CLI:
|
||||||
|
// node tests/skills/build-webtest-db.mjs # пересобрать с нуля
|
||||||
|
// node tests/skills/build-webtest-db.mjs --runtime python
|
||||||
|
// node tests/skills/build-webtest-db.mjs --skip-platform # только XML, без db-create/load/update
|
||||||
|
//
|
||||||
|
// После завершения база готова к /web-publish + web-test сессии.
|
||||||
|
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join, resolve, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const ROOT = dirname(__filename);
|
||||||
|
const REPO_ROOT = resolve(ROOT, '../..');
|
||||||
|
const SKILLS = resolve(REPO_ROOT, '.claude/skills');
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads .v8-project.json and locates webtest registration.
|
||||||
|
* @returns {{ v8path: string, v8exe: string, webtestDb: object, configSrc: string, dbPath: string }}
|
||||||
|
*/
|
||||||
|
export function getProjectInfo() {
|
||||||
|
const projectFile = join(REPO_ROOT, '.v8-project.json');
|
||||||
|
if (!existsSync(projectFile)) throw new Error('.v8-project.json not found');
|
||||||
|
const proj = JSON.parse(readFileSync(projectFile, 'utf8'));
|
||||||
|
const webtestDb = proj.databases?.find(d => d.id === 'webtest');
|
||||||
|
if (!webtestDb) throw new Error('Database "webtest" not registered in .v8-project.json');
|
||||||
|
const v8path = proj.v8path;
|
||||||
|
const v8exe = join(v8path, '1cv8.exe');
|
||||||
|
const dbPath = webtestDb.path;
|
||||||
|
const configSrc = resolve(REPO_ROOT, webtestDb.configSrc);
|
||||||
|
return { v8path, v8exe, webtestDb, configSrc, dbPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a skill script path to an absolute file (chooses .ps1 or .py based on runtime).
|
||||||
|
*/
|
||||||
|
export function resolveScript(scriptRelPath, runtime = 'powershell') {
|
||||||
|
const ext = runtime === 'python' ? '.py' : '.ps1';
|
||||||
|
const full = join(SKILLS, scriptRelPath + ext);
|
||||||
|
if (!existsSync(full)) throw new Error(`Script not found: ${full}`);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a single skill script with provided arguments.
|
||||||
|
* @returns {Promise<string>} stdout
|
||||||
|
*/
|
||||||
|
export function execSkill(scriptPath, args, runtime = 'powershell') {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
const cmd = runtime === 'python'
|
||||||
|
? [process.env.PYTHON || 'python', [scriptPath, ...args]]
|
||||||
|
: ['powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]];
|
||||||
|
execFile(cmd[0], cmd[1], { encoding: 'utf8', timeout: 120_000, cwd: REPO_ROOT }, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
rej(new Error(stderr?.trim() || stdout?.trim() || err.message));
|
||||||
|
} else {
|
||||||
|
res(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces {workDir}/{v8path}/{dbPath} placeholders in a string value.
|
||||||
|
*/
|
||||||
|
export function replacePlaceholders(s, paths) {
|
||||||
|
return String(s)
|
||||||
|
.replace('{workDir}', paths.workDir ?? '')
|
||||||
|
.replace('{v8path}', paths.v8path ?? '')
|
||||||
|
.replace('{dbPath}', paths.dbPath ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes an array of build steps.
|
||||||
|
*
|
||||||
|
* Each step: { name, script?, args?, input?, writeFile?, content? }
|
||||||
|
* - writeFile: write content to a file (relative to workDir or absolute), skip script call
|
||||||
|
* - script: relative path under .claude/skills (without extension)
|
||||||
|
* - args: { '-Flag': value | true }, value may contain {workDir}/{v8path}/{dbPath}/{inputFile}
|
||||||
|
* - input: JSON object written to __input.json (referenced by {inputFile} in args)
|
||||||
|
*
|
||||||
|
* @param {Array} steps
|
||||||
|
* @param {{ workDir: string, v8path: string, dbPath: string }} paths
|
||||||
|
* @param {string} runtime 'powershell' | 'python'
|
||||||
|
* @param {(line: string) => void} log
|
||||||
|
* @returns {Promise<{ ok: boolean, elapsed: number, failedAt?: number }>}
|
||||||
|
*/
|
||||||
|
export async function runSteps(steps, paths, runtime, log = console.log) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const step = steps[i];
|
||||||
|
const stepT0 = Date.now();
|
||||||
|
|
||||||
|
if (step.writeFile) {
|
||||||
|
try {
|
||||||
|
const target = replacePlaceholders(step.writeFile, paths);
|
||||||
|
const abs = target.includes(':') || target.startsWith('/') ? target : join(paths.workDir, target);
|
||||||
|
mkdirSync(dirname(abs), { recursive: true });
|
||||||
|
writeFileSync(abs, step.content ?? '', 'utf8');
|
||||||
|
const ms = Date.now() - stepT0;
|
||||||
|
log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`);
|
||||||
|
} catch (e) {
|
||||||
|
log(` [${i + 1}/${steps.length}] FAIL ${step.name}: ${e.message}`);
|
||||||
|
return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputFile = null;
|
||||||
|
if (step.input) {
|
||||||
|
inputFile = join(paths.workDir, '__input.json');
|
||||||
|
writeFileSync(inputFile, JSON.stringify(step.input, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = resolveScript(step.script, runtime);
|
||||||
|
const args = [];
|
||||||
|
for (const [flag, value] of Object.entries(step.args || {})) {
|
||||||
|
args.push(flag);
|
||||||
|
if (value === true) continue;
|
||||||
|
let v = String(value).replace('{inputFile}', inputFile || '');
|
||||||
|
v = replacePlaceholders(v, paths);
|
||||||
|
args.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execSkill(script, args, runtime);
|
||||||
|
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
|
||||||
|
const ms = Date.now() - stepT0;
|
||||||
|
log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`);
|
||||||
|
} catch (e) {
|
||||||
|
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
|
||||||
|
log(` [${i + 1}/${steps.length}] FAIL ${step.name}`);
|
||||||
|
log(` ${e.message.split('\n').join('\n ').substring(0, 1500)}`);
|
||||||
|
return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, elapsed: (Date.now() - t0) / 1000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the standard platform load steps (db-create + db-load-xml + db-update).
|
||||||
|
*/
|
||||||
|
export function platformLoadSteps() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'db-create: создание файловой ИБ',
|
||||||
|
script: 'db-create/scripts/db-create',
|
||||||
|
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'db-load-xml: загрузка конфигурации',
|
||||||
|
script: 'db-load-xml/scripts/db-load-xml',
|
||||||
|
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}', '-ConfigDir': '{workDir}' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'db-update: обновление БД',
|
||||||
|
script: 'db-update/scripts/db-update',
|
||||||
|
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the build-webtest-config.test.mjs steps array.
|
||||||
|
*/
|
||||||
|
export async function loadBuildSteps() {
|
||||||
|
const buildModule = await import(`file://${join(ROOT, 'integration/build-webtest-config.test.mjs').replace(/\\/g, '/')}`);
|
||||||
|
return buildModule.steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runCli() {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const opts = { runtime: 'powershell', skipPlatform: false };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--runtime' && argv[i + 1]) { opts.runtime = argv[++i]; continue; }
|
||||||
|
if (a === '--skip-platform') { opts.skipPlatform = true; continue; }
|
||||||
|
if (a === '-h' || a === '--help') {
|
||||||
|
console.log('Usage: build-webtest-db.mjs [--runtime powershell|python] [--skip-platform]');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { v8path, v8exe, configSrc, dbPath } = getProjectInfo();
|
||||||
|
|
||||||
|
if (!opts.skipPlatform && !existsSync(v8exe)) {
|
||||||
|
console.error(`1cv8.exe not found at ${v8exe}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[build-webtest-db] configSrc: ${configSrc}`);
|
||||||
|
console.log(`[build-webtest-db] dbPath: ${dbPath}`);
|
||||||
|
console.log(`[build-webtest-db] runtime: ${opts.runtime}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (existsSync(configSrc)) {
|
||||||
|
console.log(`Removing existing configSrc...`);
|
||||||
|
rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||||
|
}
|
||||||
|
mkdirSync(configSrc, { recursive: true });
|
||||||
|
|
||||||
|
if (!opts.skipPlatform && existsSync(dbPath)) {
|
||||||
|
console.log(`Removing existing IB...`);
|
||||||
|
rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSteps = await loadBuildSteps();
|
||||||
|
const platformSteps = opts.skipPlatform ? [] : platformLoadSteps();
|
||||||
|
const allSteps = [...buildSteps, ...platformSteps];
|
||||||
|
|
||||||
|
const paths = { workDir: configSrc, v8path, dbPath };
|
||||||
|
const result = await runSteps(allSteps, paths, opts.runtime, console.log);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error(`Build FAILED after ${result.elapsed.toFixed(1)}s`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Build OK (${result.elapsed.toFixed(1)}s)`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` configSrc: ${configSrc}`);
|
||||||
|
if (!opts.skipPlatform) {
|
||||||
|
console.log(` IB: ${dbPath}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(` Next: /web-publish webtest → open in browser`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI guard: run only when invoked directly, not when imported.
|
||||||
|
const invokedDirectly = process.argv[1]
|
||||||
|
? fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
||||||
|
: false;
|
||||||
|
if (invokedDirectly) {
|
||||||
|
runCli().catch(e => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "Таблица с колонкой-картинкой (PictureField + ValuesPicture + Selection)",
|
||||||
|
"preRun": [
|
||||||
|
{
|
||||||
|
"script": "meta-compile/scripts/meta-compile",
|
||||||
|
"input": { "type": "DataProcessor", "name": "КартинкаВСтроке" },
|
||||||
|
"args": { "-JsonPath": "{inputFile}", "-OutputDir": "{workDir}" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"script": "form-add/scripts/form-add",
|
||||||
|
"args": { "-ObjectPath": "{workDir}/DataProcessors/КартинкаВСтроке.xml", "-FormName": "Форма" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"params": { "outputPath": "DataProcessors/КартинкаВСтроке/Forms/Форма/Ext/Form.xml" },
|
||||||
|
"validatePath": "DataProcessors/КартинкаВСтроке/Forms/Форма/Ext/Form.xml",
|
||||||
|
"input": {
|
||||||
|
"title": "Картинка в строке",
|
||||||
|
"elements": [
|
||||||
|
{ "table": "ТаблицаДанных", "path": "ТаблицаДанных",
|
||||||
|
"on": ["Selection"], "handlers": { "Selection": "ТаблицаДанныхВыбор" },
|
||||||
|
"columns": [
|
||||||
|
{ "input": "ТаблицаДанныхНоменклатура", "path": "ТаблицаДанных.Номенклатура" },
|
||||||
|
{ "picField": "ТаблицаДанныхКартинка", "path": "ТаблицаДанных.Картинка", "valuesPicture": "StdPicture.Favorites", "loadTransparent": true },
|
||||||
|
{ "check": "ТаблицаДанныхКартинкаФлаг", "path": "ТаблицаДанных.Картинка", "title": "Флаг" }
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"attributes": [
|
||||||
|
{ "name": "Объект", "type": "DataProcessorObject.КартинкаВСтроке", "main": true },
|
||||||
|
{ "name": "ТаблицаДанных", "type": "ValueTable", "title": "Таблица данных", "columns": [
|
||||||
|
{ "name": "Номенклатура", "type": "string(10)" },
|
||||||
|
{ "name": "Картинка", "type": "boolean" }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -38,6 +38,7 @@
|
|||||||
<v8:content>Файл</v8:content>
|
<v8:content>Файл</v8:content>
|
||||||
</v8:item>
|
</v8:item>
|
||||||
</Title>
|
</Title>
|
||||||
|
<ChoiceButton>true</ChoiceButton>
|
||||||
<InputHint>
|
<InputHint>
|
||||||
<v8:item>
|
<v8:item>
|
||||||
<v8:lang>ru</v8:lang>
|
<v8:lang>ru</v8:lang>
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||||
|
<Configuration uuid="UUID-001">
|
||||||
|
<InternalInfo>
|
||||||
|
<xr:ContainedObject>
|
||||||
|
<xr:ClassId>UUID-002</xr:ClassId>
|
||||||
|
<xr:ObjectId>UUID-003</xr:ObjectId>
|
||||||
|
</xr:ContainedObject>
|
||||||
|
<xr:ContainedObject>
|
||||||
|
<xr:ClassId>UUID-004</xr:ClassId>
|
||||||
|
<xr:ObjectId>UUID-005</xr:ObjectId>
|
||||||
|
</xr:ContainedObject>
|
||||||
|
<xr:ContainedObject>
|
||||||
|
<xr:ClassId>UUID-006</xr:ClassId>
|
||||||
|
<xr:ObjectId>UUID-007</xr:ObjectId>
|
||||||
|
</xr:ContainedObject>
|
||||||
|
<xr:ContainedObject>
|
||||||
|
<xr:ClassId>UUID-008</xr:ClassId>
|
||||||
|
<xr:ObjectId>UUID-009</xr:ObjectId>
|
||||||
|
</xr:ContainedObject>
|
||||||
|
<xr:ContainedObject>
|
||||||
|
<xr:ClassId>UUID-010</xr:ClassId>
|
||||||
|
<xr:ObjectId>UUID-011</xr:ObjectId>
|
||||||
|
</xr:ContainedObject>
|
||||||
|
<xr:ContainedObject>
|
||||||
|
<xr:ClassId>UUID-012</xr:ClassId>
|
||||||
|
<xr:ObjectId>UUID-013</xr:ObjectId>
|
||||||
|
</xr:ContainedObject>
|
||||||
|
<xr:ContainedObject>
|
||||||
|
<xr:ClassId>UUID-014</xr:ClassId>
|
||||||
|
<xr:ObjectId>UUID-015</xr:ObjectId>
|
||||||
|
</xr:ContainedObject>
|
||||||
|
</InternalInfo>
|
||||||
|
<Properties>
|
||||||
|
<Name>TestConfig</Name>
|
||||||
|
<Synonym>
|
||||||
|
<v8:item>
|
||||||
|
<v8:lang>ru</v8:lang>
|
||||||
|
<v8:content>TestConfig</v8:content>
|
||||||
|
</v8:item>
|
||||||
|
</Synonym>
|
||||||
|
<Comment />
|
||||||
|
<NamePrefix />
|
||||||
|
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||||
|
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||||
|
<UsePurposes>
|
||||||
|
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
|
</UsePurposes>
|
||||||
|
<ScriptVariant>Russian</ScriptVariant>
|
||||||
|
<DefaultRoles />
|
||||||
|
<Vendor></Vendor>
|
||||||
|
<Version></Version>
|
||||||
|
<UpdateCatalogAddress />
|
||||||
|
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||||
|
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||||
|
<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
|
||||||
|
<AdditionalFullTextSearchDictionaries />
|
||||||
|
<CommonSettingsStorage />
|
||||||
|
<ReportsUserSettingsStorage />
|
||||||
|
<ReportsVariantsStorage />
|
||||||
|
<FormDataSettingsStorage />
|
||||||
|
<DynamicListsUserSettingsStorage />
|
||||||
|
<URLExternalDataStorage />
|
||||||
|
<Content />
|
||||||
|
<DefaultReportForm />
|
||||||
|
<DefaultReportVariantForm />
|
||||||
|
<DefaultReportSettingsForm />
|
||||||
|
<DefaultReportAppearanceTemplate />
|
||||||
|
<DefaultDynamicListSettingsForm />
|
||||||
|
<DefaultSearchForm />
|
||||||
|
<DefaultDataHistoryChangeHistoryForm />
|
||||||
|
<DefaultDataHistoryVersionDataForm />
|
||||||
|
<DefaultDataHistoryVersionDifferencesForm />
|
||||||
|
<DefaultCollaborationSystemUsersChoiceForm />
|
||||||
|
<RequiredMobileApplicationPermissions />
|
||||||
|
<UsedMobileApplicationFunctionalities>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Biometrics</app:functionality>
|
||||||
|
<app:use>true</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Location</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>BackgroundLocation</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>BluetoothPrinters</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>WiFiPrinters</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Contacts</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Calendars</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>PushNotifications</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>LocalNotifications</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>InAppPurchases</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>PersonalComputerFileExchange</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Ads</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>NumberDialing</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>CallProcessing</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>CallLog</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>AutoSendSMS</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>ReceiveSMS</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>SMSLog</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Camera</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Microphone</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>MusicLibrary</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>PictureAndVideoLibraries</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>AudioPlaybackAndVibration</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>BackgroundAudioPlaybackAndVibration</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>InstallPackages</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>OSBackup</app:functionality>
|
||||||
|
<app:use>true</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>ApplicationUsageStatistics</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>BarcodeScanning</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>BackgroundAudioRecording</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>AllFilesAccess</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Videoconferences</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>NFC</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>DocumentScanning</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>SpeechToText</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>Geofences</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>IncomingShareRequests</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
<app:functionality>
|
||||||
|
<app:functionality>AllIncomingShareRequestsTypesProcessing</app:functionality>
|
||||||
|
<app:use>false</app:use>
|
||||||
|
</app:functionality>
|
||||||
|
</UsedMobileApplicationFunctionalities>
|
||||||
|
<StandaloneConfigurationRestrictionRoles />
|
||||||
|
<MobileApplicationURLs />
|
||||||
|
<AllowedIncomingShareRequestTypes />
|
||||||
|
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
||||||
|
<DefaultInterface />
|
||||||
|
<DefaultStyle />
|
||||||
|
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||||
|
<BriefInformation />
|
||||||
|
<DetailedInformation />
|
||||||
|
<Copyright />
|
||||||
|
<VendorInformationAddress />
|
||||||
|
<ConfigurationInformationAddress />
|
||||||
|
<DataLockControlMode>Managed</DataLockControlMode>
|
||||||
|
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||||
|
<ModalityUseMode>DontUse</ModalityUseMode>
|
||||||
|
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
|
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
||||||
|
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
|
<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||||
|
<DefaultConstantsForm />
|
||||||
|
</Properties>
|
||||||
|
<ChildObjects>
|
||||||
|
<Language>Русский</Language>
|
||||||
|
<DataProcessor>КартинкаВСтроке</DataProcessor>
|
||||||
|
</ChildObjects>
|
||||||
|
</Configuration>
|
||||||
|
</MetaDataObject>
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||||
|
<DataProcessor uuid="UUID-001">
|
||||||
|
<InternalInfo>
|
||||||
|
<xr:GeneratedType name="DataProcessorObject.КартинкаВСтроке" category="Object">
|
||||||
|
<xr:TypeId>UUID-002</xr:TypeId>
|
||||||
|
<xr:ValueId>UUID-003</xr:ValueId>
|
||||||
|
</xr:GeneratedType>
|
||||||
|
<xr:GeneratedType name="DataProcessorManager.КартинкаВСтроке" category="Manager">
|
||||||
|
<xr:TypeId>UUID-004</xr:TypeId>
|
||||||
|
<xr:ValueId>UUID-005</xr:ValueId>
|
||||||
|
</xr:GeneratedType>
|
||||||
|
</InternalInfo>
|
||||||
|
<Properties>
|
||||||
|
<Name>КартинкаВСтроке</Name>
|
||||||
|
<Synonym>
|
||||||
|
<v8:item>
|
||||||
|
<v8:lang>ru</v8:lang>
|
||||||
|
<v8:content>Картинка встроке</v8:content>
|
||||||
|
</v8:item>
|
||||||
|
</Synonym>
|
||||||
|
<Comment />
|
||||||
|
<UseStandardCommands>false</UseStandardCommands>
|
||||||
|
<DefaultForm>DataProcessor.КартинкаВСтроке.Form.Форма</DefaultForm>
|
||||||
|
<AuxiliaryForm />
|
||||||
|
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||||
|
<ExtendedPresentation />
|
||||||
|
<Explanation />
|
||||||
|
</Properties>
|
||||||
|
<ChildObjects>
|
||||||
|
<Form>Форма</Form>
|
||||||
|
</ChildObjects>
|
||||||
|
</DataProcessor>
|
||||||
|
</MetaDataObject>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user