From e89def5f86befa78488e815b7e9bcd7c211835a2 Mon Sep 17 00:00:00 2001 From: MartinSStewart Date: Wed, 7 May 2025 13:22:42 +0200 Subject: [PATCH 1/6] Add program-test version of LocalDev.elm --- extra/LocalDev/LocalDevProgramTest.elm | 182 +++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 extra/LocalDev/LocalDevProgramTest.elm diff --git a/extra/LocalDev/LocalDevProgramTest.elm b/extra/LocalDev/LocalDevProgramTest.elm new file mode 100644 index 00000000..25c6394f --- /dev/null +++ b/extra/LocalDev/LocalDevProgramTest.elm @@ -0,0 +1,182 @@ +port module LocalDev exposing (main) + +{- + + Hello you curious thing! + + This is the development harness used for local development of Lamdera apps. + + This file should not be used as a reference for building Lamdera apps, see + https://dashboard.lamdera.app/docs/building instead. + + The features used by this file are subject to change/removal and should not + be relied on in any way. + +-} + +import Backend +import Effect.LocalDev exposing (ConnectionMsg, Msg(..), WireMsg) +import Env +import Frontend +import Json.Encode +import Lamdera exposing (ClientId, Key, SessionId, Url) +import Lamdera.Json as Json +import Lamdera.Wire3 exposing (Bytes) +import LamderaRPC +import RPC +import Types + + +{-| Injected when parsed by Lamdera/CLI/Live.hs +-} +currentVersion = + ( 0, 0, 0 ) + + +port send_ToBackend : Bytes -> Cmd msg + + +port receive_ToBackend : (( SessionId, ClientId, Bytes ) -> msg) -> Sub msg + + +port save_BackendModel : { t : String, f : Bool, b : Bytes } -> Cmd msg + + +port send_EnvMode : { t : String, v : String } -> Cmd msg + + +port send_ToFrontend : WireMsg -> Cmd msg + + +port receive_ToFrontend : (WireMsg -> msg) -> Sub msg + + + +-- @TODO this isn't used currently but needs to adapt for state restore functions? + + +port receive_BackendModel : (Bytes -> msg) -> Sub msg + + + +-- @LEGCACY END + + +port setNodeTypeLeader : (Bool -> msg) -> Sub msg + + +port setLiveStatus : (Bool -> msg) -> Sub msg + + +port setClientId : (String -> msg) -> Sub msg + + +port rpcIn : (Json.Value -> msg) -> Sub msg + + +port rpcOut : Json.Value -> Cmd msg + + +port onConnection : (ConnectionMsg -> msg) -> Sub msg + + +port onDisconnection : (ConnectionMsg -> msg) -> Sub msg + + +port localDevGotEvent : (Json.Encode.Value -> msg) -> Sub msg + + +port localDevStartRecording : () -> Cmd msg + + +port localDevStopRecording : () -> Cmd msg + + +port localDevCopyToClipboard : String -> Cmd msg + + +main = + let + _ = + shouldProxy + in + Effect.LocalDev.localDev + { send_ToBackend = send_ToBackend + , receive_ToBackend = receive_ToBackend + , save_BackendModel = save_BackendModel + , send_EnvMode = send_EnvMode + , send_ToFrontend = send_ToFrontend + , receive_ToFrontend = receive_ToFrontend + , receive_BackendModel = receive_BackendModel + , setNodeTypeLeader = setNodeTypeLeader + , setLiveStatus = setLiveStatus + , setClientId = setClientId + , rpcIn = rpcIn + , mkrrc = mkrrc + , onConnection = onConnection + , onDisconnection = onDisconnection + , localDevGotEvent = localDevGotEvent + , localDevStartRecording = localDevStartRecording + , localDevStopRecording = localDevStopRecording + , localDevCopyToClipboard = localDevCopyToClipboard + , currentVersion = currentVersion + , w3_encode_BackendModel = Types.w3_encode_BackendModel + , w3_decode_BackendModel = Types.w3_decode_BackendModel + , w3_encode_ToFrontend = Types.w3_encode_ToFrontend + , w3_decode_ToFrontend = Types.w3_decode_ToFrontend + , w3_encode_ToBackend = Types.w3_encode_ToBackend + , w3_decode_ToBackend = Types.w3_decode_ToBackend + , envMeta = + case Env.mode of + Env.Production -> + ( "Prod", "#E06C75" ) + + Env.Development -> + ( "Dev", "#85BC7A" ) + , userFrontendApp = Frontend.app + , userBackendApp = Backend.app + } + + +mkrrc m rpcArgsJson = + let + log t v = + if m.devbar.logging then + Debug.log t v + + else + v + in + -- The case expression is just so we have the right amount of tabbing for the code insertion + case () of + () -> + -- MKRRC + ( m, Cmd.none ) + + + +-- -} + + +{-| Used directly by the core CORS modification to decide which Msg types need + +the CORS flag set for subsequent Cmd's they'll initiate + +-} +shouldProxy : Msg frontendMsg backendMsg toFrontend toBackend -> Bool +shouldProxy msg = + case msg of + BEMsg _ -> + True + + FEtoBE _ -> + True + + FEtoBEDelayed _ -> + True + + ReceivedToBackend _ -> + True + + _ -> + False From 6b7090d9b4eb0114d9f130773fc6b41e115f877d Mon Sep 17 00:00:00 2001 From: MartinSStewart Date: Wed, 7 May 2025 13:31:00 +0200 Subject: [PATCH 2/6] Add test recording js --- extra/test-recording.js | 542 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 extra/test-recording.js diff --git a/extra/test-recording.js b/extra/test-recording.js new file mode 100644 index 00000000..4142c810 --- /dev/null +++ b/extra/test-recording.js @@ -0,0 +1,542 @@ +// Is included as a elm-pkg-js module when running lamdera/program-test version 4.0.0 or greater +exports.init = async function init(app) +{ + app.ports.localDevCopyToClipboard.subscribe(text => copyTextToClipboard(text)); + + function copyTextToClipboard(text) { + if (!navigator.clipboard) { + fallbackCopyTextToClipboard(text); + return; + } + navigator.clipboard.writeText(text).then(function() { + }, function(err) { + console.error('Error: Could not copy text: ', err); + }); + } + + function fallbackCopyTextToClipboard(text) { + var textArea = document.createElement("textarea"); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + var successful = document.execCommand('copy'); + if (successful !== true) { + console.log('Error: Copying text command was unsuccessful'); + } + } catch (err) { + console.error('Error: Oops, unable to copy', err); + } + + document.body.removeChild(textArea); + } + + var dataPath = "/tests/data/"; + var startTime = Date.now(); + + var orig_XMLHttpRequest = window.XMLHttpRequest; + var orig_open = window.XMLHttpRequest.prototype.open; + + XMLHttpRequest.prototype.open = function() { + this.requestData = arguments; + orig_open.apply(this, arguments); + }; + + app.ports.localDevStartRecording.subscribe(() => + { + var isRecording = true; + app.ports.localDevStopRecording.subscribe(() => { isRecording = false; }) + + function sendHelper2(eventName, eventData, timeSinceStart) { + if (!isRecording) { + return; + } + let payload = { timestamp : startTime + timeSinceStart, eventType : { tag : eventName, args : eventData } }; + app.ports.localDevGotEvent.send(payload); + } + + function keysToIgnore(event) { + return (event.ctrlKey && event.altKey && event.key === "x" + ) || (event.metaKey && event.altKey && event.key === "v"); + } + + + // Copied from here https://github.com/GaurangTandon/checkEventAdded + var hasEvent; + (function (window) { + hasEvent = function (elm, type) { + var ev = elm.dataset.events; + if (!ev) return false; + + return (new RegExp(type)).test(ev); + }; + + function addRemoveEvent(elm, type, bool) { + if (bool) elm.dataset.events += "," + type; + else elm.dataset.events = elm.dataset.events.replace(new RegExp(type), ""); + } + + function sendPointerEvent(name, event, id) { + sendHelper2( + name, + { targetId : id + , ctrlKey : event.ctrlKey + , metaKey : event.metaKey + , shiftKey : event.shiftKey + , altKey : event.altKey + , clientX : event.clientX + , clientY : event.clientY + , offsetX : event.offsetX + , offsetY : event.offsetY + , pageX : event.pageX + , pageY : event.pageY + , screenX : event.screenX + , screenY : event.screenY + , button : event.button + , pointerType : event.pointerType + , pointerId : event.pointerId + , isPrimary : event.isPrimary + , width : event.width + , height : event.height + , pressure : event.pressure + , tiltX : event.tiltX + , tiltY : event.tiltY + } + , event.timeStamp); + } + + function sendMouseEvent(name, event, id) { + sendHelper2( + name, + { targetId : id + , ctrlKey : event.ctrlKey + , metaKey : event.metaKey + , shiftKey : event.shiftKey + , altKey : event.altKey + , clientX : event.clientX + , clientY : event.clientY + , offsetX : event.offsetX + , offsetY : event.offsetY + , pageX : event.pageX + , pageY : event.pageY + , screenX : event.screenX + , screenY : event.screenY + , button : event.button + } + , event.timeStamp); + } + + function sendWheelEvent(event, id) { + sendHelper2( + "Wheel", + { deltaX : event.deltaX + , deltaY : event.deltaY + , deltaZ : event.deltaZ + , deltaMode : event.deltaMode + , mouseEvent : + { targetId : id + , ctrlKey : event.ctrlKey + , metaKey : event.metaKey + , shiftKey : event.shiftKey + , altKey : event.altKey + , clientX : event.clientX + , clientY : event.clientY + , offsetX : event.offsetX + , offsetY : event.offsetY + , pageX : event.pageX + , pageY : event.pageY + , screenX : event.screenX + , screenY : event.screenY + , button : event.button + } + } + , event.timeStamp); + } + + function sendTouchEvent(name, event, id) { + function helper(array) + { + let newArray = []; + for (let i = 0; i < array.length; i++) { + let a = array[i]; + newArray.push({ clientX : a.clientX , clientY : a.clientY , pageX : a.pageX , pageY : a.pageY , screenX : a.screenX , screenY : a.screenY , identifier : a.identifier }); + } + return newArray; + } + sendHelper2( + name, + { targetId : id + , ctrlKey : event.ctrlKey + , metaKey : event.metaKey + , shiftKey : event.shiftKey + , altKey : event.altKey + , changedTouches : helper(event.changedTouches) + , targetTouches : helper(event.targetTouches) + , touches : helper(event.touches) + } + , event.timeStamp); + } + + let dict = new Map(); + + function makeListener(name, bool) { + var f = EventTarget.prototype[name + "EventListener"]; + + return function (type, callback, capture, cb1, cb2) { + if (!this.dataset) this.dataset = {}; + if (!this.dataset.events) this.dataset.events = ""; + + let newCallback = dict.get(callback); + if (newCallback) { + + } + else + { + newCallback = (event) => { + if (isInsideDevBar(event.target)) { + + } + else + { + switch (type) + { + case "wheel": + { + if (this.id) { + sendWheelEvent(event, this.id); + } + break; + } + case "input": + { + if (this.id) { + sendHelper2("Input", { targetId : this.id, text : this.value }, event.timeStamp); + } + break; + } + case "keydown": + { + if (!keysToIgnore(event) && this.id) { + sendHelper2("KeyDown", { targetId : this.id, ctrlKey : event.ctrlKey , metaKey : event.metaKey , shiftKey : event.shiftKey, altKey : event.altKey, key : event.key }, event.timeStamp); + } + break; + } + case "keyup": + { + if (!keysToIgnore(event) && this.id) { + sendHelper2("KeyUp", { targetId : this.id, ctrlKey : event.ctrlKey , metaKey : event.metaKey , shiftKey : event.shiftKey, altKey : event.altKey, key : event.key }, event.timeStamp); + } + break; + } + case "focus": { if (this.id) { sendHelper2("Focus", { targetId : this.id }, event.timeStamp); } break; } + case "blur": { if (this.id) { sendHelper2("Blur", { targetId : this.id }, event.timeStamp); } break; } + case "pointerdown": { if (this.id) { sendPointerEvent("PointerDown", event, this.id); } break; } + case "pointerup": { if (this.id) { sendPointerEvent("PointerUp", event, this.id); } break; } + case "pointercancel": { if (this.id) { sendPointerEvent("PointerCancel", event, this.id); } break; } + case "pointerleave": { if (this.id) { sendPointerEvent("PointerLeave", event, this.id); } break; } + case "pointermove": { if (this.id) { sendPointerEvent("PointerMove", event, this.id); } break; } + case "pointerover": { if (this.id) { sendPointerEvent("PointerOver", event, this.id); } break; } + case "pointerenter": { if (this.id) { sendPointerEvent("PointerEnter", event, this.id); } break; } + case "pointerout": { if (this.id) { sendPointerEvent("PointerOut", event, this.id); } break; } + case "touchstart": { if (this.id) { sendTouchEvent("TouchStart", event, this.id); } break; } + case "touchmove": { if (this.id) { sendTouchEvent("TouchMove", event, this.id); } break; } + case "touchend": { if (this.id) { sendTouchEvent("TouchEnd", event, this.id); } break; } + case "touchcancel": { if (this.id) { sendTouchEvent("TouchCancel", event, this.id); } break; } + case "mousedown": { if (this.id) { sendMouseEvent("MouseDown", event, this.id); } break; } + case "mouseup": { if (this.id) { sendMouseEvent("MouseUp", event, this.id); } break; } + case "mouseleave": { if (this.id) { sendMouseEvent("MouseLeave", event, this.id); } break; } + case "mousemove": { if (this.id) { sendMouseEvent("MouseMove", event, this.id); } break; } + case "mouseover": { if (this.id) { sendMouseEvent("MouseOver", event, this.id); } break; } + case "mouseenter": { if (this.id) { sendMouseEvent("MouseEnter", event, this.id); } break; } + case "mouseout": { if (this.id) { sendMouseEvent("MouseOut", event, this.id); } break; } + case "change": { + if (this.type == "file") { + + const files = event.target.files; + if (files) { + const list = []; + let filesLeft = files.length; + const allowMultiple = this.multiple; + for (var i = 0; i < files.length; i++) { + const fileItem = { name : files[i].name + , lastModified : files[i].lastModified + , contentFilePath : "" + , mimeType : files[i].type + }; + + + list.push(fileItem); + + const reader = new FileReader(); + reader.addEventListener('loadend', function(loadEvent) { + filesLeft = filesLeft - 1; + + bytesToSha256(loadEvent.target.result) + .then((result) => { + var path = dataPath + result.slice(0,16) + ".txt"; + writeToFile(path, loadEvent.target.result); + fileItem.contentFilePath = path; + if (filesLeft === 0) { + sendHelper2("Files", { files : list, allowMultiple : allowMultiple }, event.timeStamp); + } + }); + }); + reader.readAsArrayBuffer(files[i]); + } + } + } + } + } + } + callback(event); + }; + + dict.set(callback, newCallback); + } + + + + f.call(this, type, newCallback, capture); + addRemoveEvent(this, type, bool); + // + // if (cb1) cb1(); + + return true; + }; + } + + EventTarget.prototype.addEventListener = makeListener("add", true); + EventTarget.prototype.removeEventListener = makeListener("remove", false); + })(); + + function writeToFile(path, content) { + var xhr = new orig_XMLHttpRequest(); + xhr.open("POST", "/_x/write" + path, true); + xhr.setRequestHeader("content-type", "application/octet-stream"); + xhr.send(content); + } + + window.XMLHttpRequest = function() { + let xhr = new orig_XMLHttpRequest(); + xhr.addEventListener('loadend', (event) => + { + if (!isRecording) { + return; + } + if (xhr.requestData[1].startsWith("/")) { + sendHelper2("HttpLocal", { filepath : xhr.requestData[1] }, event.timeStamp); + } + else + { + switch (xhr.responseType) + { + case "arraybuffer": + bytesToSha256(xhr.response) + .then((result) => { + var path = dataPath + result.slice(0,16) + ".txt"; + writeToFile(path, xhr.response); + sendHelper2("Http", { responseType : xhr.responseType, filepath : path, request : xhr.request, method : xhr.requestData[0], url : xhr.requestData[1] }, event.timeStamp); + }); + break; + case "": + stringToSha256(xhr.response) + .then((result) => { + var path = dataPath + result.slice(0,16) + ".txt"; + writeToFile(path, xhr.response); + sendHelper2("Http", { responseType : xhr.responseType, filepath : path, request : xhr.request, method : xhr.requestData[0], url : xhr.requestData[1] }, event.timeStamp); + }); + break; + default: + debugger; + break; + } + } + }); + return xhr; + }; + + addEventListener("resize", (event) => { + sendHelper2("WindowResize", { width : window.innerWidth, height : window.innerHeight }, event.timeStamp); + }); + + addEventListener("paste", (event) => { + let targetId = getId(event.target); + + sendHelper2("Paste", { targetId : targetId, text : event.clipboardData.getData("text") }, event.timeStamp); + }); + + addEventListener("click", (event) => { + if (!viewCheckGenerationStarted) { + sendClickEvent("Click", event); + } + }); + + function sendClickEvent(elmName, event) { + if (isInsideDevBar(event.target)) { + return; + } + let data = getIdOrLink(event.target); + if (data) { + if (data.isLink) { + sendHelper2("ClickLink", { path : data.path }, event.timeStamp); + } + else { + sendHelper2(elmName, { targetId : data ? data.targetId : null }, event.timeStamp); + } + } + } + + function isInsideDevBar(target) { + if (target.id === "localDev_devbar") { + return true; + } + if (target.parentElement) { + return isInsideDevBar(target.parentElement); + } + } + + function getId(target) { + if (target.id && target.id !== "") { + return target.id; + } + if (target.parentElement) { + return getId(target.parentElement); + } + return null; + } + + function getIdOrLink(target) { + if (target.tagName == "A") { + return { isLink : true, path : target.getAttribute("href") }; + } + if (hasEvent(target, "click")) { + return { isLink : false, targetId : target.id }; + } + if (target.parentElement) { + return getIdOrLink(target.parentElement); + } + return null; + } + + async function stringToSha256(source) { + const sourceBytes = new TextEncoder().encode(source); + const digest = await crypto.subtle.digest("SHA-256", sourceBytes); + const resultBytes = [...new Uint8Array(digest)]; + return resultBytes.map(x => x.toString(16).padStart(2, '0')).join(""); + } + + async function bytesToSha256(sourceBytes) { + const digest = await crypto.subtle.digest("SHA-256", sourceBytes); + const resultBytes = [...new Uint8Array(digest)]; + return resultBytes.map(x => x.toString(16).padStart(2, '0')).join(""); + } + + for (let key in app.ports) { + switch (key) + { + case "localDevGotEvent": break; + case "localDevStartRecording": break; + case "localDevStopRecording": break; + case "setClientId": break; + case "setNodeTypeLeader": break; + case "setLiveStatus": break; + case "receive_ToBackend": break; + case "receive_ToFrontend": break; + case "receive_BackendModel": break; + case "onDisconnection": break; + case "onConnection": break; + default: { + if (app.ports[key].send) { + let oldSend = app.ports[key].send; + app.ports[key].send = (a) => { + sendHelper2("FromJsPort", { port : key, data : JSON.stringify(a) }, Date.now() - startTime); + oldSend(a); + }; + } + } + } + } + + var viewCheckGenerationStarted = false; + document.addEventListener( + "keydown", + (event) => { + if (!isRecording) { + return; + } + if (event.metaKey && event.altKey && event.code === "KeyV" && !viewCheckGenerationStarted) + { + const lastChild = document.body.lastElementChild; + + viewCheckGenerationStarted = true; + + let node = document.createElement("style"); + node.textContent = "* {\n" + + " user-select: text;\n" + + " pointer-events: none;\n" + + "}\n" + + "\n" + + "button, a, input, textarea {\n" + + " user-select: text;\n" + + " pointer-events: none;\n" + + "}" + document.body.appendChild(node); + event.preventDefault(); + } + }); + + document.addEventListener( + "keyup", + (event) => { + if (!isRecording) { + return; + } + if ((event.key === "Meta" || event.key === "Alt" || event.code === "KeyV") && viewCheckGenerationStarted) + { + viewCheckGenerationStarted = false; + const lastChild = document.body.lastElementChild; + + let selection = window.getSelection(); + + let count = selection.rangeCount; + + let items = []; + for (let i = 0; i < count; i++) { + let range = selection.getRangeAt(i); + try { + if (range.startContainer === range.endContainer) { + items.push( + range.startContainer.textContent.slice( + Math.min(range.startOffset, range.endOffset), + Math.max(range.startOffset, range.endOffset) + )); + } + else { + items.push(range.startContainer.textContent.slice(range.startOffset)); + items.push(range.endContainer.textContent.slice(0, range.endOffset)); + } + } + catch (e) { + } + } + if (items.length > 0) { + sendHelper2("CheckView", { selection : items }, event.timeStamp); + selection.removeAllRanges(); + } + + if (lastChild && lastChild.tagName === 'STYLE') { + document.body.removeChild(lastChild); + event.preventDefault(); + } + } + }); + }); +} From 13ae39021a7bf986dbe05f7633c9f1de64c88b1d Mon Sep 17 00:00:00 2001 From: MartinSStewart Date: Wed, 7 May 2025 15:02:52 +0200 Subject: [PATCH 3/6] wip --- builder/src/Deps/Registry.hs | 2 +- terminal/src/Develop.hs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/builder/src/Deps/Registry.hs b/builder/src/Deps/Registry.hs index 936a2000..5f579b34 100644 --- a/builder/src/Deps/Registry.hs +++ b/builder/src/Deps/Registry.hs @@ -255,7 +255,7 @@ lamderaCoreDeps = [ (Lamdera.Project.lamderaCodecs, KnownVersions { _newest = V.Version 1 0 0, _previous = [] }) , (Lamdera.Project.lamderaCore, KnownVersions { _newest = V.Version 1 0 0, _previous = [] }) , (Lamdera.Project.lamderaContainers, KnownVersions { _newest = V.Version 1 0 0, _previous = [] }) - , (Lamdera.Project.lamderaProgramTest, KnownVersions { _newest = V.Version 3 0 0, _previous = [ V.Version 1 0 0, V.Version 2 0 0] }) + , (Lamdera.Project.lamderaProgramTest, KnownVersions { _newest = V.Version 4 0 0, _previous = [ V.Version 1 0 0, V.Version 2 0 0, V.Version 3 0 0 ] }) , (Lamdera.Project.lamderaWebsocket, KnownVersions { _newest = V.Version 1 0 0, _previous = [] }) , (Lamdera.Project.lamderaFusion, KnownVersions { _newest = V.Version 1 0 0, _previous = [] }) ] diff --git a/terminal/src/Develop.hs b/terminal/src/Develop.hs index d4f2217b..caf18311 100644 --- a/terminal/src/Develop.hs +++ b/terminal/src/Develop.hs @@ -17,6 +17,7 @@ import qualified Data.ByteString.Builder as B import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as BSL import qualified Data.HashMap.Strict as HashMap +import qualified Data.Map as Map import Data.Monoid ((<>)) import qualified Data.NonEmptyList as NE import qualified System.Directory as Dir @@ -28,6 +29,8 @@ import Snap.Util.FileServe import qualified BackgroundWriter as BW import qualified Build import qualified Elm.Details as Details +import qualified Elm.Outline +import qualified Elm.Version import qualified Develop.Generate.Help as Help import qualified Develop.Generate.Index as Index import qualified Develop.StaticFiles as StaticFiles @@ -45,6 +48,7 @@ import qualified Lamdera.Constrain import qualified Lamdera.ReverseProxy import qualified Lamdera.TypeHash import qualified Lamdera.PostCompile +import qualified Lamdera.Project import qualified Data.List as List import Ext.Common (trackedForkIO, whenDebug) @@ -87,6 +91,22 @@ runWithRoot root (Flags maybePort) = sentryCache <- liftIO $ Sentry.init + elmJson <- Elm.Outline.read root True + + atomicPutStrLn $ + case elmJson of + Right (Elm.Outline.App (Elm.Outline.AppOutline _ _ direct _ _ _)) -> + case Map.lookup Lamdera.Project.lamderaProgramTest direct of + Just (Elm.Version.Version major _ _) -> + if major >= 4 then + "Has program test" + else + "Version is too low" + Nothing -> + "Program test not installed" + Right (Elm.Outline.Pkg a) -> "elm.json is a package" + Left _ -> "elm.json not found" + let recompile :: [String] -> IO () recompile events = do From e97bc9d92a372a7a2512eeccc4abe882d8fec0ce Mon Sep 17 00:00:00 2001 From: MartinSStewart Date: Wed, 7 May 2025 16:18:56 +0200 Subject: [PATCH 4/6] Override LocalDev --- terminal/src/Develop.hs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/terminal/src/Develop.hs b/terminal/src/Develop.hs index caf18311..e7736ef8 100644 --- a/terminal/src/Develop.hs +++ b/terminal/src/Develop.hs @@ -93,27 +93,13 @@ runWithRoot root (Flags maybePort) = elmJson <- Elm.Outline.read root True - atomicPutStrLn $ - case elmJson of - Right (Elm.Outline.App (Elm.Outline.AppOutline _ _ direct _ _ _)) -> - case Map.lookup Lamdera.Project.lamderaProgramTest direct of - Just (Elm.Version.Version major _ _) -> - if major >= 4 then - "Has program test" - else - "Version is too low" - Nothing -> - "Program test not installed" - Right (Elm.Outline.Pkg a) -> "elm.json is a package" - Left _ -> "elm.json not found" - let recompile :: [String] -> IO () recompile events = do -- Fork a recompile+cache update Sentry.asyncUpdateJsOutput sentryCache $ do debug_ $ "🛫 recompile triggered by: " ++ show events - harness <- Live.prepareLocalDev root + harness <- Live.prepareLocalDev (useLocalDevOverrides elmJson) root let typesRootChanged = events & filter (\event -> stringContains "src/Types.elm" event @@ -159,6 +145,21 @@ runWithRoot root (Flags maybePort) = <|> Live.serveUnmatchedUrlsToIndex root (serveElm sentryCache) -- Everything else without extensions goes to Lamdera LocalDev harness <|> error404 -- Will get hit for any non-matching extensioned paths i.e. /hello.blah +useLocalDevOverrides :: Either a Elm.Outline.Outline -> Bool +useLocalDevOverrides elmJson = + case elmJson of + Right (Elm.Outline.App (Elm.Outline.AppOutline _ _ direct _ _ _)) -> + case Map.lookup Lamdera.Project.lamderaProgramTest direct of + Just (Elm.Version.Version major _ _) -> + if major >= 3 then + True + else + False + + Nothing -> + False + _ -> + False -- Try narrow down source of exceptions with generalised error catch gcatchlog :: String -> Snap () -> Snap () From dfda01aa80222425441bbd9b4668148d2f6f5f63 Mon Sep 17 00:00:00 2001 From: MartinSStewart Date: Thu, 8 May 2025 00:18:06 +0200 Subject: [PATCH 5/6] Seems to work --- extra/Lamdera/CLI/Live.hs | 18 +++++++++++++---- extra/Lamdera/Injection.hs | 41 +++++++++++++++++++++++++++++++++----- terminal/src/Develop.hs | 19 +++--------------- 3 files changed, 53 insertions(+), 25 deletions(-) diff --git a/extra/Lamdera/CLI/Live.hs b/extra/Lamdera/CLI/Live.hs index 5fcc7b82..a7097a43 100644 --- a/extra/Lamdera/CLI/Live.hs +++ b/extra/Lamdera/CLI/Live.hs @@ -131,9 +131,14 @@ serveUnmatchedUrlsToIndex root serveElm = serveElm (lamderaCache root "LocalDev.elm") -prepareLocalDev :: FilePath -> IO FilePath -prepareLocalDev root = do - overrideM <- Lamdera.Relative.readFile "extra/LocalDev/LocalDev.elm" +prepareLocalDev :: Bool -> FilePath -> IO FilePath +prepareLocalDev useProgramTestOverrides root = do + overrideM <- + Lamdera.Relative.readFile $ + if useProgramTestOverrides then + "extra/LocalDev/LocalDevProgramTest.elm" + else + "extra/LocalDev/LocalDev.elm" let cache = lamderaCache root harnessPath = cache "LocalDev.elm" @@ -155,7 +160,7 @@ prepareLocalDev root = do Nothing -> writeIfDifferent harnessPath - (lamderaLocalDev + ((if useProgramTestOverrides then lamderaLocalDevProgramTest else lamderaLocalDev) & replaceVersionMarker & replaceRpcMarker rpcExists ) @@ -210,6 +215,11 @@ lamderaLocalDev = T.decodeUtf8 $(bsToExp =<< runIO (Lamdera.Relative.readByteString "extra/LocalDev/LocalDev.elm")) +lamderaLocalDevProgramTest :: Text +lamderaLocalDevProgramTest = + T.decodeUtf8 $(bsToExp =<< runIO (Lamdera.Relative.readByteString "extra/LocalDev/LocalDevProgramTest.elm")) + + refreshClients (mClients, mLeader, mChan, beState) = SocketServer.broadcastImpl mClients "{\"t\":\"r\"}" -- r is refresh, see live.js diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index 373c7ad8..fb8dab6e 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -29,8 +29,11 @@ import qualified Elm.Package as Pkg import qualified Elm.ModuleName as ModuleName import qualified AST.Optimized as Opt import qualified Elm.Kernel +import qualified Elm.Outline +import qualified Elm.Version import Lamdera +import qualified Lamdera.Project import qualified Lamdera.Relative import StandaloneInstances import qualified Ext.Common @@ -798,6 +801,7 @@ elmPkgJs mode = includesPathM <- Lamdera.Relative.findFile $ root "elm-pkg-js-includes.js" esbuildConfigPathM <- Lamdera.Relative.findFile $ root "esbuild.config.js" esbuildPathM <- Dir.findExecutable "esbuild" + elmJson <- Elm.Outline.read root True case (esbuildConfigPathM, esbuildPathM, includesPathM) of (Just esbuildConfigPath, _, _) -> @@ -819,21 +823,38 @@ elmPkgJs mode = error "no min file after compile, run `node esbuild.config.js` to check errors" else do Lamdera.debug_ "🏗️🟠 Using dumbJsPackager, ignoring esbuild.config.js in non-dev mode" - dumbJsPackager root elmPkgJsSources + dumbJsPackager (useProgramTestOverrides elmJson) root elmPkgJsSources (_, Just esbuildPath, Just includesPath) -> if Ext.Common.isDebug_ then do esbuildIncluder root esbuildPath includesPath else do Lamdera.debug_ "🏗️🟠 Using dumbJsPackager, ignoring esbuild in non-dev mode" - dumbJsPackager root elmPkgJsSources + dumbJsPackager (useProgramTestOverrides elmJson) root elmPkgJsSources _ -> do Lamdera.debug_ "🏗️ Using dumbJsPackager" - dumbJsPackager root elmPkgJsSources + dumbJsPackager (useProgramTestOverrides elmJson) root elmPkgJsSources _ -> "" +useProgramTestOverrides :: Either a Elm.Outline.Outline -> Bool +useProgramTestOverrides elmJson = + case elmJson of + Right (Elm.Outline.App (Elm.Outline.AppOutline _ _ direct _ _ _)) -> + case Map.lookup Lamdera.Project.lamderaProgramTest direct of + Just (Elm.Version.Version major _ _) -> + if major >= 3 then + True + else + False + + Nothing -> + False + _ -> + False + + esbuildIncluder :: FilePath -> FilePath -> FilePath -> IO B.Builder esbuildIncluder root esbuildPath includesPath = do minFile <- Lamdera.Relative.readFile $ root "elm-pkg-js-includes.min.js" @@ -869,11 +890,14 @@ esbuildIncluder root esbuildPath includesPath = do -- ) +lamderaTestRecordingJs :: Text +lamderaTestRecordingJs = + Text.decodeUtf8 $(bsToExp =<< runIO (Lamdera.Relative.readByteString "extra/test-recording.js")) -- Tries to be clever by injecting `{}` as the `exports` value. Falls over if the target files have been compiled -- by a packager or if they don't use the `export.init` syntax, i.e. `export async function init() {...}` -dumbJsPackager root elmPkgJsSources = do +dumbJsPackager addTestRecording root elmPkgJsSources = do wrappedPkgImports <- mapM (\f -> @@ -888,7 +912,14 @@ dumbJsPackager root elmPkgJsSources = do elmPkgJsSources pure $ B.byteString $ mconcat - [ "const pkgExports = {\n" <> mconcat wrappedPkgImports <> "\n}\n" + [ "const pkgExports = {\n" + <> mconcat wrappedPkgImports + <> (if addTestRecording then + "'program-test-recording.js': function(exports){\n" <> Text.encodeUtf8 lamderaTestRecordingJs <> "\nreturn exports;},\n" + else + "" + ) + <> "\n}\n" , "if (typeof window !== 'undefined') {" , " window.elmPkgJsIncludes = {" , " init: async function(app) {" diff --git a/terminal/src/Develop.hs b/terminal/src/Develop.hs index e7736ef8..284489d6 100644 --- a/terminal/src/Develop.hs +++ b/terminal/src/Develop.hs @@ -49,6 +49,7 @@ import qualified Lamdera.ReverseProxy import qualified Lamdera.TypeHash import qualified Lamdera.PostCompile import qualified Lamdera.Project +import qualified Lamdera.Injection import qualified Data.List as List import Ext.Common (trackedForkIO, whenDebug) @@ -99,7 +100,7 @@ runWithRoot root (Flags maybePort) = -- Fork a recompile+cache update Sentry.asyncUpdateJsOutput sentryCache $ do debug_ $ "🛫 recompile triggered by: " ++ show events - harness <- Live.prepareLocalDev (useLocalDevOverrides elmJson) root + harness <- Live.prepareLocalDev (Lamdera.Injection.useProgramTestOverrides elmJson) root let typesRootChanged = events & filter (\event -> stringContains "src/Types.elm" event @@ -145,21 +146,7 @@ runWithRoot root (Flags maybePort) = <|> Live.serveUnmatchedUrlsToIndex root (serveElm sentryCache) -- Everything else without extensions goes to Lamdera LocalDev harness <|> error404 -- Will get hit for any non-matching extensioned paths i.e. /hello.blah -useLocalDevOverrides :: Either a Elm.Outline.Outline -> Bool -useLocalDevOverrides elmJson = - case elmJson of - Right (Elm.Outline.App (Elm.Outline.AppOutline _ _ direct _ _ _)) -> - case Map.lookup Lamdera.Project.lamderaProgramTest direct of - Just (Elm.Version.Version major _ _) -> - if major >= 3 then - True - else - False - - Nothing -> - False - _ -> - False + -- Try narrow down source of exceptions with generalised error catch gcatchlog :: String -> Snap () -> Snap () From f9207583581e5ec01aa2d94595c3280269e6784d Mon Sep 17 00:00:00 2001 From: MartinSStewart Date: Thu, 8 May 2025 11:51:57 +0200 Subject: [PATCH 6/6] Fix version number --- extra/Lamdera/Injection.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extra/Lamdera/Injection.hs b/extra/Lamdera/Injection.hs index fb8dab6e..581ba3da 100644 --- a/extra/Lamdera/Injection.hs +++ b/extra/Lamdera/Injection.hs @@ -844,7 +844,7 @@ useProgramTestOverrides elmJson = Right (Elm.Outline.App (Elm.Outline.AppOutline _ _ direct _ _ _)) -> case Map.lookup Lamdera.Project.lamderaProgramTest direct of Just (Elm.Version.Version major _ _) -> - if major >= 3 then + if major >= 4 then True else False