kaloko 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +1 -1
- package/schemas/config.v1.json +6 -4
- package/skills/kaloko/reference/acceptance.md +2 -2
- package/src/cli.mjs +11 -7
- package/src/lib/build.mjs +1 -1
- package/src/lib/capture.mjs +88 -44
- package/src/lib/changelog-core.mjs +3 -1
- package/src/lib/evaluate.mjs +12 -9
- package/src/lib/htmlview.mjs +3 -1
- package/src/lib/keyboard.mjs +4 -2
- package/src/lib/pii.mjs +529 -212
- package/src/lib/recording.mjs +12 -3
- package/src/lib/triage-core.mjs +3 -1
- package/src/lib/triage.mjs +17 -5
- package/src/lib/video.mjs +2 -0
- package/viewer/index.html +82 -19
package/src/lib/recording.mjs
CHANGED
|
@@ -12,9 +12,9 @@ import { tmpdir } from 'node:os'
|
|
|
12
12
|
import { join, relative, dirname } from 'node:path'
|
|
13
13
|
import { writeJson, localized } from './util.mjs'
|
|
14
14
|
import { addCapture, runPaths } from './run.mjs'
|
|
15
|
-
import { encodeClip, preset, MAX_SECONDS } from './video.mjs'
|
|
15
|
+
import { encodeClip, preset, MAX_SECONDS, MIN_STEP_MS } from './video.mjs'
|
|
16
16
|
import { PAGE_HOOK, networkEntries, actionEntries, summarizeTrace, cleanText, TRACE_LIMITS } from './trace.mjs'
|
|
17
|
-
import { maskSpec, scenarioMasks, paintMasks, scrubDeep } from './pii.mjs'
|
|
17
|
+
import { maskSpec, scenarioMasks, paintMasks, scrubDeep, loadKnownPii } from './pii.mjs'
|
|
18
18
|
|
|
19
19
|
export const RECORD_MODES = ['step', 'flow', 'failed']
|
|
20
20
|
const QUALITIES = ['low', 'medium', 'high']
|
|
@@ -209,6 +209,7 @@ export async function recordingSession ({ page, run, scenario, locale, settings,
|
|
|
209
209
|
// every selector the scenario masks on any step, for the whole recording (more hidden than a single capture is fine)
|
|
210
210
|
const masks = scenarioMasks(scenario)
|
|
211
211
|
const mask = maskSpec({ run, selectors: masks.selectors, auto: masks.auto, locale })
|
|
212
|
+
if (mask.active) await loadKnownPii(run.dir)
|
|
212
213
|
if (anyRecord && mask.active && !(await paintMasks(page, mask))) say(`Masks could not be drawn into the recording (${label}); the recording is skipped so it shows no personal data.`)
|
|
213
214
|
const painted = !anyRecord || !mask.active || await page.evaluate(() => !!window.__kalokoMask).catch(() => false)
|
|
214
215
|
const obs = await observePage(page, { screencast: anyRecord && painted, trace: settings.trace, quality })
|
|
@@ -249,11 +250,17 @@ export async function recordingSession ({ page, run, scenario, locale, settings,
|
|
|
249
250
|
const keepClip = want && (want.mode !== 'failed' || failed)
|
|
250
251
|
if (!keepClip) { await obs.prune(t2); return }
|
|
251
252
|
const clip = obs.clip(s.t0, error ? t2 : t1)
|
|
253
|
+
// the screencast sends a frame only when the picture changes, and a fast step is captured before the new page's
|
|
254
|
+
// frame even arrives: with fewer than two frames in the step nothing moved worth watching, so no video is kept
|
|
255
|
+
// and the canvas shows the screenshot (a failed step keeps its recording whatever it shows)
|
|
256
|
+
const still = clip.frames.filter((f) => f.t > s.t0).length < 2
|
|
257
|
+
if (still && !failed) { await obs.prune(t2); return }
|
|
258
|
+
const end = Math.max(error ? t2 : t1, s.t0 + MIN_STEP_MS)
|
|
252
259
|
// the frames stay on disk until the encoder has them; pruning waits in the same queue
|
|
253
260
|
enqueue(async () => {
|
|
254
261
|
const base = recBase(run.dir, node.id, locale, vp)
|
|
255
262
|
await mkdir(dirname(base), { recursive: true })
|
|
256
|
-
const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: want.cursor === false ? [] : clip.events, start: s.t0, end
|
|
263
|
+
const r = await encodeClip({ page: await encoder(), frames: clip.frames, events: want.cursor === false ? [] : clip.events, start: s.t0, end, out: base, quality: want.quality, maxSeconds: want.max_seconds, codec: want.codec || 'auto', mp4: want.mp4 ?? 'auto' })
|
|
257
264
|
// offset_ms: where the clip starts after the step's start (a trimmed long step keeps its end); the trace aligns by it
|
|
258
265
|
if (r) await saveRecording({ run, node, locale, viewport: vp, result: { ...r, offset_ms: Math.max(0, Math.round(r.start - s.t0)) }, mode: want.mode, failed, error, captured_at: records?.[0]?.captured_at || null, source: 'screencast' })
|
|
259
266
|
for (const n of r?.notes || []) notes.add(n)
|
|
@@ -307,6 +314,8 @@ export async function saveRecording ({ run, node, locale, viewport, result, mode
|
|
|
307
314
|
fps: result.fps || null,
|
|
308
315
|
codec: result.codec,
|
|
309
316
|
...(result.trimmed ? { trimmed: true } : {}),
|
|
317
|
+
// nothing moved during the step: the canvas shows the screenshot in its place
|
|
318
|
+
...(result.still ? { still: true } : {}),
|
|
310
319
|
...(result.offset_ms ? { offset_ms: result.offset_ms } : {}),
|
|
311
320
|
files,
|
|
312
321
|
bytes: result.bytes || {},
|
package/src/lib/triage-core.mjs
CHANGED
|
@@ -11,7 +11,9 @@ export const TRIAGE_MODELS = ['clef', 'clef-flash']
|
|
|
11
11
|
export const clefModelId = (model) => `@cf/cloudflare/${TRIAGE_MODELS.includes(model) ? model : TRIAGE_MODELS[0]}`
|
|
12
12
|
// Clef by default: on the first bench (#72) it found 69 % of injected defects against 54 % for Clef-flash, with no
|
|
13
13
|
// false finding on clean screens either way, at about 1.2 s against 0.5 s a screen
|
|
14
|
-
|
|
14
|
+
// Clef's context is 65,536 tokens and images take about one token per 40 pixels: two tiles of 896×1024 or four
|
|
15
|
+
// frames 640 wide leave room for the questions (four full tiles of 1024×1400 came to 134,000 tokens)
|
|
16
|
+
export const TRIAGE_DEFAULTS = { model: 'clef', keep: 0.7, maybe: 0.5, max_width: 896, tile_height: 1024, max_tiles: 2, frame_width: 640, frames_per_step: 6, frames_per_run: 60, max_calls: 200 }
|
|
15
17
|
/** Workers AI: at most 4 images per request, 4 MiB each, 8 MiB together; 64 questions. */
|
|
16
18
|
export const MAX_TRIAGE_IMAGES = 4
|
|
17
19
|
export const MAX_TRIAGE_QUESTIONS = 64
|
package/src/lib/triage.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { readFile } from 'node:fs/promises'
|
|
|
7
7
|
import { readJson, writeJson, sha256 } from './util.mjs'
|
|
8
8
|
import { buildTriageRequest, parseTriage, mergeFindings, clefModelId, TRIAGE_PROMPT_VERSION, MAX_TRIAGE_IMAGES } from './triage-core.mjs'
|
|
9
9
|
|
|
10
|
+
// set once Workers AI refused the local Cloudflare token: the rest of the evaluation goes through the service
|
|
11
|
+
let ownKeyRefused = false
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* Asks Clef with a content-addressed cache. Returns { findings, criteria, model, usage, cached, via, ms } or
|
|
12
15
|
* { skipped } / { error }. `allowCall()` is asked before every uncached request.
|
|
@@ -21,16 +24,21 @@ export async function askTriage ({ payload, settings, cacheDir, where, fetchImpl
|
|
|
21
24
|
if (!raw) {
|
|
22
25
|
const token = process.env.CLOUDFLARE_API_TOKEN; const account = process.env.CLOUDFLARE_ACCOUNT_ID
|
|
23
26
|
if (!allowCall()) return { skipped: `triage call limit reached (max_calls ${settings.max_calls})` }
|
|
24
|
-
if (token && account) {
|
|
27
|
+
if (token && account && !ownKeyRefused) {
|
|
25
28
|
const t0 = Date.now()
|
|
26
29
|
let res
|
|
27
30
|
try { res = await fetchImpl(`https://api.cloudflare.com/client/v4/accounts/${account}/ai/run/${clefModelId(body.model)}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) } catch (e) { return { error: `Workers AI unreachable: ${e.message}` } }
|
|
31
|
+
// too many tokens for the model's context: once more with half the images (a tall page loses its lower part)
|
|
32
|
+
if (res.status === 413 && payload.images.length > 1) return askTriage({ payload: { ...payload, images: payload.images.slice(0, Math.ceil(payload.images.length / 2)) }, settings, cacheDir, where, fetchImpl, hosted, allowCall })
|
|
33
|
+
// a Cloudflare token without Workers AI (a deploy token in the project's .env): the service asks instead
|
|
34
|
+
if ((res.status === 401 || res.status === 403) && hosted) { ownKeyRefused = true; if (hosted.ready && !(await hosted.ready())) return { skipped: 'the Cloudflare token cannot use Workers AI and the triage experiment is off for this organization' }; return askTriage({ payload, settings, cacheDir, where, fetchImpl, hosted, allowCall: () => true }) }
|
|
28
35
|
if (!res.ok) return { error: `Workers AI HTTP ${res.status}: ${(await res.text()).slice(0, 300)}` }
|
|
29
36
|
const out = await res.json()
|
|
30
37
|
const a = out?.result?.answers ? out.result : out
|
|
31
38
|
raw = { model: a.model || settings.model, answers: a.answers || {}, usage: a.usage || null, ms: Date.now() - t0, via: 'own-key' }
|
|
32
39
|
} else if (hosted) {
|
|
33
40
|
const r = await hosted({ model: settings.model, ...payload })
|
|
41
|
+
if (r.error && /HTTP 502.*(context window|413)/i.test(r.error) && payload.images.length > 1) return askTriage({ payload: { ...payload, images: payload.images.slice(0, Math.ceil(payload.images.length / 2)) }, settings, cacheDir, where, fetchImpl, hosted, allowCall })
|
|
34
42
|
if (r.skipped || r.error) return r
|
|
35
43
|
raw = { model: r.response.model, answers: r.response.answers || {}, usage: r.response.usage || null, ms: r.response.ms ?? null, via: 'kaloko' }
|
|
36
44
|
} else return { skipped: 'no service token (KALOKO_TOKEN) and no CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID' }
|
|
@@ -75,15 +83,19 @@ export function hostedTriageSender (config, report) {
|
|
|
75
83
|
* at most `max` frames, each a WebP data URL `maxW` wide.
|
|
76
84
|
*/
|
|
77
85
|
export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 1024 }) {
|
|
86
|
+
if (!(max > 0)) return []
|
|
78
87
|
const src = `data:video/${file.endsWith('.mp4') ? 'mp4' : 'webm'};base64,${(await readFile(file)).toString('base64')}`
|
|
79
88
|
return page.evaluate(async ({ src, taps, max, maxW }) => {
|
|
80
89
|
const v = document.createElement('video'); v.muted = true; v.preload = 'auto'; v.src = src
|
|
81
|
-
|
|
90
|
+
// every wait has a limit: a stalled video must not hang the evaluation
|
|
91
|
+
const within = (p, ms) => Promise.race([p, new Promise((ok, no) => setTimeout(() => no(new Error('the recording did not answer in time')), ms))])
|
|
92
|
+
await within(new Promise((ok, no) => { v.onloadeddata = ok; v.onerror = () => no(new Error('the recording could not be decoded')) }), 15000)
|
|
93
|
+
if (!v.videoWidth || !v.videoHeight) return []
|
|
82
94
|
// WebM from MediaRecorder-like encoders may report Infinity until the end is sought once
|
|
83
|
-
if (!Number.isFinite(v.duration)) { v.currentTime = 1e9; await new Promise((ok) => { v.onseeked = ok }) }
|
|
95
|
+
if (!Number.isFinite(v.duration)) { v.currentTime = 1e9; await within(new Promise((ok) => { v.onseeked = ok }), 5000).catch(() => {}) }
|
|
84
96
|
const dur = Number.isFinite(v.duration) ? v.duration : 0
|
|
85
97
|
if (!dur) return []
|
|
86
|
-
const seek = (t) => new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(Math.max(0, t), Math.max(0, dur - 0.01)) })
|
|
98
|
+
const seek = (t) => within(new Promise((ok) => { v.onseeked = ok; v.currentTime = Math.min(Math.max(0, t), Math.max(0, dur - 0.01)) }), 5000)
|
|
87
99
|
const small = document.createElement('canvas'); small.width = 64; small.height = Math.max(1, Math.round(64 * v.videoHeight / v.videoWidth))
|
|
88
100
|
const sg = small.getContext('2d', { willReadFrequently: true })
|
|
89
101
|
// sampled differences: 4 samples a second, at most 240
|
|
@@ -101,7 +113,7 @@ export async function chooseFrames ({ page, file, taps = [], max = 6, maxW = 102
|
|
|
101
113
|
for (const c of diffs.filter((x) => x.d > 0.02).sort((a, b) => b.d - a.d).slice(0, 3)) { add(c.t - step, 'before a change'); add(c.t, 'after a change') }
|
|
102
114
|
add(dur - 0.05, 'the end of the step')
|
|
103
115
|
const chosen = [...pick.values()].sort((a, b) => a.t - b.t)
|
|
104
|
-
//
|
|
116
|
+
// within max: the earliest moments and the end of the step
|
|
105
117
|
const list = chosen.length > max ? [...chosen.slice(0, max - 1), chosen.at(-1)] : chosen
|
|
106
118
|
const big = document.createElement('canvas'); const s = Math.min(1, maxW / v.videoWidth)
|
|
107
119
|
big.width = Math.round(v.videoWidth * s); big.height = Math.round(v.videoHeight * s)
|
package/src/lib/video.mjs
CHANGED
|
@@ -257,6 +257,8 @@ export async function encoderCaps (page) {
|
|
|
257
257
|
* { files: { webm, mp4, preview, poster }, duration_ms, width, height, codec, fps, trimmed, notes }.
|
|
258
258
|
*/
|
|
259
259
|
// the encoder page holds one clip at a time: parallel locales and named sessions take turns
|
|
260
|
+
// a step clip shorter than this is held on its last frame: a one-second video jumps past before anyone can look
|
|
261
|
+
export const MIN_STEP_MS = 2500
|
|
260
262
|
const LOCKS = new WeakMap()
|
|
261
263
|
export function encodeClip (opts) {
|
|
262
264
|
if (!opts.page) return encodeClipNow(opts)
|
package/viewer/index.html
CHANGED
|
@@ -1604,7 +1604,8 @@ function wireComments (root) {
|
|
|
1604
1604
|
b.disabled = true;
|
|
1605
1605
|
const made = await cApi(scope, '', 'POST', body);
|
|
1606
1606
|
// a comment made from a triage finding records the answer, so the experiment counts it (#70)
|
|
1607
|
-
|
|
1607
|
+
// only when the comment still carries the finding's line, on the same step, locale and screen
|
|
1608
|
+
if (main && scope === 'step' && C.fromFinding?.node === nodeId) { const sec = document.getElementById('findSec'); const ff = C.fromFinding; C.fromFinding = null; const f = (byId(nodeId)?.captures?.[ff.loc]?.[ff.vp]?.findings || []).find((x) => x.id === ff.fid); if (sec && f && sec.dataset.vp === ff.vp && S.loc === ff.loc && note.startsWith(T('findNote')(T('fk_' + f.kind) || f.kind, Math.round(f.probability * 100)).trim())) answerFinding(sec, ff.fid, 'comment', made?.id || made?.comment?.id || null).catch(() => {}); }
|
|
1608
1609
|
saveDraft(box.dataset.key, ''); if (main) { C.pin = null; C.pinMode = false; C.shape = null; C.draw = null; LIVE.anchor = null; } C.replyTo = null;
|
|
1609
1610
|
delete C.files[box.dataset.key];
|
|
1610
1611
|
toast(T('saved')); await refreshComments();
|
|
@@ -1907,7 +1908,7 @@ function shot (n, v, w, h) {
|
|
|
1907
1908
|
if (att) return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${att.preview ? ` data-prev="${esc(asset(att.preview))}"` : ''}><img loading="lazy" src="${esc(asset(att.poster))}" alt="" style="opacity:.75"><span class="shot__rec" title="${esc(T('recFailed'))}">▶ ✕</span><span class="shot__tag">${v.w}</span></span>`;
|
|
1908
1909
|
if (!c) return `<span class="shot shot--${v.mobile ? 'm' : 'd'} shot--empty" style="width:${w}px;height:${h}px">${T('noCapture')}</span>`;
|
|
1909
1910
|
const prev = c.rec?.preview && !RUN.recordings_expired ? asset(c.rec.preview) : null;
|
|
1910
|
-
return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${prev ? ` data-prev="${esc(prev)}"` : ''}><img loading="lazy" src="${esc(asset(c.thumb || c.image || c.png))}" alt="">${c.rec ? `<span class="shot__rec" title="${esc(T('recHint'))}">▶</span>` : ''}<span class="shot__tag">${v.w}</span></span>`;
|
|
1911
|
+
return `<span class="shot shot--${v.mobile ? 'm' : 'd'}" data-vp="${esc(v.name)}"${prev ? ` data-prev="${esc(prev)}"` : ''}><img loading="lazy" src="${esc(asset(c.thumb || c.image || c.png))}"${c.thumb && (c.image || c.png) ? ` data-full="${esc(asset(c.image || c.png))}"` : ''} alt="">${c.rec ? `<span class="shot__rec" title="${esc(T('recHint'))}">▶</span>` : ''}<span class="shot__tag">${v.w}</span></span>`;
|
|
1911
1912
|
}
|
|
1912
1913
|
/* Quick signals of a capture (probabilities of "yes"): which ones deserve a look. Purpose and language are good
|
|
1913
1914
|
when high, broken is good when low; the evaluator is calibrated, so 30 % / 70 % are honest cut-offs. */
|
|
@@ -1978,8 +1979,11 @@ document.addEventListener('click', async (e) => {
|
|
|
1978
1979
|
const n = byId(sec.dataset.node); const f = (n?.captures?.[S.loc]?.[sec.dataset.vp]?.findings || []).find((x) => x.id === fid);
|
|
1979
1980
|
if (!f) return;
|
|
1980
1981
|
const key = draftKey('step', n.id, 'new');
|
|
1981
|
-
|
|
1982
|
-
|
|
1982
|
+
// the finding's line leads the comment, also above a draft already started
|
|
1983
|
+
const line = T('findNote')(T('fk_' + f.kind) || f.kind, Math.round(f.probability * 100));
|
|
1984
|
+
const draft = loadDraft(key);
|
|
1985
|
+
if (!draft.startsWith(line)) saveDraft(key, draft ? `${line}${draft}` : line);
|
|
1986
|
+
C.fromFinding = { node: n.id, vp: sec.dataset.vp, loc: S.loc, fid };
|
|
1983
1987
|
rerenderComments();
|
|
1984
1988
|
const ta = document.querySelector(`.c-compose.c-main[data-node="${CSS.escape(n.id)}"] .c-text`);
|
|
1985
1989
|
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); ta.scrollIntoView({ block: 'center' }); }
|
|
@@ -2635,7 +2639,24 @@ function setInv (v) {
|
|
|
2635
2639
|
INV_CSS ||= $('invCss') || document.head.appendChild(document.createElement('style'));
|
|
2636
2640
|
INV_CSS.textContent = `#world :focus-visible,#world .pxd,#world .lane-head,#world .node__title,#world .cur{--inv:${v}}`;
|
|
2637
2641
|
}
|
|
2638
|
-
|
|
2642
|
+
/* A card's thumbnail is made for the card at about 100 %; zoomed in (or on a 2× screen with an older, smaller
|
|
2643
|
+
thumbnail) the visible cards load the full screenshot instead of stretching the thumbnail. */
|
|
2644
|
+
let sharpenT = null;
|
|
2645
|
+
function sharpenShots () {
|
|
2646
|
+
clearTimeout(sharpenT);
|
|
2647
|
+
sharpenT = setTimeout(() => {
|
|
2648
|
+
const dpr = window.devicePixelRatio || 1; const vw = innerWidth; const vh = innerHeight;
|
|
2649
|
+
for (const img of document.querySelectorAll('#nodes .shot img[data-full]')) {
|
|
2650
|
+
if (!img.complete || !img.naturalWidth || img.closest('.shot')?.dataset.playing) continue;
|
|
2651
|
+
const r = img.getBoundingClientRect();
|
|
2652
|
+
if (r.right < 0 || r.bottom < 0 || r.left > vw || r.top > vh || !r.width) continue;
|
|
2653
|
+
if (r.width * dpr > img.naturalWidth * 1.15) { img.src = img.dataset.full; delete img.dataset.full; }
|
|
2654
|
+
}
|
|
2655
|
+
}, 250);
|
|
2656
|
+
}
|
|
2657
|
+
// a thumbnail that finishes loading is checked too (cards render before their images arrive)
|
|
2658
|
+
document.addEventListener('load', (e) => { if (e.target?.matches?.('#nodes .shot img[data-full]')) sharpenShots(); }, true);
|
|
2659
|
+
function applyView () { sharpenShots(); const v = S.view; $('world').style.transform = `translate(${v.x}px,${v.y}px) scale(${v.k})`; { const far = v.k < 0.6; if ($('world').classList.contains('far') !== far) { $('world').classList.toggle('far', far); requestAnimationFrame(drawEdges); } $('world').classList.toggle('tiny', v.k < 0.25); } setInv(String(Math.min(6, 1 / v.k))); $('zVal').textContent = Math.round(v.k * 100) + ' %'; $('vp').style.backgroundSize = `${22 * v.k}px ${22 * v.k}px`; $('vp').style.backgroundPosition = `${v.x}px ${v.y}px`; renderMini(); presenceView(); }
|
|
2639
2660
|
/* minimap: every node as a status-coloured block, the visible area as a frame; click or drag to move */
|
|
2640
2661
|
let MINI = null, MINI_NODES = null;
|
|
2641
2662
|
function renderMini () {
|
|
@@ -2789,7 +2810,7 @@ function renderChrome () {
|
|
|
2789
2810
|
? laneNamesOf().map((name, i) => { const mine = order.filter((n) => n.lane === i); if (!mine.length) return ''; const c = { pass: mine.filter((n) => nodeSt(n) === 'pass').length }; return `<h3 data-lane="${i}" title="${esc(T('fitLane'))}">${esc(name)} <small class="muted">${c.pass}/${mine.length}</small></h3>${mine.map(stepLi).join('')}`; }).join('')
|
|
2790
2811
|
: order.map(stepLi).join('');
|
|
2791
2812
|
$('steps').onclick = (e) => { const ln = e.target.closest('[data-lane]'); if (ln) { if (S.mode === 'index') { const k = (S.ri?.entries || []).findIndex((x) => x.type === 'group' && x.label === laneNamesOf()[Number(ln.dataset.lane)]); if (k >= 0) $('riList').scrollTop = S.ri.tops[k]; return; } fitLane(Number(ln.dataset.lane)); return; } const h = e.target.closest('[data-sess]'); if (h) { S.sess = S.sess === h.dataset.sess ? null : h.dataset.sess; renderAll(); return; } const b = e.target.closest('[data-step]'); if (!b) return; if (S.mode === 'index') riFocus(b.dataset.step); else if (byId(b.dataset.step)?.kind === 'stack') { focusNode(b.dataset.step); openStack(b.dataset.step); } else focusNode(b.dataset.step); if (innerWidth <= 1100) { S.left = false; setPanels(); } };
|
|
2792
|
-
$('scFoot').innerHTML = `${issFootHtml()}${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
|
|
2813
|
+
$('scFoot').innerHTML = `${issFootHtml()}${RUN.project ? `<span class="mono">${esc(RUN.project.slug)}</span> · ` : ''}${RUN.issue?.url ? `<a class="pr" href="${esc(webHref(RUN.issue.url))}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a> · ` : ''}<code>${esc(RUN.scenario.file || '')}</code>${RUN.scenario.version ? ` · v${esc(RUN.scenario.version)}` : ''}<br><span class="mono">${esc(RUN.id)}</span>`;
|
|
2793
2814
|
}
|
|
2794
2815
|
// the summary panel before the cards: the cards measure themselves, and that one layout then covers the panel too
|
|
2795
2816
|
function renderAll () { renderChrome(); if (S.right) renderReport(); renderNodes(); presenceView(); if (S.mode === 'index') { if (S.st?.inline) paintStack(); else if ($('riList')) riPaint(); else riRender(); } }
|
|
@@ -3042,11 +3063,14 @@ function metaSection (n, cap, v) {
|
|
|
3042
3063
|
|
|
3043
3064
|
/* ---------- recordings and traces (batch D, #12): video of a step, its markers, the trace timeline ---------- */
|
|
3044
3065
|
/** A step's video: its own recording, a failed attempt's, or its chapter of the flow recording (from/to in s). */
|
|
3066
|
+
/* A clip where nothing moved, or one so short it only flickers (older runs, before clips were held to 2.5 s), shows
|
|
3067
|
+
as the screenshot instead. */
|
|
3068
|
+
const stillRec = (r) => !!r && (r.still === true || (Number(r.duration_ms) > 0 && Number(r.duration_ms) < 1000));
|
|
3045
3069
|
function recOf (n, cap, v) {
|
|
3046
3070
|
if (!n || n.kind === 'decision' || n.kind === 'stack') return null;
|
|
3047
|
-
if (cap?.rec && (cap.rec.webm || cap.rec.mp4)) return { ...cap.rec, kind: 'step' };
|
|
3071
|
+
if (cap?.rec && (cap.rec.webm || cap.rec.mp4) && !stillRec(cap.rec)) return { ...cap.rec, kind: 'step' };
|
|
3048
3072
|
const f = (n.recordings || []).filter((r) => r.locale === S.loc && (r.viewport === v?.name || !cap)).pop();
|
|
3049
|
-
if (f && (f.webm || f.mp4)) return { ...f, kind: 'failed' };
|
|
3073
|
+
if (f && (f.webm || f.mp4) && !stillRec(f)) return { ...f, kind: 'failed' };
|
|
3050
3074
|
const fl = RUN.recordings?.flow?.[S.loc]; const ch = fl?.chapters?.find((c) => c.node === n.id);
|
|
3051
3075
|
if (ch && (fl.webm || fl.mp4) && (!fl.viewport || !v || fl.viewport === v.name)) return { ...fl, kind: 'chapter', from: ch.start / 1000, to: Math.max(ch.capture, ch.start + 300) / 1000, title: ch.title };
|
|
3052
3076
|
return null;
|
|
@@ -3086,7 +3110,7 @@ function wireVideo (root, rec, cap) {
|
|
|
3086
3110
|
vid.ontimeupdate = () => {
|
|
3087
3111
|
const t = vid.currentTime - from;
|
|
3088
3112
|
if (pos) pos.style.left = `${Math.min(100, Math.max(0, t / d * 100))}%`;
|
|
3089
|
-
if (to && vid.currentTime >= to) vid.pause();
|
|
3113
|
+
if (to && vid.currentTime >= to) { vid.dataset.autoPause = '1'; vid.pause(); vid.dispatchEvent(new Event('clipend')); }
|
|
3090
3114
|
if (taps && rec.taps?.length) taps.innerHTML = rec.taps.filter((x) => Math.abs(x.t / 1000 - (rec.offset_ms || 0) / 1000 - t) < 0.45).map((x) => `<span class="vtap" style="left:${x.x * 100}%;top:${x.y * 100}%"></span>`).join('');
|
|
3091
3115
|
// the trace row of this moment, when the Technical tab shows the trace of this capture
|
|
3092
3116
|
const ms = t * 1000 + (rec.kind === 'step' || rec.kind === 'failed' ? (rec.offset_ms || 0) : 0);
|
|
@@ -3094,7 +3118,21 @@ function wireVideo (root, rec, cap) {
|
|
|
3094
3118
|
for (const r of rows) { r.classList.remove('is-now'); if (Number(r.dataset.t) <= ms) now = r; }
|
|
3095
3119
|
now?.classList.add('is-now');
|
|
3096
3120
|
};
|
|
3121
|
+
// once someone plays a video, the next step's video plays by itself (until they pause one)
|
|
3122
|
+
vid.addEventListener('play', () => { S.vAuto = true; });
|
|
3123
|
+
vid.addEventListener('pause', () => { if (!vid.ended && !vid.dataset.autoPause && !(to && vid.currentTime >= to - 0.05)) S.vAuto = false; delete vid.dataset.autoPause; });
|
|
3097
3124
|
if (S.vseek != null) { const t = S.vseek; S.vseek = null; seekVideo(vid, t, true); }
|
|
3125
|
+
else if (S.vAuto) { vid.muted = true; seekVideo(vid, 0, true); }
|
|
3126
|
+
}
|
|
3127
|
+
/* The next step's video starts loading while this one plays, so moving on does not wait for the network. */
|
|
3128
|
+
const PRELOADED = [];
|
|
3129
|
+
function preloadNext (n, v) {
|
|
3130
|
+
const next = outEdges(n.id).map((e) => byId(e.to)).find((x) => x && x.kind !== 'decision');
|
|
3131
|
+
const rec = next ? recOf(next, next.captures?.[S.loc]?.[v.name], v) : null;
|
|
3132
|
+
const src = rec && (rec.webm && document.createElement('video').canPlayType('video/webm') ? rec.webm : rec.mp4 || rec.webm);
|
|
3133
|
+
if (!src || RUN.recordings_expired || PRELOADED.some((x) => x.dataset.src === src)) return;
|
|
3134
|
+
const pre = document.createElement('video'); pre.preload = 'auto'; pre.muted = true; pre.dataset.src = src; pre.src = asset(src);
|
|
3135
|
+
PRELOADED.push(pre); if (PRELOADED.length > 3) PRELOADED.shift().removeAttribute('src');
|
|
3098
3136
|
}
|
|
3099
3137
|
const TRACES = new Map();
|
|
3100
3138
|
function loadTrace (cap) {
|
|
@@ -3161,6 +3199,8 @@ function wireReplayVideo (n, cap, v) {
|
|
|
3161
3199
|
const rec = rpVideoOf(n, cap, v); const stage = $('rpStage'); if (!rec || !stage) return;
|
|
3162
3200
|
wireVideo(stage, rec, cap);
|
|
3163
3201
|
const vid = stage.querySelector('video.rec'); if (!vid) return;
|
|
3202
|
+
// a step's own video (not the flow's) hands over to the next step when it ends
|
|
3203
|
+
if (rec.kind !== 'flow') { vid.addEventListener('ended', () => rpAutoNext(n)); vid.addEventListener('clipend', () => rpAutoNext(n)); }
|
|
3164
3204
|
const chaps = rec.chapters || [];
|
|
3165
3205
|
stage.querySelectorAll('[data-chap]').forEach((b) => { b.onclick = () => seekVideo(vid, Number(b.dataset.seek), true); });
|
|
3166
3206
|
// the replay's step and the video's chapter move together: a step change seeks, playing past a chapter marks it
|
|
@@ -3297,6 +3337,7 @@ function openLb (id, vpName, opts = {}) {
|
|
|
3297
3337
|
if ($('lbMode')) seg($('lbMode'), [...(liveSrc(cap) ? [['live', T('liveMode')]] : []), ...(viewSrc(cap) ? [['view', T('htmlView')]] : []), ...(recOf(n, cap, v) ? [['video', T('videoMode')]] : []), ['shot', T('shotMode')]], S.lbMode === 'video' && !recOf(n, cap, v) ? 'shot' : S.lbMode, (m) => { S.lbMode = m; LIVE.mode = null; openLb(id, v.name); });
|
|
3298
3338
|
// the step's video and the trace timeline (batch D): markers seek, trace rows jump into the video
|
|
3299
3339
|
wireVideo(lb, recOf(n, cap, v), cap);
|
|
3340
|
+
if (S.lbMode === 'video') preloadNext(n, v);
|
|
3300
3341
|
if (cap?.trace || cap?.trace_inline) fillTrace(lb, n, cap, v);
|
|
3301
3342
|
if ($('lbLiveTools')) renderLiveTools();
|
|
3302
3343
|
if ($('lbProto')) $('lbProto').onclick = () => ptOpen(id, v.name);
|
|
@@ -3593,12 +3634,22 @@ function rpOpen (path) {
|
|
|
3593
3634
|
$('lb').hidden = true; S.open = null; closeBrowser();
|
|
3594
3635
|
renderReplay();
|
|
3595
3636
|
}
|
|
3596
|
-
function rpClose () { S.rp = null; $('rp').hidden = true; setHash(null); }
|
|
3637
|
+
function rpClose () { clearTimeout(S.rpTimer); S.rp = null; $('rp').hidden = true; setHash(null); }
|
|
3638
|
+
/* Replay with video plays on by itself: at the end of a step's video (or after a still step has shown for a while) it
|
|
3639
|
+
moves to the next step when there is one way on. A decision waits for the person; the end of the flow stops. */
|
|
3640
|
+
const STILL_MS = 3000;
|
|
3641
|
+
function rpAutoNext (n) {
|
|
3642
|
+
clearTimeout(S.rpTimer);
|
|
3643
|
+
if (!S.rp || !S.rpVideo || !S.vAuto || S.rp.path[S.rp.path.length - 1] !== n.id) return;
|
|
3644
|
+
const opts = rpNextOptions(n);
|
|
3645
|
+
if (opts.length === 1 && opts[0].n) rpGo(opts[0].n.id);
|
|
3646
|
+
}
|
|
3597
3647
|
function rpGo (id) { if (!byId(id)) return; S.rp.path.push(id); renderReplay(); }
|
|
3598
3648
|
function rpBack () { if (S.rp.path.length > 1) { S.rp.path.pop(); renderReplay(); } }
|
|
3599
3649
|
function rpJump (i) { S.rp.path = S.rp.path.slice(0, i + 1); renderReplay(); }
|
|
3600
3650
|
function rpNextOptions (n) { return outEdges(n.id).map((e, k) => ({ e, n: byId(e.to), k })); }
|
|
3601
3651
|
function renderReplay () {
|
|
3652
|
+
clearTimeout(S.rpTimer);
|
|
3602
3653
|
const cur = S.rp.path[S.rp.path.length - 1], n = byId(cur);
|
|
3603
3654
|
const v = (S.vp === 'mobile' ? mVp() : dVp()) || viewports()[0];
|
|
3604
3655
|
const cap = n.captures?.[S.loc]?.[v.name];
|
|
@@ -3640,7 +3691,12 @@ function renderReplay () {
|
|
|
3640
3691
|
$('rp').querySelectorAll('[data-stack-open]').forEach((b) => { b.onclick = () => openStack(b.dataset.stackOpen); });
|
|
3641
3692
|
$('rp').querySelector('[data-restart]')?.addEventListener('click', () => rpOpen(null));
|
|
3642
3693
|
if ($('rpVid')) $('rpVid').onclick = () => { S.rpVideo = !S.rpVideo; renderReplay(); };
|
|
3643
|
-
if (S.rpVideo)
|
|
3694
|
+
if (S.rpVideo) {
|
|
3695
|
+
wireReplayVideo(n, cap, v);
|
|
3696
|
+
// a step without a video of its own (a still page) shows its screenshot for a moment, then playing goes on
|
|
3697
|
+
if (S.vAuto && n.kind !== 'decision' && !rpVideoOf(n, cap, v)) S.rpTimer = setTimeout(() => rpAutoNext(n), STILL_MS);
|
|
3698
|
+
preloadNext(n, v);
|
|
3699
|
+
}
|
|
3644
3700
|
$('rpStage').scrollTop = 0;
|
|
3645
3701
|
setHash({ replay: 1, step: cur, path: S.rp.path.join(',') });
|
|
3646
3702
|
focusNode(cur);
|
|
@@ -3696,18 +3752,25 @@ function stepNoteHtml (n) {
|
|
|
3696
3752
|
return x && noteTxt(x.text) ? `<p class="note step-note" data-step-note><b>${T('whatChanged')}${RUN.notes?.from ? ` (v${esc(RUN.notes.from.version)} → v${esc(RUN.version)})` : ''}:</b> ${esc(noteTxt(x.text))}</p>` : '';
|
|
3697
3753
|
}
|
|
3698
3754
|
|
|
3755
|
+
/* The run verdict buttons: both while it is pending, then only the other choice, so an accepted run offers Return
|
|
3756
|
+
and a returned one offers Accept; the badge beside the heading says what was decided. */
|
|
3757
|
+
function verdictButtons (current, attr, design) {
|
|
3758
|
+
const acc = `<button class="btn btn--primary" ${attr}="accepted">${design ? T('acceptVersion') : T('accept')}</button>`;
|
|
3759
|
+
const ret = `<button class="btn" ${attr}="returned">${design ? T('returnVersion') : T('ret')}</button>`;
|
|
3760
|
+
return current === 'accepted' ? ret : current === 'returned' ? acc : acc + ret;
|
|
3761
|
+
}
|
|
3699
3762
|
function renderReport () {
|
|
3700
3763
|
const c = RUN.summary, tot = screens().length || 1, pr = prevRun();
|
|
3701
3764
|
const all = screens().flatMap((n) => n.criteria), ok = all.filter((x) => critSt(x) === 'pass').length;
|
|
3702
3765
|
const issues = screens().flatMap((n) => n.criteria.filter((x) => ['fail', 'partial'].includes(critSt(x))).map((x) => ({ n, x })));
|
|
3703
3766
|
const changes = pr ? screens().filter((n) => pr.statuses?.[n.id] && pr.statuses[n.id] !== n.status) : [];
|
|
3704
3767
|
$('rside').innerHTML = `
|
|
3705
|
-
<div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(RUN.issue.url)}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
|
|
3768
|
+
<div class="rside__head"><div style="flex:1;min-width:0"><div class="seg-label">${T('summary')}</div><h3>${RUN.issue ? `${RUN.issue.url ? `<a class="pr" href="${esc(webHref(RUN.issue.url))}" target="_blank" rel="noopener">${esc(RUN.issue.repo ? RUN.issue.repo + '#' : '#')}${esc(String(RUN.issue.number))}</a>` : '#' + esc(String(RUN.issue.number))} · ` : ''}${esc(RUN.scenario.title)}</h3><div class="node__path">${RUN.project ? esc(RUN.project.slug) + ' · ' : ''}${esc(RUN.scenario.id)} · ${fmtDate(RUN.started_at)} · ${esc(RUN.environment.name)} · ${dur()}</div></div><button class="icon-btn" id="rsClose" aria-label="${T('hide')}">${ICON.close}</button></div>
|
|
3706
3769
|
${APP() && isDraft() ? `<div class="sec"><h4>${T('draft')} #${esc(RUN.draft.seq)}</h4><p class="note">${T('draftInfo')}</p><div id="draftChanges" class="note"></div></div>` : ''}
|
|
3707
3770
|
${versionNotesHtml()}
|
|
3708
3771
|
${APP() && !isDraft() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${isDesign() ? T('versionVerdict') : T('runVerdict')}</span><span class="badge st-${RUN.verdict?.verdict === 'accepted' ? 'pass' : RUN.verdict?.verdict === 'returned' ? 'fail' : 'skip'}">${T(RUN.verdict?.verdict || 'pending')}</span>${RUN.verdict?.baseline ? `<span class="badge st-partial">${T('baseline')}</span>` : ''}</h4>${RUN.verdict?.by || (RUN.baseline_id && RUN.baseline_id !== RUN.id) ? `<p class="note" style="margin:0 0 8px">${RUN.verdict?.by ? `${esc(RUN.verdict.by)}${RUN.verdict.via === 'token' || RUN.verdict.via === 'agent' ? ` <span class="badge st-skip">${T(RUN.verdict.via === 'agent' ? 'viaAgent' : 'viaToken')}</span>` : ''} · ${fmtDate(RUN.verdict.at)}` : ''}${RUN.baseline_id && RUN.baseline_id !== RUN.id ? `${RUN.verdict?.by ? ' · ' : ''}<a class="pr" href="${runHref(RUN.baseline_id)}">${T('baseline')} →</a>` : ''}</p>` : ''}${RUN.verdict?.note ? `<p class="note">${esc(RUN.verdict.note)}</p>` : ''}${RUN.verdict?.approvers ? `<p class="note">${RUN.verdict.awaiting.length ? `${T('awaiting')}: <b>${RUN.verdict.awaiting.map(esc).join(', ')}</b>` : `${T('approvedByAll')}`}${RUN.verdict.approved_by.length ? ` · ${T('approvedBy')}: ${RUN.verdict.approved_by.map(esc).join(', ')}` : ''}${RUN.verdict.approved_by_tokens?.length ? ` · ${T('approvedBy')} ${T('approvedByTokens')}: ${RUN.verdict.approved_by_tokens.map((a) => esc(a.email)).join(', ')}` : ''}</p>` : ''}
|
|
3709
3772
|
${canWrite() ? `<textarea id="rvVerdictNote" ${loadDraft(draftKey('run', '', 'verdict')) ? '' : 'hidden '}rows="2" placeholder="${T('verdictNotePh')}" data-draft="${esc(draftKey('run', '', 'verdict'))}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
|
|
3710
|
-
<div class="share__row"
|
|
3773
|
+
<div class="share__row">${verdictButtons(RUN.verdict?.verdict, 'data-verdict', isDesign())}</div>
|
|
3711
3774
|
<div class="vmore">${loadDraft(draftKey('run', '', 'verdict')) ? '' : `<button class="linkish" id="rvNoteShow">${T('addNote')}</button>`}${RUN.verdict?.verdict === 'accepted' && !isDesign() ? `<button class="linkish" data-baseline="${RUN.verdict.baseline ? 'unset' : 'set'}">${RUN.verdict.baseline ? T('unsetBaseline') : T('setBaseline')}</button>` : ''}${APP().user?.role === 'admin' ? `<button class="linkish" data-public="${RUN.public ? 'off' : 'on'}" title="${T('publicHint')}">${RUN.public ? T('makePrivate') : isDesign() ? T('makePublicVersion') : T('makePublic')}</button>` : ''}</div>` : ''}${RUN.public ? `<p class="note" style="margin:6px 0 0"><span class="badge st-partial">${T('publicRun')}</span> ${T('publicNote')}</p>` : ''}${signoffHtml()}</div>` : ''}
|
|
3712
3775
|
${commentsPanel()}
|
|
3713
3776
|
<div class="sec"><p class="verdict">${esc(c.pass)} / ${tot} ${T('stepsDone')} <em>· ${ok}/${all.length} ${T('criteriaWord')} (${esc(S.loc)})</em></p>
|
|
@@ -3739,9 +3802,9 @@ function renderReport () {
|
|
|
3739
3802
|
const sc = $('shareCopy'); if (sc) sc.onclick = async () => { const link = $('shareUrl').textContent; try { await navigator.clipboard.writeText(link); toast(T('linkCopied')); } catch { toast(link); } };
|
|
3740
3803
|
const showNote = () => { const t = $('rvVerdictNote'); if (!t) return; t.hidden = false; $('rvNoteShow')?.remove(); t.focus(); };
|
|
3741
3804
|
if ($('rvNoteShow')) $('rvNoteShow').onclick = showNote;
|
|
3742
|
-
$('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3805
|
+
$('rside').querySelectorAll('[data-verdict]').forEach((b) => { b.onclick = async () => { const note = $('rvVerdictNote').value.trim(); if (b.dataset.verdict === 'returned' && !note) { showNote(); toast(T('noteRequired')); return; } b.disabled = true; try { await postJson('/verdict', { verdict: b.dataset.verdict, note, seen: RUN.verdict?.at ?? null }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; if (/meanwhile/.test(e.message)) reloadRun().catch(() => {}); } }; });
|
|
3743
3806
|
$('rside').querySelectorAll('[data-public]').forEach((b) => { b.onclick = async () => { if (b.dataset.public === 'on' && !confirm(T('publicConfirm'))) return; b.disabled = true; try { await postJson('/public', { public: b.dataset.public === 'on' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3744
|
-
$('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set' }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3807
|
+
$('rside').querySelectorAll('[data-baseline]').forEach((b) => { b.onclick = async () => { b.disabled = true; try { await postJson('/verdict', { verdict: 'accepted', note: RUN.verdict?.note || '', baseline: b.dataset.baseline === 'set', seen: RUN.verdict?.at ?? null }); toast(T('saved')); await reloadRun(); } catch (e) { toast(e.message); b.disabled = false; } }; });
|
|
3745
3808
|
$('rside').querySelectorAll('[data-go],tr[data-id]').forEach((el) => { el.onclick = () => { if (byId(el.dataset.go || el.dataset.id)) openLb(el.dataset.go || el.dataset.id); }; });
|
|
3746
3809
|
// the version's summary in someone's own words (designers; step texts too through the API, MCP and --notes)
|
|
3747
3810
|
const ne = $('notesEdit'); if (ne) ne.onclick = async () => {
|
|
@@ -5009,7 +5072,7 @@ function issBadge (id) {
|
|
|
5009
5072
|
/** The run's own issue (named by its branch or pull request) beside the GitHub issue in the footer. */
|
|
5010
5073
|
function issFootHtml () {
|
|
5011
5074
|
const l = ISS?.data?.linked; if (!l) return '';
|
|
5012
|
-
return `<span data-iss-linked title="${esc(T('issBranch'))}">${l.url ? `<a class="pr" href="${esc(l.url)}" target="_blank" rel="noopener">${esc(l.key)}</a>` : `<span class="mono">${esc(l.key)}</span>`}${l.state ? ` <span class="note">${esc(T('iss_' + l.state))}</span>` : ''} · </span>`;
|
|
5075
|
+
return `<span data-iss-linked title="${esc(T('issBranch'))}">${l.url ? `<a class="pr" href="${esc(webHref(l.url))}" target="_blank" rel="noopener">${esc(l.key)}</a>` : `<span class="mono">${esc(l.key)}</span>`}${l.state ? ` <span class="note">${esc(T('iss_' + l.state))}</span>` : ''} · </span>`;
|
|
5013
5076
|
}
|
|
5014
5077
|
function issFoot () { const f = $('scFoot'); if (f && !f.querySelector('[data-iss-linked]')) f.insertAdjacentHTML('afterbegin', issFootHtml()); }
|
|
5015
5078
|
/** Issues of a step and the form to create one, in the step detail and in review mode. */
|
|
@@ -5018,7 +5081,7 @@ function issuesBlock (n, v) {
|
|
|
5018
5081
|
const list = issOf(n.id); const trackers = issTrackers();
|
|
5019
5082
|
const can = ISS.data.can_create && trackers.length && canWrite();
|
|
5020
5083
|
if (!list.length && !can) return '';
|
|
5021
|
-
const items = list.length ? `<ul class="iss-list">${list.map((i) => `<li><a class="pr" href="${esc(i.url)}" target="_blank" rel="noopener">${esc(i.key)}</a> <span class="badge st-${i.state === 'open' ? 'partial' : i.state === 'done' ? 'pass' : 'skip'}">${esc(T('iss_' + i.state))}</span>${i.criterion ? ` <span class="id">${esc(i.criterion)}</span>` : ''} <span class="note">${esc(i.title)}</span></li>`).join('')}</ul>` : '';
|
|
5084
|
+
const items = list.length ? `<ul class="iss-list">${list.map((i) => `<li><a class="pr" href="${esc(webHref(i.url))}" target="_blank" rel="noopener">${esc(i.key)}</a> <span class="badge st-${i.state === 'open' ? 'partial' : i.state === 'done' ? 'pass' : 'skip'}">${esc(T('iss_' + i.state))}</span>${i.criterion ? ` <span class="id">${esc(i.criterion)}</span>` : ''} <span class="note">${esc(i.title)}</span></li>`).join('')}</ul>` : '';
|
|
5022
5085
|
let tail = '';
|
|
5023
5086
|
if (can && ISS.form === n.id) {
|
|
5024
5087
|
const crit = (n.criteria || []).slice().sort((a, b) => (a.verdict === 'fail' ? 0 : 1) - (b.verdict === 'fail' ? 0 : 1));
|
|
@@ -5281,7 +5344,7 @@ function rvEndHtml () {
|
|
|
5281
5344
|
const v = RUN.verdict || d.verdict || {};
|
|
5282
5345
|
const verdict = rvWritable() ? `<div class="sec verdict-sec"><h4 class="vhead"><span>${T('runVerdict')}</span><span class="badge st-${v.verdict === 'accepted' ? 'pass' : v.verdict === 'returned' ? 'fail' : 'skip'}">${T(v.verdict || 'pending')}</span></h4><p class="note">${esc(T('rvVerdictLead'))}${returned ? ` ${esc(T('rvReturnedHint'))}` : ''}</p>${v.approvers ? `<p class="note">${v.awaiting?.length ? `${T('awaiting')}: <b>${v.awaiting.map(esc).join(', ')}</b>` : T('approvedByAll')}</p>` : ''}
|
|
5283
5346
|
<textarea id="rvVNote" rows="2" placeholder="${esc(T('verdictNotePh'))}" data-draft="${esc(draftKey('run', '', 'verdict'))}" style="width:100%;font:inherit;padding:8px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink);margin:0 0 8px">${esc(loadDraft(draftKey('run', '', 'verdict')))}</textarea>
|
|
5284
|
-
<div class="share__row"
|
|
5347
|
+
<div class="share__row">${verdictButtons(v.verdict, 'data-rv-verdict', false)}</div></div>` : '';
|
|
5285
5348
|
return `<div class="rvm__end"><h3>${esc(head)}</h3><p class="note">${esc(sum)}</p>${d.reference ? `<p class="note">${esc(T('rvComparedWith'))}: <a class="pr" href="${runHref(d.reference.id)}">${fmtDate(d.reference.started_at)}${d.reference.accepted_by ? ` · ${esc(d.reference.accepted_by)}` : ''}</a></p>` : ''}${list}${carried}${verdict}</div>`;
|
|
5286
5349
|
}
|
|
5287
5350
|
function rvWire (it, n, v) {
|
|
@@ -5414,7 +5477,7 @@ async function rvVerdict (verdict, b) {
|
|
|
5414
5477
|
const note = $('rvVNote')?.value.trim() || '';
|
|
5415
5478
|
if (verdict === 'returned' && !note) { toast(T('noteRequired')); $('rvVNote')?.focus(); return; }
|
|
5416
5479
|
b.disabled = true;
|
|
5417
|
-
try { await postJson('/verdict', { verdict, note }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
5480
|
+
try { await postJson('/verdict', { verdict, note, seen: RUN.verdict?.at ?? null }); saveDraft(draftKey('run', '', 'verdict'), ''); toast(T('saved')); await reloadRun(); await rvLoad(); rvRender(); } catch (e) { toast(e.message); b.disabled = false; }
|
|
5418
5481
|
}
|
|
5419
5482
|
/** Starts review mode support once the run is on screen: the button, its count and the hash. */
|
|
5420
5483
|
function rvInit () {
|