mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
merge: dev-web → main — web-test browser automation skill
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: web-test
|
||||
description: Тестирование 1С через веб-клиент — автоматизация действий в браузере. Используй когда пользователь просит проверить, протестировать, автоматизировать действия в 1С через браузер
|
||||
argument-hint: "сценарий на естественном языке"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /web-test — Browser automation for 1C web client
|
||||
|
||||
Automates user interactions with 1C:Enterprise web client via Playwright — navigating sections, filling forms, reading tables and reports, filtering lists.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
RUN=".claude/skills/web-test/scripts/run.mjs"
|
||||
|
||||
# One-shot: opens browser → runs script → closes browser → exits
|
||||
node $RUN run http://localhost:8081/bpdemo test-scenario.js
|
||||
|
||||
# Or pipe inline:
|
||||
cat <<'SCRIPT' | node $RUN run http://localhost:8081/bpdemo -
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Заказы клиентов');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Клиент': 'Альфа' });
|
||||
await clickElement('Провести и закрыть');
|
||||
SCRIPT
|
||||
```
|
||||
|
||||
## Setup (first time)
|
||||
|
||||
```bash
|
||||
cd .claude/skills/web-test/scripts && npm install
|
||||
```
|
||||
|
||||
Requires Node.js 18+. `npm install` downloads Playwright and Chromium.
|
||||
|
||||
## URL resolution
|
||||
|
||||
Read `.v8-project.json` from project root. Each database has `id` and optional `webUrl`.
|
||||
Construct URL as `http://localhost:8081/<id>` or use `webUrl` if set.
|
||||
Use `/web-publish` first if the database is not published.
|
||||
|
||||
## Execution modes
|
||||
|
||||
### Autonomous mode (preferred for complete scenarios)
|
||||
|
||||
```bash
|
||||
node $RUN run <url> script.js # exits when done, no session
|
||||
```
|
||||
|
||||
### Interactive mode (step-by-step development)
|
||||
|
||||
```bash
|
||||
# 1. Start session (run_in_background=true, prints JSON when ready)
|
||||
node $RUN start <url>
|
||||
|
||||
# 2. Execute scripts against running session
|
||||
cat <<'SCRIPT' | node $RUN exec -
|
||||
const form = await getFormState();
|
||||
console.log(JSON.stringify(form, null, 2));
|
||||
SCRIPT
|
||||
|
||||
# 3. Screenshot
|
||||
node $RUN shot result.png
|
||||
|
||||
# 4. Stop (logout + close)
|
||||
node $RUN stop
|
||||
```
|
||||
|
||||
`start` runs an HTTP server in background. Use `exec`/`shot`/`stop` from other shells.
|
||||
|
||||
### Writing exec scripts
|
||||
|
||||
All browser.mjs exports are globals — no `import` needed.
|
||||
`console.log()` output is captured in the JSON response.
|
||||
`writeFileSync` / `readFileSync` also available.
|
||||
|
||||
## API reference
|
||||
|
||||
### Navigation
|
||||
|
||||
#### `navigateSection(name)` → `{ navigated, sections, commands }`
|
||||
Go to a top-level section (fuzzy match). Returns list of commands in that section.
|
||||
```js
|
||||
await navigateSection('Продажи');
|
||||
// { navigated: 'Продажи', sections: [...], commands: ['Заказы клиентов', ...] }
|
||||
```
|
||||
|
||||
#### `openCommand(name)` → form state
|
||||
Open a command from the function panel (fuzzy). Returns form state of the opened form.
|
||||
```js
|
||||
const form = await openCommand('Заказы клиентов');
|
||||
```
|
||||
|
||||
#### `navigateLink(url)` → form state
|
||||
Open any 1C object by metadata path (Shift+F11 dialog). Bypasses section/command navigation.
|
||||
```js
|
||||
await navigateLink('Документ.ЗаказКлиента');
|
||||
await navigateLink('РегистрНакопления.ЗаказыКлиентов');
|
||||
await navigateLink('Справочник.Контрагенты');
|
||||
```
|
||||
|
||||
#### `switchTab(name)` → form state
|
||||
Switch to an already-open tab/window (fuzzy match).
|
||||
|
||||
### Reading form state
|
||||
|
||||
#### `getFormState()` → `{ fields, buttons, tabs, table, filters, reportSettings? }`
|
||||
Returns current form structure. This is the primary way to understand what's on screen.
|
||||
|
||||
**fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields)
|
||||
|
||||
**table** — summary only: `{ name, columns, rowCount }`. Use `readTable()` for actual data.
|
||||
|
||||
**reportSettings** — for DCS reports: human-readable filter settings instead of raw technical names:
|
||||
```js
|
||||
const form = await getFormState();
|
||||
// form.reportSettings = [
|
||||
// { name: "Склад", enabled: true, value: "Склад бытовой техники", actions: ["select"] },
|
||||
// { name: "Номенклатура", enabled: false, value: "" }
|
||||
// ]
|
||||
```
|
||||
|
||||
**errorModal** — if present, 1C showed an error dialog. Read the message and decide how to proceed.
|
||||
|
||||
**confirmation** — if present, a Yes/No dialog is shown. Call `clickElement('Да')` or `clickElement('Нет')`.
|
||||
|
||||
### Reading data
|
||||
|
||||
#### `readTable({ maxRows?, offset? })` → `{ columns, rows, total, shown, offset }`
|
||||
Read actual grid data with pagination. Each row is `{ columnName: value }`.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `maxRows` | 20 | Max rows to return per call |
|
||||
| `offset` | 0 | Skip first N rows |
|
||||
|
||||
Special row fields:
|
||||
- `_kind: 'group'` — hierarchical group row
|
||||
- `_kind: 'parent'` — parent row in hierarchy
|
||||
- `_tree: 'expanded'|'collapsed'` — tree node state
|
||||
- `_level: N` — nesting depth in tree view
|
||||
- `hierarchical: true` — list has groups (on result object)
|
||||
- `viewMode: 'tree'` — tree view active (on result object)
|
||||
|
||||
```js
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
console.log('Columns:', t.columns);
|
||||
console.log('Rows:', t.rows.length, 'of', t.total);
|
||||
// Pagination:
|
||||
const page2 = await readTable({ maxRows: 50, offset: 50 });
|
||||
```
|
||||
|
||||
#### `readSpreadsheet()` → `{ title?, headers?, data?, totals?, rows?, total }`
|
||||
Read report output (SpreadsheetDocument) after clicking "Сформировать".
|
||||
|
||||
Returns structured data when header row is detected:
|
||||
```js
|
||||
await clickElement('Сформировать');
|
||||
await wait(5);
|
||||
const report = await readSpreadsheet();
|
||||
// { title: "Остатки товаров", headers: ["Номенклатура", "Склад", "Количество"],
|
||||
// data: [{ "Номенклатура": "Бумага", "Склад": "Основной", "Количество": "150" }, ...],
|
||||
// totals: { "Количество": "1250" }, total: 42 }
|
||||
```
|
||||
|
||||
Falls back to `{ rows: string[][], total }` when headers can't be detected.
|
||||
|
||||
#### `getSections()` → `{ activeSection, sections, commands }`
|
||||
Read section panel and commands without navigating.
|
||||
|
||||
#### `getCommands()` → `string[]`
|
||||
Commands of the current section.
|
||||
|
||||
#### `getPageState()` → `{ activeSection, activeTab, sections, tabs }`
|
||||
Sections + all open tabs.
|
||||
|
||||
### Actions
|
||||
|
||||
#### `clickElement(text, { dblclick? })` → form state
|
||||
Click button, hyperlink, tab, or grid row (fuzzy match).
|
||||
|
||||
- Single click selects a row in a list. **Double-click opens** the item:
|
||||
```js
|
||||
await clickElement('0000-000227', { dblclick: true }); // opens document
|
||||
```
|
||||
- Returns `submenu[]` when a menu opens — click again with item name:
|
||||
```js
|
||||
const r = await clickElement('Ещё');
|
||||
// r.submenu = ['Расширенный поиск', 'Настройки', ...]
|
||||
await clickElement('Расширенный поиск');
|
||||
```
|
||||
- Handles tree nodes: clicking a tree icon expands/collapses.
|
||||
|
||||
#### `fillFields({ name: value })` → `{ filled, form }`
|
||||
Fill form fields by label (fuzzy match). Auto-detects field type.
|
||||
|
||||
| Value | Field type | Method |
|
||||
|-------|-----------|--------|
|
||||
| `'Конфетпром'` | Reference | Clipboard paste + typeahead |
|
||||
| `'5000'` | Plain text | Clipboard paste |
|
||||
| `'true'` / `'да'` | Checkbox | Toggle |
|
||||
| `'Оплата поставщику'` | Radio | Fuzzy label match |
|
||||
|
||||
**DCS report filters**: use human-readable label names. Checkbox is auto-enabled:
|
||||
```js
|
||||
await fillFields({
|
||||
'Склад': 'Склад бытовой техники', // auto-enables "Склад" checkbox + fills value
|
||||
'Номенклатура': 'Вентилятор' // same: enables checkbox + fills
|
||||
});
|
||||
```
|
||||
|
||||
Returns `{ filled: [{ field, ok, value, method }], form: {...} }`.
|
||||
Method is one of: `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | `'typeahead'`
|
||||
|
||||
#### `selectValue(field, search)` → 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.
|
||||
|
||||
```js
|
||||
await selectValue('Организация', 'Конфетпром');
|
||||
// result.selected = { field: 'Организация', search: 'Конфетпром', method: 'dropdown'|'form' }
|
||||
```
|
||||
|
||||
Also supports DCS labels — auto-enables the paired checkbox.
|
||||
|
||||
#### `fillTableRow(fields, opts)` → form state
|
||||
Fill table row cells via Tab navigation.
|
||||
|
||||
```js
|
||||
// Add new row:
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Бумага', 'Количество': '10', 'Цена': '100' },
|
||||
{ tab: 'Товары', add: true }
|
||||
);
|
||||
// Edit existing row:
|
||||
await fillTableRow(
|
||||
{ 'Количество': '20' },
|
||||
{ tab: 'Товары', row: 0 }
|
||||
);
|
||||
```
|
||||
|
||||
- Tab-based sequential navigation — field order set by 1C form config
|
||||
- Fuzzy cell match: "Количество" matches "ТоварыКоличество"
|
||||
- Reference cells auto-detected by autocomplete popup
|
||||
|
||||
#### `deleteTableRow(row, { tab? })` → form state
|
||||
Delete row by 0-based index.
|
||||
|
||||
#### `closeForm({ save? })` → form state
|
||||
Close the current form via Escape.
|
||||
|
||||
| Argument | Behavior |
|
||||
|----------|----------|
|
||||
| `{ save: false }` | Auto-clicks "Нет" on confirmation |
|
||||
| `{ save: true }` | Auto-clicks "Да" on confirmation |
|
||||
| `{}` (omitted) | Returns `confirmation` field if dialog appears |
|
||||
|
||||
Preferred over `clickElement('×')` — close buttons on tabs are ambiguous.
|
||||
|
||||
#### `filterList(text, opts?)` → form state
|
||||
Filter list. Simple mode searches all columns, advanced mode targets a specific field.
|
||||
|
||||
```js
|
||||
await filterList('КП00-000018'); // simple — all columns
|
||||
await filterList('Мишка', { field: 'Наименование' }); // advanced — specific column
|
||||
await filterList('Мишка', { field: 'Наименование', exact: true }); // exact match
|
||||
```
|
||||
|
||||
Works on hierarchical catalogs too (flattens the view).
|
||||
|
||||
#### `unfilterList({ field? })` → form state
|
||||
Clear filters. Without arguments clears all, with `{ field }` clears specific badge.
|
||||
|
||||
### Utility
|
||||
|
||||
#### `screenshot()` → PNG Buffer
|
||||
#### `wait(seconds)` → form state
|
||||
#### `getPage()` → Playwright Page (raw, for advanced scripting)
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Create and save a document
|
||||
|
||||
```js
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Заказы клиентов');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Организация': 'Конфетпром', 'Контрагент': 'Альфа' });
|
||||
await fillTableRow({ 'Номенклатура': 'Бумага', 'Количество': '10' }, { tab: 'Товары', add: true });
|
||||
await clickElement('Провести и закрыть');
|
||||
```
|
||||
|
||||
### Open item from list
|
||||
|
||||
```js
|
||||
await clickElement('КП00-000227', { dblclick: true });
|
||||
// Always use { dblclick: true } — single click only selects the row
|
||||
```
|
||||
|
||||
### Work with hierarchical lists
|
||||
|
||||
```js
|
||||
await filterList('Конфетпром'); // flatten + search
|
||||
await clickElement('Конфетпром ООО', { dblclick: true }); // open
|
||||
await closeForm();
|
||||
await unfilterList(); // restore hierarchy
|
||||
```
|
||||
|
||||
### Generate and read a report
|
||||
|
||||
```js
|
||||
// Fill report filters using readable labels
|
||||
await fillFields({ 'Склад': 'Основной склад' });
|
||||
await clickElement('Сформировать');
|
||||
await wait(5);
|
||||
const report = await readSpreadsheet();
|
||||
console.log('Title:', report.title);
|
||||
console.log('Data rows:', report.data?.length);
|
||||
```
|
||||
|
||||
### Keyboard shortcuts (via `page.keyboard.press`)
|
||||
|
||||
| Key | Context | Action |
|
||||
|-----|---------|--------|
|
||||
| `F8` | Reference field focused | Create new catalog item |
|
||||
| `Shift+F4` | Reference field focused | Clear field value |
|
||||
| `F4` | Reference field focused | Open selection form |
|
||||
| `Alt+F` | List/table form | Open advanced search dialog |
|
||||
|
||||
### Closing forms — which method to use
|
||||
|
||||
| Goal | Method |
|
||||
|------|--------|
|
||||
| Post & close document | `clickElement('Провести и закрыть')` |
|
||||
| Save & close catalog | `clickElement('Записать и закрыть')` |
|
||||
| Close without saving | `closeForm({ save: false })` |
|
||||
| Close and save | `closeForm({ save: true })` |
|
||||
| Close (manual confirm) | `closeForm()` — returns `confirmation` if dialog appears |
|
||||
|
||||
## Exec response format
|
||||
|
||||
```json
|
||||
{ "ok": true, "output": "...console.log output...", "elapsed": 3.2 }
|
||||
```
|
||||
|
||||
On error (auto-screenshot taken):
|
||||
```json
|
||||
{ "ok": false, "error": "Element not found", "output": "...", "screenshot": "error-shot.png", "elapsed": 1.5 }
|
||||
```
|
||||
|
||||
## Avoiding loops
|
||||
|
||||
- **Max 2 attempts per operation.** If an action fails twice with the same approach — stop and report to the user
|
||||
- **Not found = not found.** If `filterList` returns 0 rows or `readTable` is empty after filtering — the item likely doesn't exist in this database. Don't retry the same search 5 times with slight variations
|
||||
- **Try a different approach, not the same one.** Couldn't find via section navigation? Try `navigateLink`. Couldn't find via simple search? Try advanced search with a specific field. But don't repeat the same method
|
||||
- **Report partial results.** If you found the list but not the specific item — say so. Show what IS available instead of silently retrying
|
||||
|
||||
## Important notes
|
||||
|
||||
- **Headed mode** — 1C requires visible browser, no headless
|
||||
- **Startup time** — 1C loads 30-60s on initial connect (built into `start`)
|
||||
- **Fuzzy matching** — all name lookups: exact > startsWith > includes
|
||||
- **Clipboard paste** — all text fields filled via Ctrl+V (triggers 1C events properly)
|
||||
- **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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "1c-web-test",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "1c-web-test",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"playwright": "^1.50.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "1c-web-test",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Browser automation engine for 1C web client (Playwright)",
|
||||
"dependencies": {
|
||||
"playwright": "^1.50.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI runner for 1C web client automation.
|
||||
*
|
||||
* Architecture: `start` launches browser + HTTP server in one process.
|
||||
* `exec`, `shot`, `stop` send requests to the running server.
|
||||
*
|
||||
* Usage:
|
||||
* 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 exec <file|-> — run script against existing session
|
||||
* node src/run.mjs shot [file] — take screenshot
|
||||
* node src/run.mjs stop — logout + close browser
|
||||
* node src/run.mjs status — check session
|
||||
*/
|
||||
import http from 'http';
|
||||
import * as browser from './browser.mjs';
|
||||
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json');
|
||||
|
||||
const [,, cmd, ...args] = process.argv;
|
||||
|
||||
switch (cmd) {
|
||||
case 'start': await cmdStart(args[0]); break;
|
||||
case 'run': await cmdRun(args[0], args[1]); break;
|
||||
case 'exec': await cmdExec(args[0]); break;
|
||||
case 'shot': await cmdShot(args[0]); break;
|
||||
case 'stop': await cmdStop(); break;
|
||||
case 'status': cmdStatus(); break;
|
||||
default: usage();
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// start: launch browser + HTTP server
|
||||
// ============================================================
|
||||
|
||||
async function cmdStart(url) {
|
||||
if (!url) die('Usage: node src/run.mjs start <url>');
|
||||
|
||||
// Connect to 1C
|
||||
const state = await browser.connect(url);
|
||||
|
||||
// Start HTTP server for exec/shot/stop
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
try {
|
||||
if (req.method === 'POST' && req.url === '/exec') {
|
||||
const code = await readBody(req);
|
||||
const result = await executeScript(code);
|
||||
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) {
|
||||
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;
|
||||
|
||||
// Wrap action functions to auto-detect 1C errors (modal, balloon)
|
||||
// and stop execution immediately with diagnostic info
|
||||
const ACTION_FNS = [
|
||||
'clickElement', 'fillFields', 'selectValue', 'fillTableRow',
|
||||
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink',
|
||||
'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) {
|
||||
const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
|
||||
const err = new Error(msg);
|
||||
err.onecError = { step: name, args, errors, formState: result };
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Error screenshot
|
||||
let 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|->');
|
||||
|
||||
const code = fileOrDash === '-'
|
||||
? await readStdin()
|
||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||
|
||||
const sess = loadSession();
|
||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/exec`, {
|
||||
method: 'POST',
|
||||
body: code
|
||||
});
|
||||
const result = await resp.json();
|
||||
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|-> Execute script (file path or - for stdin)
|
||||
shot [file] Take screenshot (default: shot.png)
|
||||
stop Logout and close browser
|
||||
status Check session status`);
|
||||
}
|
||||
@@ -21,3 +21,10 @@ __pycache__/
|
||||
|
||||
# Локальный реестр баз данных 1С
|
||||
.v8-project.json
|
||||
|
||||
# web-test: Node.js зависимости и runtime-артефакты
|
||||
.claude/skills/web-test/scripts/node_modules/
|
||||
.claude/skills/web-test/.browser-session.json
|
||||
|
||||
# Скриншоты (артефакты тестирования web-test)
|
||||
*.png
|
||||
|
||||
@@ -35,12 +35,14 @@
|
||||
| Командный интерфейс (CI) | 2 навыка `/interface-*` | Редактирование и валидация CommandInterface.xml подсистем | [Подробнее](docs/subsystem-guide.md) |
|
||||
| Базы данных (DB) | 9 навыков `/db-*` | Создание баз, загрузка/выгрузка конфигураций, обновление БД, загрузка из Git | [Подробнее](docs/db-guide.md) |
|
||||
| Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) |
|
||||
| Тестирование (Web) | `/web-test` | Автоматизация 1С через браузер — навигация, формы, таблицы, отчёты, фильтры | [Подробнее](docs/web-test-guide.md) |
|
||||
| Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — |
|
||||
|
||||
## Требования
|
||||
|
||||
- **Windows** с PowerShell 5.1+ (входит в Windows) — рантайм по умолчанию
|
||||
- **1С:Предприятие 8.3** — для сборки/разборки EPF/ERF (навыки генерации XML работают без платформы)
|
||||
- **Node.js 18+** — для `/web-test` (тестирование через браузер)
|
||||
|
||||
### Кроссплатформенный режим (Python)
|
||||
|
||||
@@ -148,6 +150,7 @@ python scripts/switch-to-powershell.py # вернуть на PowerShell
|
||||
├── web-info/ # Статус Apache и публикаций
|
||||
├── web-stop/ # Остановка Apache
|
||||
├── web-unpublish/ # Удаление публикации
|
||||
├── web-test/ # Тестирование через браузер (Playwright)
|
||||
└── img-grid/ # Сетка для анализа изображений
|
||||
scripts/
|
||||
├── switch-to-python.py # Переключение навыков на Python-рантайм
|
||||
@@ -164,6 +167,7 @@ docs/
|
||||
├── subsystem-guide.md # Гайд: подсистемы и командный интерфейс
|
||||
├── db-guide.md # Гайд: базы данных 1С
|
||||
├── web-guide.md # Гайд: веб-публикация через Apache
|
||||
├── web-test-guide.md # Гайд: тестирование через веб-клиент
|
||||
├── 1c-epf-spec.md # Спецификация XML-формата (EPF)
|
||||
├── 1c-erf-spec.md # Спецификация XML-формата (ERF)
|
||||
├── 1c-form-spec.md # Спецификация управляемых форм
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Тестирование через веб-клиент 1С
|
||||
|
||||
Навык `/web-test` автоматизирует действия в веб-клиенте 1С через Playwright — навигация по разделам, заполнение форм, чтение таблиц и отчётов, фильтрация списков. Замыкает цикл: правка исходников → загрузка → обновление → публикация → **автоматическое тестирование**.
|
||||
|
||||
## Навык
|
||||
|
||||
| Навык | Скрипт | Описание |
|
||||
|-------|:------:|----------|
|
||||
| `/web-test` | `.mjs` (Node.js) | Автоматизация 1С через браузер — навигация, формы, таблицы, отчёты |
|
||||
|
||||
## Предусловия
|
||||
|
||||
- База опубликована через Apache (`/web-publish`)
|
||||
- Node.js 18+ установлен
|
||||
- Зависимости установлены: `cd .claude/skills/web-test/scripts && npm install`
|
||||
|
||||
## Рабочий цикл
|
||||
|
||||
```
|
||||
/web-publish → /web-test → результат
|
||||
↑ |
|
||||
└── правки → /db-load-xml → /db-update ──┘
|
||||
```
|
||||
|
||||
### Два режима работы
|
||||
|
||||
**Автономный** — одна команда запускает браузер, выполняет сценарий и закрывает:
|
||||
```
|
||||
> Открой список заказов клиентов в ERP, найди заказ КП00-000018, открой и прочитай реквизиты
|
||||
```
|
||||
Claude напишет `.js` файл со сценарием и запустит `node $RUN run <url> script.js`.
|
||||
|
||||
**Интерактивный** — пошаговая работа через живую сессию:
|
||||
```
|
||||
> Запусти браузер на базе erp
|
||||
> Перейди в раздел Продажи
|
||||
> Открой Заказы клиентов
|
||||
> Прочитай таблицу
|
||||
```
|
||||
Claude запустит `node $RUN start <url>`, затем выполнит каждый шаг через `node $RUN exec`.
|
||||
|
||||
## Сценарии использования
|
||||
|
||||
### Навигация и чтение данных
|
||||
|
||||
```
|
||||
> Открой список контрагентов в ERP и покажи первые 10 записей
|
||||
```
|
||||
|
||||
Claude перейдёт в нужный раздел, откроет список и прочитает таблицу.
|
||||
|
||||
### Создание документа
|
||||
|
||||
```
|
||||
> Создай заказ клиента: организация "Андромеда Плюс", контрагент "Торговый дом Комплексный",
|
||||
> добавь строку: номенклатура "Вентилятор", количество 5
|
||||
```
|
||||
|
||||
Claude откроет форму создания, заполнит реквизиты шапки, добавит строку в табличную часть.
|
||||
|
||||
### Работа с отчётами
|
||||
|
||||
```
|
||||
> Открой отчёт "Остатки и доступность товаров",
|
||||
> установи отбор Склад = "Склад бытовой техники", сформируй и прочитай результат
|
||||
```
|
||||
|
||||
Claude заполнит фильтры отчёта (DCS-настройки) по человекочитаемым именам, нажмёт "Сформировать" и прочитает табличный документ.
|
||||
|
||||
### Поиск и фильтрация
|
||||
|
||||
```
|
||||
> Найди в списке номенклатуры товар "Вентилятор" и открой его карточку
|
||||
```
|
||||
|
||||
Claude отфильтрует список, откроет найденный элемент двойным кликом, прочитает форму.
|
||||
|
||||
### Проверка после загрузки расширения
|
||||
|
||||
```
|
||||
> Загрузи расширение ТестОшибки и проверь через браузер, что при создании заказа клиента
|
||||
> появляется ошибка "Тестовая ошибка из расширения"
|
||||
```
|
||||
|
||||
Claude загрузит расширение через `/db-load-xml`, затем через `/web-test` откроет форму заказа и проверит ожидаемое поведение.
|
||||
|
||||
## API: что умеет навык
|
||||
|
||||
### Навигация
|
||||
|
||||
| Функция | Что делает |
|
||||
|---------|------------|
|
||||
| `navigateSection(name)` | Переход в раздел (Продажи, Склад и доставка, ...) |
|
||||
| `openCommand(name)` | Открытие команды из панели функций |
|
||||
| `navigateLink(url)` | Открытие по пути метаданных (`Документ.ЗаказКлиента`) |
|
||||
| `switchTab(name)` | Переключение между открытыми вкладками |
|
||||
|
||||
### Чтение
|
||||
|
||||
| Функция | Что делает |
|
||||
|---------|------------|
|
||||
| `getFormState()` | Структура формы: поля, кнопки, вкладки, фильтры, DCS-настройки отчёта |
|
||||
| `readTable()` | Данные таблицы с пагинацией, поддержка дерева и иерархии |
|
||||
| `readSpreadsheet()` | Табличный документ отчёта: заголовки, данные, итоги |
|
||||
| `getSections()` | Разделы и команды |
|
||||
|
||||
### Действия
|
||||
|
||||
| Функция | Что делает |
|
||||
|---------|------------|
|
||||
| `clickElement(text)` | Клик по кнопке, ссылке, вкладке, строке таблицы |
|
||||
| `fillFields({...})` | Заполнение полей формы (текст, чекбокс, радио, ссылочные) |
|
||||
| `selectValue(field, search)` | Выбор из справочника через форму подбора |
|
||||
| `fillTableRow({...})` | Заполнение строки табличной части |
|
||||
| `filterList(text)` | Фильтрация списка (простая и расширенная) |
|
||||
| `closeForm()` | Закрытие формы с управлением диалогом подтверждения |
|
||||
|
||||
### DCS-отчёты (фильтры по меткам)
|
||||
|
||||
Фильтры отчётов можно задавать человекочитаемыми именами вместо технических:
|
||||
|
||||
```js
|
||||
// Вместо: 'КомпоновщикНастроекПользовательскиеНастройкиЭлемент3Значение'
|
||||
// Просто:
|
||||
await fillFields({ 'Склад': 'Склад бытовой техники' });
|
||||
```
|
||||
|
||||
`getFormState()` возвращает `reportSettings` — фильтры в читаемом виде:
|
||||
```json
|
||||
[
|
||||
{ "name": "Склад", "enabled": true, "value": "Склад бытовой техники" },
|
||||
{ "name": "Номенклатура", "enabled": false, "value": "" }
|
||||
]
|
||||
```
|
||||
|
||||
## Особенности
|
||||
|
||||
- **Headed mode** — 1С требует видимый браузер, headless не поддерживается
|
||||
- **Время запуска** — первое подключение к 1С занимает 30-60 секунд
|
||||
- **Fuzzy matching** — все поиски по имени: точное совпадение → начало строки → вхождение
|
||||
- **Clipboard paste** — все поля заполняются через Ctrl+V (единственный способ корректно триггерить события 1С)
|
||||
- **Anti-loop** — если элемент не найден после 2 попыток, навык сообщает что найдено вместо бесконечных ретраев
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- [Веб-публикация](web-guide.md) — `/web-publish`, `/web-info`, `/web-stop`, `/web-unpublish`
|
||||
- [Базы данных](db-guide.md) — `/db-load-xml`, `/db-update`, `/db-run`
|
||||
- [Расширения](cfe-guide.md) — `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`
|
||||
Reference in New Issue
Block a user