{ "nodes": [ { "parameters": { "httpMethod": "POST", "path": "lock-acquire", "responseMode": "responseNode", "options": { "allowedOrigins": "*" } }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -48, 0 ], "id": "ac5d001c-cd97-448d-8782-4bd9f1669eca", "name": "Lock Acquire Webhook", "webhookId": "lock-acquire" }, { "parameters": { "jsCode": "// ═══ Lock Acquire Handler — no TTL dependency ═══\n//\n// Key changes:\n// 1. Lock check uses is_running (server truth) instead of expires_at\n// 2. Removed expires_at from lock object — lock lives until:\n// - All tests complete (auto-release in Store Result)\n// - Explicit release (Lock Release webhook from HTML)\n// - Abandoned-run safety net (30 min in Heartbeat)\n// 3. kept held_since and last_heartbeat for diagnostics only\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\nif (!staticData.testState) staticData.testState = null;\n\nconst body = $input.first().json.body || {};\nconst clientId = (body.client_id || '').trim();\nconst sessionId = (body.session_id || '').trim();\nconst tests = Array.isArray(body.tests) ? body.tests : [];\nconst now = Math.floor(Date.now() / 1000);\n\nif (!clientId) {\n return [{ json: { success: false, error: 'client_id required', _statusCode: 400 } }];\n}\n\nif (tests.length === 0) {\n return [{ json: { success: false, error: 'tests array required', _statusCode: 400 } }];\n}\n\n// ── Check existing active run ──────────────────────────────\n// Use is_running as the source of truth, not expires_at\nconst existing = staticData.testState;\nif (existing && existing.is_running && existing.lock) {\n const lk = existing.lock;\n return [{ json: {\n success: false,\n error: 'Test run already in progress',\n lock_held_by: lk.held_by,\n lock_held_since: lk.held_since,\n _statusCode: 409\n }}];\n}\n\n// ── Build testState with all tests as PENDING ──────────────\nconst testList = tests.map(t => ({\n id: parseInt(t.id) || 0,\n name: t.name || '',\n slug: t.slug || '',\n status: 'PENDING',\n result: null,\n elapsed: null,\n started_at: null,\n finished_at: null\n}));\n\nconst newState = {\n session_id: sessionId || null,\n is_running: true,\n started_at: now,\n completed_at: null,\n lock: {\n held_by: clientId,\n held_since: now,\n last_heartbeat: now\n // NO expires_at — lock lives until run completes or explicit release\n },\n tests: testList,\n summary: {\n pass: 0, fail: 0, skipped: 0,\n running: 0, pending: testList.length, total: testList.length\n },\n last_updated: now\n};\n\nstaticData.testState = newState;\n\n// ── Write status file ──────────────────────────────────────\nconst fs = require('fs');\ntry {\n fs.writeFileSync('/tmp/test-status.json', JSON.stringify(newState, null, 2));\n} catch(e) { /* non-fatal */ }\n\nreturn [{ json: {\n success: true,\n session_id: sessionId || null,\n lock: newState.lock,\n test_count: testList.length,\n _statusCode: 200\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 0 ], "id": "b2b47c06-adee-4f4b-a073-d90ef8f6dc1d", "name": "Lock Acquire Handler" }, { "parameters": { "command": "sh /home/node/scripts/upload_status.sh" }, "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 384, 0 ], "id": "93b01630-1fc8-4708-a68c-23092dfbd870", "name": "Lock Acquire Upload", "continueOnFail": true }, { "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ success: $('Lock Acquire Handler').first().json.success, session_id: $('Lock Acquire Handler').first().json.session_id, lock: $('Lock Acquire Handler').first().json.lock, test_count: $('Lock Acquire Handler').first().json.test_count, error: $('Lock Acquire Handler').first().json.error, lock_held_by: $('Lock Acquire Handler').first().json.lock_held_by, lock_held_since: $('Lock Acquire Handler').first().json.lock_held_since }) }}", "options": { "responseCode": "={{ $('Lock Acquire Handler').first().json._statusCode }}", "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 608, 0 ], "id": "3b2ebdbe-2da8-4e66-9b04-87c07308e62f", "name": "Lock Acquire Response" }, { "parameters": { "httpMethod": "POST", "path": "test-run-async", "responseMode": "responseNode", "options": { "allowedOrigins": "*" } }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -48, 240 ], "id": "b2fbf34f-918a-4cc5-a2e8-e926cbbdd69b", "name": "Start Test Webhook", "webhookId": "test-run-async" }, { "parameters": { "jsCode": "// ═══ Generate job_id and mark test as RUNNING ═══\n// New params vs original: test_id (int), test_name (string)\n// These must be sent by HTML in the test-run-async payload.\n\nconst body = $input.first().json.body || {};\nconst slug = (body.slug || '').trim();\nconst sessionId = (body.session_id || '').trim();\nconst deviceName = (body.device_name || '').trim();\nconst workflowId = (body.workflow_id || '').trim();\nconst testId = parseInt(body.test_id) || null;\nconst testName = (body.test_name || '').trim();\n\nif (!slug) {\n return [{ json: { _done: true, error: 'slug required', _statusCode: 400 } }];\n}\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\n\nconst jobId = 'job_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);\nconst now = Math.floor(Date.now() / 1000);\n\n// Build target URL from environment variable\nconst rawBase = ($env['N8N_WEBHOOK_BASE_URL'] || 'http://localhost:5678/webhook').replace(/\\/+$/, '');\nconst targetUrl = rawBase + '/' + slug;\n\nstaticData.jobs[jobId] = {\n job_id: jobId,\n slug,\n status: 'running',\n created_at: now,\n session_id: sessionId || null,\n test_id: testId,\n test_name: testName,\n result: null\n};\n\n// ── Update testState: mark this test RUNNING ───────────────\nconst ts = staticData.testState;\nif (ts && testId) {\n const entry = ts.tests.find(t => t.id === testId);\n if (entry) {\n entry.status = 'RUNNING';\n entry.started_at = now;\n }\n if (sessionId && !ts.session_id) ts.session_id = sessionId;\n\n const all = ts.tests;\n ts.summary = {\n pass: all.filter(t => t.status === 'PASS').length,\n fail: all.filter(t => t.status === 'FAIL').length,\n skipped: all.filter(t => t.status === 'SKIPPED').length,\n running: all.filter(t => t.status === 'RUNNING').length,\n pending: all.filter(t => t.status === 'PENDING').length,\n total: all.length\n };\n ts.last_updated = now;\n\n const fs = require('fs');\n try { fs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2)); } catch(e) {}\n}\n\nreturn [{ json: {\n job_id: jobId,\n slug,\n test_id: testId,\n test_name: testName,\n status: 'running',\n target_url: targetUrl,\n forward_payload: { session_id: sessionId, device_name: deviceName },\n _done: false,\n _statusCode: 202\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 240 ], "id": "67aa267f-b448-42c3-a961-7dc0571eee7a", "name": "Generate Job ID" }, { "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, "conditions": [ { "id": "done-check", "leftValue": "={{ $json._done }}", "rightValue": true, "operator": { "type": "boolean", "operation": "notEquals" } } ], "combinator": "and" }, "options": {} }, "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 384, 240 ], "id": "feb46b56-cb3b-465d-b6b0-c0bf9aa314c9", "name": "Valid Request?" }, { "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ job_id: $json.job_id, status: $json.status, slug: $json.slug }) }}", "options": { "responseCode": "={{ $json._statusCode }}", "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Access-Control-Allow-Headers", "value": "Content-Type" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 608, 144 ], "id": "ab5fc8c1-7dab-467b-a4cb-f4b1bc620c00", "name": "Respond 202 Accepted" }, { "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ error: $json.error }) }}", "options": { "responseCode": "={{ $json._statusCode }}", "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Access-Control-Allow-Headers", "value": "Content-Type" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 608, 368 ], "id": "e3ad22aa-ba30-4974-990f-9e30fc71356f", "name": "Respond Error" }, { "parameters": { "command": "sh /home/node/scripts/upload_status.sh" }, "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 832, 144 ], "id": "8432629b-05ed-4ba7-a93c-826527cb2c03", "name": "Upload Status Start", "continueOnFail": true }, { "parameters": { "method": "POST", "url": "={{ $('Generate Job ID').first().json.target_url }}", "sendBody": true, "bodyParameters": { "parameters": [ { "name": "session_id", "value": "={{ $('Generate Job ID').first().json.forward_payload.session_id || '' }}" }, { "name": "device_name", "value": "={{ $('Generate Job ID').first().json.forward_payload.device_name || '' }}" } ] }, "options": { "timeout": 48000000 } }, "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1056, 144 ], "id": "6da2a691-e499-4402-b752-0e961a43cc76", "name": "Call Actual Test", "executeOnce": true, "continueOnFail": true }, { "parameters": { "jsCode": "// ═══ Store Result + update testState + AUTO-COMPLETE ═══\n//\n// Key change: After storing a test result, check if ALL tests\n// have reached a terminal state. If so, auto-release the lock\n// and mark the run as completed. This removes dependency on\n// browser heartbeat TTL for run lifecycle management.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\nif (!staticData.testState) staticData.testState = null;\n\nconst genData = $('Generate Job ID').first().json;\nconst jobId = genData.job_id;\nconst testId = genData.test_id || null;\nconst rawResult = $input.first().json;\nconst now = Math.floor(Date.now() / 1000);\n\n// Terminal statuses — test is \"done\" in any of these\nconst TERMINAL = ['PASS', 'FAIL', 'ERROR', 'SKIPPED', 'TIMEOUT'];\n\n// ── Determine result values ────────────────────────────────\nlet testResult = 'ERROR';\nlet teastName = genData.slug || '';\nlet testActive = '';\nlet sessionId = genData.forward_payload ? genData.forward_payload.session_id : null;\nlet elapsed = null;\n\nif (!rawResult.error) {\n testResult = rawResult.test_result || rawResult.result || 'UNKNOWN';\n teastName = rawResult.teast_name || rawResult.test_name || genData.slug || '';\n testActive = rawResult.test_active || '';\n sessionId = rawResult.session_id || sessionId;\n elapsed = rawResult.elapsed || null;\n}\n\n// ── Update jobs ────────────────────────────────────────────\nif (staticData.jobs[jobId]) {\n Object.assign(staticData.jobs[jobId], {\n status: 'done',\n result: testResult,\n teast_name: teastName,\n test_active: testActive,\n session_id: sessionId,\n elapsed,\n finished_at: now\n });\n}\n\n// ── Update testState ───────────────────────────────────────\nconst ts = staticData.testState;\nlet runAutoCompleted = false;\n\nif (ts && testId) {\n const entry = ts.tests.find(t => t.id === testId);\n if (entry) {\n entry.status = testResult;\n entry.result = testResult;\n entry.elapsed = elapsed;\n entry.finished_at = now;\n }\n if (sessionId && !ts.session_id) ts.session_id = sessionId;\n\n // ── Recompute summary ──────────────────────────────────\n const all = ts.tests;\n ts.summary = {\n pass: all.filter(t => t.status === 'PASS').length,\n fail: all.filter(t => t.status === 'FAIL').length,\n skipped: all.filter(t => TERMINAL.includes(t.status) && !['PASS','FAIL'].includes(t.status)).length,\n running: all.filter(t => t.status === 'RUNNING').length,\n pending: all.filter(t => t.status === 'PENDING').length,\n total: all.length\n };\n ts.last_updated = now;\n\n // ══ AUTO-COMPLETE: if all tests are in terminal state ══\n const allDone = all.every(t => TERMINAL.includes(t.status));\n if (allDone && ts.is_running) {\n ts.is_running = false;\n ts.completed_at = now;\n ts.lock = null; // release lock server-side\n ts.summary.running = 0;\n ts.summary.pending = 0;\n runAutoCompleted = true;\n }\n\n const fs = require('fs');\n try { fs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2)); } catch(e) {}\n}\n\n// ── Prune old jobs (keep last 100) ─────────────────────────\nconst keys = Object.keys(staticData.jobs);\nif (keys.length > 100) {\n keys.sort((a, b) => (staticData.jobs[a].created_at || 0) - (staticData.jobs[b].created_at || 0));\n keys.slice(0, keys.length - 100).forEach(k => delete staticData.jobs[k]);\n}\n\nreturn [{ json: {\n job_id: jobId,\n test_result: testResult,\n teast_name: teastName,\n test_active: testActive,\n session_id: sessionId,\n elapsed,\n stored_at: now,\n run_auto_completed: runAutoCompleted\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1280, 144 ], "id": "399705d5-da3f-4c20-b59d-e837ce2cbe91", "name": "Store Result" }, { "parameters": { "command": "sh /home/node/scripts/upload_status.sh" }, "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 1488, 144 ], "id": "77270315-7ab5-4657-89fb-d109dfb1e2a3", "name": "Upload Status Result", "continueOnFail": true }, { "parameters": { "httpMethod": "POST", "path": "test-check", "responseMode": "responseNode", "options": { "allowedOrigins": "*" } }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -48, 480 ], "id": "d9bccaa3-f3d1-4ebb-a680-80a505cf8e50", "name": "Check Status Webhook", "webhookId": "test-check" }, { "parameters": { "jsCode": "// ═══ Check job status ═══\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\n\nconst body = $input.first().json.body || {};\nconst jobId = (body.job_id || '').trim();\nconst now = Math.floor(Date.now() / 1000);\n\nif (!jobId) {\n return [{ json: { error: 'job_id required', _statusCode: 400 } }];\n}\n\nconst job = staticData.jobs[jobId];\nif (!job) {\n return [{ json: { error: 'job not found', job_id: jobId, _statusCode: 404 } }];\n}\n\nif (job.status === 'done') {\n return [{ json: {\n job_id: jobId,\n status: 'done',\n session_id: job.session_id,\n teast_name: job.teast_name,\n test_active: job.test_active,\n test_result: job.result,\n elapsed: job.elapsed,\n _statusCode: 200\n }}];\n}\n\nconst elapsed_seconds = now - job.created_at;\nreturn [{ json: {\n job_id: jobId,\n status: 'running',\n elapsed_seconds,\n workflow_status: 'active',\n _statusCode: 202\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 480 ], "id": "d6ea640f-ab51-4fbe-9412-0499ce2c4149", "name": "Check Job Status" }, { "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify( $json._statusCode === 200 ? { job_id: $json.job_id, status: $json.status, session_id: $json.session_id, teast_name: $json.teast_name, test_active: $json.test_active, test_result: $json.test_result, elapsed: $json.elapsed } : { job_id: $json.job_id, status: $json.status, elapsed_seconds: $json.elapsed_seconds, workflow_status: $json.workflow_status, error: $json.error } ) }}", "options": { "responseCode": "={{ $json._statusCode }}", "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Access-Control-Allow-Headers", "value": "Content-Type" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 384, 480 ], "id": "29ecf76a-da10-4014-9706-bbf58b6f1727", "name": "Check Response" }, { "parameters": { "httpMethod": "POST", "path": "lock-release", "responseMode": "responseNode", "options": { "allowedOrigins": "*" } }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -48, 720 ], "id": "3a019802-f1f0-4d26-998a-5afb5d4ddc93", "name": "Lock Release Webhook", "webhookId": "lock-release" }, { "parameters": { "jsCode": "// ═══ Lock Release Handler ═══\n// Payload: { client_id }\n// Releases lock, marks only PENDING tests as SKIPPED.\n// RUNNING tests are left alone — Store Result will handle them\n// and auto-complete the run when the last one finishes.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.testState) staticData.testState = null;\n\nconst body = $input.first().json.body || {};\nconst clientId = (body.client_id || '').trim();\nconst now = Math.floor(Date.now() / 1000);\n\nif (!clientId) {\n return [{ json: { success: false, error: 'client_id required', _statusCode: 400 } }];\n}\n\nconst ts = staticData.testState;\n\n// No active lock for this client — idempotent success\nif (!ts || !ts.lock || ts.lock.held_by !== clientId) {\n return [{ json: { success: true, message: 'No active lock held by this client', _statusCode: 200 } }];\n}\n\n// ── Release lock ───────────────────────────────────────────\nts.lock = null;\nts.last_updated = now;\n\n// Mark only PENDING tests as SKIPPED (they'll never start).\n// RUNNING tests are left alone — Store Result will handle them\n// and auto-complete the run when the last one finishes.\nlet hasRunning = false;\nts.tests.forEach(t => {\n if (t.status === 'PENDING') {\n t.status = 'SKIPPED';\n t.finished_at = now;\n }\n if (t.status === 'RUNNING') {\n hasRunning = true;\n }\n});\n\n// Only mark run as complete if nothing is still running.\n// If a test is still RUNNING, Store Result will set these\n// when that test finishes (auto-complete logic).\nif (!hasRunning) {\n ts.is_running = false;\n ts.completed_at = now;\n}\n\n// Recompute summary\nconst all = ts.tests;\nts.summary = {\n pass: all.filter(t => t.status === 'PASS').length,\n fail: all.filter(t => t.status === 'FAIL').length,\n skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n running: all.filter(t => t.status === 'RUNNING').length,\n pending: 0,\n total: all.length\n};\n\nconst fs = require('fs');\ntry { fs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2)); } catch(e) {}\n\nreturn [{ json: {\n success: true,\n message: hasRunning ? 'Lock released, waiting for running test(s) to finish' : 'Lock released, run completed',\n has_running: hasRunning,\n completed_at: hasRunning ? null : now,\n summary: ts.summary,\n _statusCode: 200\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 720 ], "id": "60794ccd-cac8-41b4-ada0-624277d89173", "name": "Lock Release Handler" }, { "parameters": { "command": "sh /home/node/scripts/upload_status.sh" }, "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 384, 720 ], "id": "5a2790cb-edc1-4f66-8e4f-c63c857bddf5", "name": "Lock Release Upload", "continueOnFail": true }, { "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ success: $('Lock Release Handler').first().json.success, message: $('Lock Release Handler').first().json.message, completed_at: $('Lock Release Handler').first().json.completed_at, summary: $('Lock Release Handler').first().json.summary, error: $('Lock Release Handler').first().json.error }) }}", "options": { "responseCode": "={{ $('Lock Release Handler').first().json._statusCode }}", "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 608, 720 ], "id": "acba9117-90fa-4a95-b95b-2cb11d3eecb5", "name": "Lock Release Response" }, { "parameters": { "httpMethod": "POST", "path": "session-heartbeat", "responseMode": "responseNode", "options": { "allowedOrigins": "*" } }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ -48, 960 ], "id": "32692f0d-41ba-40f6-8c84-ba4d69da52b3", "name": "Heartbeat Webhook", "webhookId": "session-heartbeat" }, { "parameters": { "jsCode": "// ═══ Session Heartbeat — READ-ONLY (no destructive mutations) ═══\n//\n// Key changes vs previous version:\n// 1. REMOVED: Lock auto-expiry based on expires_at < now\n// This was the root cause of observer heartbeats killing active runs\n// when the runner's browser tab was throttled.\n// 2. Lock lifecycle is now managed by:\n// - Store Result (auto-completes when all tests finish)\n// - Lock Release (explicit release from HTML beforeunload / stop)\n// 3. ADDED: Conservative 30-minute abandoned-run safety net\n// Only cleans up if is_running=true but last_updated is stale 30+ min\n// AND no test is currently RUNNING. This handles crashed browsers.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.viewers) staticData.viewers = {};\nif (!staticData.jobs) staticData.jobs = {};\nif (!staticData.testState) staticData.testState = null;\n\nconst body = $input.first().json.body || {};\nconst clientId = (body.client_id || '').trim();\nconst now = Math.floor(Date.now() / 1000);\n\nif (!clientId) {\n return [{ json: { error: 'client_id required', _statusCode: 400 } }];\n}\n\nconst VIEWER_TTL = 60; // 1 minute — prune stale viewers\nconst ABANDON_TIMEOUT = 1800; // 30 minutes — safety net for crashed runners\n\nconst ts = staticData.testState;\nconst isRunner = !!(ts && ts.lock && ts.lock.held_by === clientId);\n\n// ── 1. Register / refresh this viewer ─────────────────────\nstaticData.viewers[clientId] = { last_seen: now, is_runner: isRunner };\n\n// ── 2. Prune stale viewers ─────────────────────────────────\nObject.keys(staticData.viewers).forEach(id => {\n if (now - staticData.viewers[id].last_seen > VIEWER_TTL) delete staticData.viewers[id];\n});\n\n// ── 3. Refresh last_heartbeat if this client is the runner ─\n// (informational only — not used for lock expiry anymore)\nif (ts && ts.lock && ts.lock.held_by === clientId) {\n ts.lock.last_heartbeat = now;\n ts.last_updated = now;\n}\n\n// ── 4. Abandoned-run safety net (30 min, very conservative) ─\n// Only triggers if:\n// - is_running is true\n// - last_updated is stale by 30+ minutes\n// - No test is currently in RUNNING state\n// This handles the edge case where browser crashed with no\n// beforeunload, AND the test workflow also crashed/hung.\nif (ts && ts.is_running && ts.last_updated) {\n const staleDuration = now - ts.last_updated;\n const hasRunningTest = ts.tests.some(t => t.status === 'RUNNING');\n\n if (staleDuration > ABANDON_TIMEOUT && !hasRunningTest) {\n ts.lock = null;\n ts.is_running = false;\n ts.completed_at = now;\n ts.last_updated = now;\n\n ts.tests.forEach(t => {\n if (t.status === 'PENDING') {\n t.status = 'SKIPPED';\n t.finished_at = now;\n }\n });\n\n const all = ts.tests;\n ts.summary = {\n pass: all.filter(t => t.status === 'PASS').length,\n fail: all.filter(t => t.status === 'FAIL').length,\n skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n running: 0,\n pending: 0,\n total: all.length\n };\n\n const fs = require('fs');\n try { fs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2)); } catch(e) {}\n }\n}\n\n// ── 5. Build response ──────────────────────────────────────\nconst viewerCount = Object.keys(staticData.viewers).length;\nconst lockState = (ts && ts.lock) ? {\n active: true,\n held_by: ts.lock.held_by,\n session_id: ts.session_id,\n held_since: ts.lock.held_since\n} : { active: false };\n\nconst responseBody = JSON.stringify({\n ok: true,\n viewer_count: viewerCount,\n is_runner: isRunner,\n lock: lockState,\n test_state: ts || null,\n server_time: now\n});\n\nreturn [{ json: {\n _response_body: responseBody,\n _statusCode: 200\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 960 ], "id": "aaf023b8-62dc-4f7e-8b7b-9506ca355a4c", "name": "Heartbeat Handler" }, { "parameters": { "respondWith": "json", "responseBody": "={{ $json._response_body }}", "options": { "responseCode": "={{ $json._statusCode }}", "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 384, 960 ], "id": "036dea2a-ded0-4f59-b492-e3a693c339a0", "name": "Heartbeat Response" }, { "parameters": { "jsCode": "// ═══ Lock Acquire Handler ═══\n// Payload: { client_id, session_id, tests: [{id, name, slug}] }\n// Checks no active lock, builds full testState, writes status file.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\nif (!staticData.testState) staticData.testState = null;\n\nconst body = $input.first().json.body || {};\nconst clientId = (body.client_id || '').trim();\nconst sessionId = (body.session_id || '').trim();\nconst tests = Array.isArray(body.tests) ? body.tests : [];\nconst now = Math.floor(Date.now() / 1000);\nconst LOCK_TTL = 120;\n\nif (!clientId) {\n return [{ json: { success: false, error: 'client_id required', _statusCode: 400 } }];\n}\n\nif (tests.length === 0) {\n return [{ json: { success: false, error: 'tests array required', _statusCode: 400 } }];\n}\n\n// ── Check existing active lock ─────────────────────────────\nconst existing = staticData.testState;\nif (existing && existing.lock && existing.lock.expires_at > now) {\n const lk = existing.lock;\n return [{ json: {\n success: false,\n error: 'Test run already in progress',\n lock_held_by: lk.held_by,\n lock_held_since: lk.held_since,\n lock_expires: lk.expires_at,\n _statusCode: 409\n }}];\n}\n\n// ── Build testState with all tests as PENDING ──────────────\nconst testList = tests.map(t => ({\n id: parseInt(t.id) || 0,\n name: t.name || '',\n slug: t.slug || '',\n status: 'PENDING',\n result: null,\n elapsed: null,\n started_at: null,\n finished_at: null\n}));\n\nconst newState = {\n session_id: sessionId || null,\n is_running: true,\n started_at: now,\n completed_at: null,\n lock: {\n held_by: clientId,\n held_since: now,\n expires_at: now + LOCK_TTL,\n last_heartbeat: now\n },\n tests: testList,\n summary: {\n pass: 0, fail: 0, skipped: 0,\n running: 0, pending: testList.length, total: testList.length\n },\n last_updated: now\n};\n\nstaticData.testState = newState;\n\n// ── Write status file (upload triggered by next node) ──────\nconst fs = require('fs');\ntry {\n fs.writeFileSync('/tmp/test-status.json', JSON.stringify(newState, null, 2));\n} catch(e) { /* non-fatal */ }\n\nreturn [{ json: {\n success: true,\n session_id: sessionId || null,\n lock: newState.lock,\n test_count: testList.length,\n _statusCode: 200\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, -160 ], "id": "99f535fe-25b9-4783-92cd-14879509badb", "name": "Lock Acquire Handler1" }, { "parameters": { "jsCode": "// ═══ Store Result + update testState ═══\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.jobs) staticData.jobs = {};\nif (!staticData.testState) staticData.testState = null;\n\nconst genData = $('Generate Job ID').first().json;\nconst jobId = genData.job_id;\nconst testId = genData.test_id || null;\nconst rawResult = $input.first().json;\nconst now = Math.floor(Date.now() / 1000);\n\n// ── Determine result values ────────────────────────────────\nlet testResult = 'ERROR';\nlet teastName = genData.slug || '';\nlet testActive = '';\nlet sessionId = genData.forward_payload ? genData.forward_payload.session_id : null;\nlet elapsed = null;\n\nif (!rawResult.error) {\n testResult = rawResult.test_result || rawResult.result || 'UNKNOWN';\n teastName = rawResult.teast_name || rawResult.test_name || genData.slug || '';\n testActive = rawResult.test_active || '';\n sessionId = rawResult.session_id || sessionId;\n elapsed = rawResult.elapsed || null;\n}\n\n// ── Update jobs ────────────────────────────────────────────\nif (staticData.jobs[jobId]) {\n Object.assign(staticData.jobs[jobId], {\n status: 'done',\n result: testResult,\n teast_name: teastName,\n test_active: testActive,\n session_id: sessionId,\n elapsed,\n finished_at: now\n });\n}\n\n// ── Update testState ───────────────────────────────────────\nconst ts = staticData.testState;\nif (ts && testId) {\n const entry = ts.tests.find(t => t.id === testId);\n if (entry) {\n entry.status = testResult;\n entry.result = testResult;\n entry.elapsed = elapsed;\n entry.finished_at = now;\n }\n if (sessionId && !ts.session_id) ts.session_id = sessionId;\n\n const all = ts.tests;\n ts.summary = {\n pass: all.filter(t => t.status === 'PASS').length,\n fail: all.filter(t => t.status === 'FAIL').length,\n skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n running: all.filter(t => t.status === 'RUNNING').length,\n pending: all.filter(t => t.status === 'PENDING').length,\n total: all.length\n };\n ts.last_updated = now;\n\n const fs = require('fs');\n try { fs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2)); } catch(e) {}\n}\n\n// ── Prune old jobs (keep last 100) ─────────────────────────\nconst keys = Object.keys(staticData.jobs);\nif (keys.length > 100) {\n keys.sort((a, b) => (staticData.jobs[a].created_at || 0) - (staticData.jobs[b].created_at || 0));\n keys.slice(0, keys.length - 100).forEach(k => delete staticData.jobs[k]);\n}\n\nreturn [{ json: { job_id: jobId, test_result: testResult, teast_name: teastName, test_active: testActive, session_id: sessionId, elapsed, stored_at: now } }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1280, -144 ], "id": "863fd9b4-1480-47af-b4b7-97e63faff6bc", "name": "Store Result1" }, { "parameters": { "jsCode": "// ═══ Session Heartbeat ═══\n// Payload: { client_id, token? }\n// Called every 30s by every open HTML tab.\n// Manages viewer presence + lock TTL refresh + lock auto-expiry.\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.viewers) staticData.viewers = {};\nif (!staticData.jobs) staticData.jobs = {};\nif (!staticData.testState) staticData.testState = null;\n\nconst body = $input.first().json.body || {};\nconst clientId = (body.client_id || '').trim();\nconst now = Math.floor(Date.now() / 1000);\n\nif (!clientId) {\n return [{ json: { error: 'client_id required', _statusCode: 400 } }];\n}\n\nconst LOCK_TTL = 120; // 2 minutes\nconst VIEWER_TTL = 60; // 1 minute\n\nconst ts = staticData.testState;\nconst isRunner = !!(ts && ts.lock && ts.lock.held_by === clientId);\n\n// ── 1. Register / refresh this viewer ─────────────────────\nstaticData.viewers[clientId] = { last_seen: now, is_runner: isRunner };\n\n// ── 2. Prune stale viewers ─────────────────────────────────\nObject.keys(staticData.viewers).forEach(id => {\n if (now - staticData.viewers[id].last_seen > VIEWER_TTL) delete staticData.viewers[id];\n});\n\n// ── 3. Refresh lock TTL if this client is the runner ──────\nif (ts && ts.lock && ts.lock.held_by === clientId) {\n ts.lock.expires_at = now + LOCK_TTL;\n ts.lock.last_heartbeat = now;\n}\n\n// ── 4. Auto-expire lock if runner gone >120s ──────────────\nif (ts && ts.lock && ts.lock.expires_at < now) {\n ts.lock = null;\n ts.is_running = false;\n ts.completed_at = now;\n ts.last_updated = now;\n ts.tests.forEach(t => {\n if (t.status === 'RUNNING' || t.status === 'PENDING') {\n t.status = 'SKIPPED'; t.finished_at = now;\n }\n });\n const all = ts.tests;\n ts.summary = {\n pass: all.filter(t => t.status === 'PASS').length,\n fail: all.filter(t => t.status === 'FAIL').length,\n skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n running: 0, pending: 0, total: all.length\n };\n // Write file on expiry (no upload here — fire-and-forget)\n const fs = require('fs');\n try { fs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2)); } catch(e) {}\n}\n\n// ── 5. Build response ──────────────────────────────────────\nconst viewerCount = Object.keys(staticData.viewers).length;\nconst lockState = (ts && ts.lock) ? {\n active: true,\n held_by: ts.lock.held_by,\n session_id: ts.session_id,\n held_since: ts.lock.held_since,\n expires_at: ts.lock.expires_at\n} : { active: false };\n\nconst responseBody = JSON.stringify({\n ok: true,\n viewer_count: viewerCount,\n is_runner: isRunner,\n lock: lockState,\n test_state: ts || null,\n server_time: now\n});\n\nreturn [{ json: {\n _response_body: responseBody,\n _statusCode: 200\n}}];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 160, 1168 ], "id": "7c67aacb-bd70-4ff6-b4c5-621d896ce4b2", "name": "Heartbeat Handler1" }, { "parameters": { "respondWith": "json", "responseBody": "={{ JSON.stringify({ success: $('Lock Acquire Handler').first().json.success, session_id: $('Lock Acquire Handler').first().json.session_id, lock: $('Lock Acquire Handler').first().json.lock, test_count: $('Lock Acquire Handler').first().json.test_count, error: $('Lock Acquire Handler').first().json.error, lock_held_by: $('Lock Acquire Handler').first().json.lock_held_by, lock_expires: $('Lock Acquire Handler').first().json.lock_expires }) }}", "options": { "responseCode": "={{ $('Lock Acquire Handler').first().json._statusCode }}", "responseHeaders": { "entries": [ { "name": "Access-Control-Allow-Origin", "value": "*" }, { "name": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" } ] } } }, "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [ 608, -160 ], "id": "9acab6a0-c8a8-4686-b28b-23c92dacdaa5", "name": "Lock Acquire Response1" }, { "parameters": { "jsCode": "// ═══ TEMPORARY — Force a test back to RUNNING ═══\n// Works via file directly (no staticData dependency)\n// Fire once, then delete.\n\nconst fs = require('fs');\nconst TARGET_ID = 13;\n\nlet ts;\ntry {\n ts = JSON.parse(fs.readFileSync('/tmp/test-status.json', 'utf8'));\n} catch(e) {\n return [{ json: { error: 'Cannot read status file: ' + e.message } }];\n}\n\nconst entry = ts.tests.find(t => t.id === TARGET_ID);\nif (!entry) return [{ json: { error: 'Test ID ' + TARGET_ID + ' not found in file' } }];\n\nconst before = entry.status;\n\nentry.status = 'RUNNING';\nentry.result = null;\nentry.finished_at = null;\n\nts.is_running = true;\nts.completed_at = null;\nts.last_updated = Math.floor(Date.now() / 1000);\n\n// Recompute summary\nconst all = ts.tests;\nts.summary = {\n pass: all.filter(t => t.status === 'PASS').length,\n fail: all.filter(t => t.status === 'FAIL').length,\n skipped: all.filter(t => ['SKIPPED','TIMEOUT','ERROR'].includes(t.status)).length,\n running: all.filter(t => t.status === 'RUNNING').length,\n pending: all.filter(t => t.status === 'PENDING').length,\n total: all.length\n};\n\nfs.writeFileSync('/tmp/test-status.json', JSON.stringify(ts, null, 2));\n\nreturn [{ json: {\n fixed: true,\n test_id: TARGET_ID,\n before,\n after: 'RUNNING',\n summary: ts.summary\n} }];" }, "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 2336, 880 ], "id": "5e4c3eac-eec3-4405-8316-f7a00b40899e", "name": "Code in JavaScript" }, { "parameters": { "command": "sh /home/node/scripts/upload_status.sh" }, "type": "n8n-nodes-base.executeCommand", "typeVersion": 1, "position": [ 2560, 880 ], "id": "e9d9bf38-ba99-47bf-8f1b-88d6694eb1bc", "name": "Upload Status Result1", "continueOnFail": true }, { "parameters": { "httpMethod": "POST", "path": "fix-status", "options": {} }, "type": "n8n-nodes-base.webhook", "typeVersion": 2.1, "position": [ 2112, 880 ], "id": "53b2b645-e5c5-4ebf-b5a3-1c043cb13c7f", "name": "Webhook", "webhookId": "a039bf9b-bed0-46d9-9aef-8ca10ae79772" } ], "connections": { "Lock Acquire Webhook": { "main": [ [ { "node": "Lock Acquire Handler", "type": "main", "index": 0 } ] ] }, "Lock Acquire Handler": { "main": [ [ { "node": "Lock Acquire Upload", "type": "main", "index": 0 } ] ] }, "Lock Acquire Upload": { "main": [ [ { "node": "Lock Acquire Response", "type": "main", "index": 0 } ] ] }, "Start Test Webhook": { "main": [ [ { "node": "Generate Job ID", "type": "main", "index": 0 } ] ] }, "Generate Job ID": { "main": [ [ { "node": "Valid Request?", "type": "main", "index": 0 } ] ] }, "Valid Request?": { "main": [ [ { "node": "Respond 202 Accepted", "type": "main", "index": 0 } ], [ { "node": "Respond Error", "type": "main", "index": 0 } ] ] }, "Respond 202 Accepted": { "main": [ [ { "node": "Upload Status Start", "type": "main", "index": 0 } ] ] }, "Upload Status Start": { "main": [ [ { "node": "Call Actual Test", "type": "main", "index": 0 } ] ] }, "Call Actual Test": { "main": [ [ { "node": "Store Result", "type": "main", "index": 0 } ] ] }, "Store Result": { "main": [ [ { "node": "Upload Status Result", "type": "main", "index": 0 } ] ] }, "Check Status Webhook": { "main": [ [ { "node": "Check Job Status", "type": "main", "index": 0 } ] ] }, "Check Job Status": { "main": [ [ { "node": "Check Response", "type": "main", "index": 0 } ] ] }, "Lock Release Webhook": { "main": [ [ { "node": "Lock Release Handler", "type": "main", "index": 0 } ] ] }, "Lock Release Handler": { "main": [ [ { "node": "Lock Release Upload", "type": "main", "index": 0 } ] ] }, "Lock Release Upload": { "main": [ [ { "node": "Lock Release Response", "type": "main", "index": 0 } ] ] }, "Heartbeat Webhook": { "main": [ [ { "node": "Heartbeat Handler", "type": "main", "index": 0 } ] ] }, "Heartbeat Handler": { "main": [ [ { "node": "Heartbeat Response", "type": "main", "index": 0 } ] ] }, "Code in JavaScript": { "main": [ [ { "node": "Upload Status Result1", "type": "main", "index": 0 } ] ] }, "Upload Status Result1": { "main": [ [] ] }, "Webhook": { "main": [ [ { "node": "Code in JavaScript", "type": "main", "index": 0 } ] ] } }, "pinData": { "Heartbeat Webhook": [ { "headers": { "connection": "upgrade", "host": "dev-test.local", "x-real-ip": "192.168.1.186", "x-forwarded-for": "192.168.1.186", "x-forwarded-proto": "https", "x-forwarded-host": "dev-test.local", "x-forwarded-port": "443", "content-length": "45", "user-agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0", "accept": "*/*", "accept-language": "en-US,en;q=0.9", "accept-encoding": "gzip, deflate, br, zstd", "content-type": "application/json", "origin": "null", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "cross-site", "priority": "u=4" }, "params": {}, "query": {}, "body": { "client_id": "cl_vm430bxibuo2doz", "token": "" }, "webhookUrl": "http://localhost:5678/webhook/session-heartbeat", "executionMode": "production" } ] }, "meta": { "instanceId": "27b239c52b459d8212719c67a4c194acfc86902b0c17a1abc2e1d6409dd5d73c" } }