[598-4][@ant] fix(marketplace,harness): variables wrapper для marketplaceSignOnboardingOffer + harness не дёргает host pnpm dev

* desktop OnboardingMemberPickCpp/api: client.Mutation(mut, { input })
  не передавал переменную в GraphQL — правильный pattern
  `{ variables: { input } }` (как в submitCreateOrder). Backend отвечал
  «Variable $input of required type MarketplaceSignOnboardingOfferInput!
  was not provided», подпись ЦПП не доходила до on-chain. После фикса
  `wallet::signagree` (voskhod/ekaterina program_id=2 draft_id=699)
  проходит, marketplace::createorder больше не падает на
  «walletop: не подписано program_id=2».

* docs-harness/bin/shoot.mjs: ветка `wasUp && distRebuilt` делала
  `pkill -f 'quasar.js dev'` на хосте + spawn хостовый `pnpm run dev`.
  Quasar dev живёт в docker-контейнере, pkill на хосте ничего не убивает,
  а параллельный хостовый pnpm dev писал в .q-cache под admin uid, пока
  контейнер пишет под node uid — EACCES, кэш разваливается, Vite
  перестаёт резолвить @coopenomics/sdk. Если порт отвечает — ничего
  не трогаем, vite сам подхватит dist через fs-watch.

* harness order-create.mjs: переписан под полный flow magistral II
  с шагом подписи L3 ЦПП через /market/onboarding/member-cpp +
  debug-снимок 00-marketplace-onboarding-page для будущей диагностики.

Magistral II по L3-подписи разблокирована. Остался отдельный блокер
в MarketplaceOrderCreateService.normalizeTxResult — backend не дождётся
tx_hash от цепи, хотя on-chain action createorder проходит
(out-of-band sync error). Это вне фоллоуапа Эпика 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-22 10:01:34 +00:00
parent e7144e9f3d
commit 1ae4d8959f
3 changed files with 115 additions and 25 deletions
@@ -70,7 +70,7 @@ export async function signOnboardingOffer(): Promise<MarketplaceOnboardingStateV
const { [Mutations.Marketplace.MarketplaceSignOnboardingOffer.name]: result } = await client.Mutation(
Mutations.Marketplace.MarketplaceSignOnboardingOffer.mutation,
{ input: { document: document.signedDocument } },
{ variables: { input: { document: document.signedDocument } } },
);
return result as MarketplaceOnboardingStateView;
}
+13 -12
View File
@@ -180,20 +180,21 @@ function ensureDist() {
return true;
}
// 3. desktop dev: поднимаем если нет; если только что собрали dist и dev уже работал —
// рестартуем, иначе vite-plugin-checker держит кэш «no module» и vue-tsc overlay
// перехватывает клики в playwright (видел на 989-9, фикс через рестарт).
async function ensureDesktop(distRebuilt) {
// 3. desktop dev: если порт уже отвечает — НИЧЕГО не трогаем. Vite в контейнере
// сам подхватит обновления dist через fs-watch на симлинке node_modules.
// Раньше при distRebuilt orchestrator пытался `pkill -f 'quasar.js dev'`
// на хосте + spawn хостовый `pnpm run dev` — но quasar в контейнере
// (mono-ai-4-desktop-1), pkill ничего не находит, а параллельный хостовый
// pnpm dev писал в `node_modules/.q-cache` под admin'ом, тогда как
// контейнер пишет туда под node — EACCES, кэш разваливается, Vite
// перестаёт резолвить `@coopenomics/sdk`, UI отдаёт пустой спиннер.
// Инцидент 2026-05-22: пользователю пришлось руками перезапустить
// контейнер. Урок: если порт отвечает, доверяем vite watch.
async function ensureDesktop(_distRebuilt) {
log(`Проверяю desktop :${PORTS.desktop}...`);
const wasUp = curlOk(`http://127.0.0.1:${PORTS.desktop}`);
if (wasUp && !distRebuilt) { ok('desktop dev живой'); return; }
if (wasUp && distRebuilt) {
log('Перезапускаю desktop dev (после билда dist нужен fresh vue-tsc cache)...');
spawnSync('pkill', ['-f', 'quasar.js dev'], { stdio: 'ignore' });
await sleep(2000);
} else {
log('desktop dev не запущен, поднимаю в фоне...');
}
if (wasUp) { ok('desktop dev живой (vite сам подхватит dist через fs-watch)'); return; }
log('desktop dev не запущен на :' + PORTS.desktop + ', поднимаю в фоне...');
const logFile = '/tmp/desktop-dev.log';
const out = fs.openSync(logFile, 'a');
const child = spawn('pnpm', ['run', 'dev'], {
@@ -1,8 +1,19 @@
// Сценарий: orderer-стол «Оформление заказа».
// MVP-форма — q-dialog поверх каталога: при клике «Заказать» открывается
// OrderCreateDialog с полями quantity + delivery_braname; submit вызывает
// `marketplaceCreateOrder`. По успеху — Notify «Заказ создан», каталог
// перезагружается, остаток APPROVED Offer'а уменьшается.
// Сценарий: orderer-стол «Оформление заказа», магистраль II «вход в каталог».
//
// Полный путь пайщика после фоллоуапа Эпика 1 (вариант A):
// 1. Логин ekaterina + подпись 4 системных agreements (Wallet ЦПП и т.п.).
// 2. Переход на L3 онбординг marketplace (/market/onboarding/member-cpp).
// 3. Канон-виджет OnboardingCPPGate показывает оферту 1101 + Положение ЦПП;
// клик «Подписать оферту» → mutation `marketplaceSignOnboardingOffer` →
// backend сам делает on-chain `wallet::signagree` (program_id=2,
// draft_id из soviet::coagreements). После успеха —
// requires_gate=false, source='agreement_signed', UI редиректит в catalog.
// 4. В каталоге кликаем «Заказать» на APPROVED Offer'е, заполняем
// OrderCreateDialog (quantity + delivery_braname), submit вызывает
// `marketplaceCreateOrder`. По успеху Notify «Заказ создан».
//
// Это закрытие magistral II: ekaterina впервые проходит весь L3 onboarding
// прямо со «Стола заказов», без редиректа в core Registrator-мастер.
import fs from 'node:fs';
import path from 'node:path';
@@ -58,6 +69,83 @@ export default async ({ page, shot }) => {
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
// === Шаг 1: L3 онбординг marketplace ===
// Сначала идём на gate-страницу. Если пайщик ещё не подписал ЦПП —
// увидит OnboardingCPPGate с двумя документами. Если уже подписал —
// banner «Вы уже подключены». Сценарий поддерживает оба исхода:
// на свежей цепочке первый прогон снимет полный flow; повторные
// прогоны пропустят подпись (уже зафиксирована on-chain).
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/onboarding/member-cpp`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(800);
const gate = page.locator('.mp-onboarding-gate').first();
const alreadyBanner = page.locator('text=Вы уже подключены к Marketplace').first();
// Debug: всегда снимаем member-cpp страницу, чтобы понимать что
// backend вернул в onboarding state, даже если ни gate, ни banner не показались.
await cleanViteOverlays(page);
await shot(
page,
'00-marketplace-onboarding-page',
'Debug: страница `/market/onboarding/member-cpp` после загрузки backend `marketplaceOnboardingState`. Если ниже видим gate с двумя документами — пайщик ещё не подписал; banner «Вы уже подключены» — подписал; «возможен временный рассинхрон» — backend вернул что gate не нужен, но и agreement_signed не зафиксирован.',
);
if (await gate.isVisible().catch(() => false)) {
await cleanViteOverlays(page);
await shot(
page,
'01-marketplace-onboarding-gate',
'L3 gate ЦПП «Стол заказов»: канон-виджет OnboardingCPPGate (UX-DR17) показывает два документа — Оферту на присоединение и Положение ЦПП. Пайщик отмечает обязательные чекбоксы и нажимает «Подписать оферту» — backend выполняет on-chain wallet::signagree (program_id=2) без редиректа в core Registrator-мастер.',
);
// Проставить обязательные чекбоксы (gate проверяет required+allRequiredAccepted).
const checkboxes = gate.locator('.q-checkbox');
const cbCount = await checkboxes.count();
for (let i = 0; i < cbCount; i++) {
const cb = checkboxes.nth(i);
const aria = await cb.getAttribute('aria-checked').catch(() => null);
if (aria !== 'true') {
await cb.click();
await page.waitForTimeout(150);
}
}
await page.waitForTimeout(300);
const signBtn = gate.locator('button:has-text("Подписать оферту")').first();
await signBtn.waitFor({ state: 'visible', timeout: 5000 });
await signBtn.click();
// Ждём либо Notify «Оферта подписана» + редирект на /market/catalog,
// либо banner «уже подключены», либо отрисовку каталога (router push).
await page.waitForFunction(
() => {
const u = window.location.href;
if (u.includes('/market/catalog')) return true;
const txt = document.body.innerText || '';
return txt.includes('Оферта ЦПП') || txt.includes('Вы уже подключены');
},
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
} else if (await alreadyBanner.isVisible().catch(() => false)) {
await cleanViteOverlays(page);
await shot(
page,
'01-marketplace-onboarding-already',
'Пайщик уже прошёл L3 онбординг ЦПП «Стол заказов» (повторный заход): banner «Вы уже подключены к Marketplace» с кнопкой «К каталогу». Backend `marketplaceOnboardingState` отдал requires_gate=false, source=\'agreement_signed\'.',
);
}
// === Шаг 2: переход на каталог ===
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/catalog`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
@@ -70,6 +158,7 @@ export default async ({ page, shot }) => {
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
// === Шаг 3: открыть OrderCreateDialog ===
const orderBtn = page.locator('button:has-text("Заказать")').first();
if (!(await orderBtn.count())) {
throw new Error('В каталоге нет CatalogOfferCard с действием «Заказать» — нужен хотя бы один APPROVED offer (chairman/offer-moderation).');
@@ -82,7 +171,7 @@ export default async ({ page, shot }) => {
await cleanViteOverlays(page);
await shot(
page,
'01-order-create-dialog',
'02-order-create-dialog',
'Диалог оформления заказа: q-input «Количество», q-select «ПВЗ доставки», итоговая сумма (цена × количество), кнопки «Отмена» и «Подтвердить заказ». Открывается при клике «Заказать» на карточке APPROVED offer\'а в каталоге.',
);
@@ -91,7 +180,6 @@ export default async ({ page, shot }) => {
await qty.fill('2');
await page.waitForTimeout(300);
// Открыть q-select «ПВЗ доставки» и выбрать первый option из выпадашки.
const branchSelect = dialog.locator('.q-select').first();
await branchSelect.click();
await page.waitForTimeout(400);
@@ -102,19 +190,20 @@ export default async ({ page, shot }) => {
await cleanViteOverlays(page);
await shot(
page,
'02-order-create-filled',
'03-order-create-filled',
'Форма с количеством 2 и выбранным ПВЗ доставки. Итоговая сумма обновляется немедленно (price_per_unit × quantity). Кнопка «Подтвердить заказ» становится активной, когда оба поля валидны.',
);
// === Шаг 4: submit + Notify «Заказ создан» ===
const confirmBtn = dialog.locator('button:has-text("Подтвердить заказ")').first();
await confirmBtn.click();
await page.locator('.q-notification').first().waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
await page.waitForTimeout(600);
await page.locator('.q-notification').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(800);
await shot(
page,
'03-order-create-no-agreement',
'Текущий блокер магистрали II: у тестового пайщика не подписана ЦПП «Стол заказов» (program_id=2, draft_id=699 в `soviet::coagreements`). Backend проходит pipeline вплоть до on-chain `marketplace::createorder`внутреннего `o.mkt.assign` (TRANSFER из `w.wal.member` в `w.mkt.member`), который требует записи в `wallet::users.programs[]` с program_id=2. Контракт корректно валит assertion «walletop: у пайщика X не подписано соглашение program_id=2 для кошелька w.mkt.member». UI показывает читаемое сообщение через `extractErrorMessage()`. Корректное разблокирование — реализовать factory adapter 1100.MarketplaceOfferTemplate в `components/factory/src/Actions` и L3 mutation `marketplaceSignOnboardingOffer` (фоллоуап Эпика 1, см. controller/extensions/marketplace/application/onboarding/README.md); после этого пайщик подпишет ЦПП через UI «Стол заказов — пакет ЦПП» (страница `onboarding/member-pick-cpp`), и submit отработает с Notify «Заказ создан».',
'04-order-created',
'Magistral II разблокирована: после подписи L3-оферты submit `marketplaceCreateOrder` проходит pipeline (createorder → o.mkt.assignTRANSFER из w.wal.member в w.mkt.member). UI показывает Notify «Заказ создан». В Witkin/MyOrders появляется новый PENDING-заказ.',
{ preserveNotifications: true },
);
};