11module GenieTest
22
33using Test
4- export App, wait_for, notify_test, @App
4+ export App, wait_for, notify_test, @App , connect!, redirect!, goto, is_reactive
55
66using Reexport
77@reexport using Stipple
@@ -58,7 +58,7 @@ function Base.notify(win::Window, field::Symbol, priorities = nothing)
5858 # no listener priorities available on the client side, so only evaluate priorities for level 0
5959 priorities isa Function && priorities (0 ) === false && return false
6060
61- run (app . __window__ , js """ window ? .GENIEMODEL ? .push (' $field' )" " " i)
61+ run (win , js """ window ? .GENIEMODEL ? .push (' $field' )" " " i)
6262end
6363
6464function Base.notify(win::Window, message::AbstractString, type::Union{Nothing, String, Symbol} = nothing; kwargs...)
@@ -71,12 +71,24 @@ Base.@kwdef mutable struct App
7171 __model__::Union{ReactiveModel, Nothing} = nothing
7272 __window__::Union{Window, Nothing} = nothing
7373 __priority__::Symbol = :model
74+ __id__::String = ""
7475 __url__::String = ""
7576 __electron_options__::Dict{String, Any} = Dict{String, Any}()
7677 __timeout__::Float64 = 30.0
7778 __port__::Union{Int, Nothing} = nothing
7879end
7980
81+ function remove_id(url::Union{String, URI})::URI
82+ uri = URI(url)
83+ query = replace(uri.query, r"debug_id=[^&/]+&?" => "")
84+ if isempty(query)
85+ # explicitly adding an empty query adds a ' ? ' at the end
86+ url = URI(chopsuffix(string(URI(uri; query = "")), ' ? ' ))
87+ else
88+ URI(uri; query)
89+ end
90+ end
91+
8092const AppDict = Dict{Any, App}
8193
8294function Base.getproperty(app::App, fieldname::Symbol)
@@ -98,10 +110,10 @@ function Base.getproperty(app::App, fieldname::Symbol)
98110 field = getfield(model, fieldname)
99111 field isa Reactive ? field[] : field
100112 end
101- elseif app.__window__ !== nothing
113+ elseif app.__window__ !== nothing && app.__window__.exists
102114 run(app.__window__, unproxy("window?.GENIEMODEL?.$fieldname"))
103115 else
104- @warn("App has neither model nor window")
116+ @warn("App has neither model nor active window")
105117 end
106118end
107119
@@ -185,6 +197,11 @@ function Base.notify(app::App, message::AbstractString, type::Union{Nothing, Str
185197 end
186198end
187199
200+ function add_id(url::Union{String, URI}, id::String)
201+ uri = URI(url)
202+ isempty(id) ? URI(uri) : URI(uri, query = join(filter(!isempty, ["debug_id=$id", uri.query]), ' & ' ))
203+ end
204+
188205"""
189206 App(url::String = "/";
190207 timeout::Float64 = 30,
@@ -217,44 +234,50 @@ Create a Stipple App with optional frontend and backend.
217234# Returns
218235An `App` instance containing the backend model and the frontend window.
219236"""
220- function App(url::String;
237+ function App(url::Union{ String, URI} ;
221238 timeout::Real = 30,
222239 port = nothing,
223240 id::String = string(uuid4()),
224- backend::Bool = !startswith(url, r"https://"i),
225- frontend::Symbol = startswith(url, r"https://"i) || !backend ? :electron : :browser,
241+ backend::Bool = !startswith(string( url) , r"https://"i),
242+ frontend::Symbol = startswith(string( url) , r"https://"i) || !backend ? :electron : :browser,
226243 isready::Function = app -> app.isready === true,
227244 electron_options::Dict{String, <:Any} = Dict{String, Any}(),
228- priority::Symbol = :model
245+ priority::Symbol = :model,
246+ window::Union{Window, Nothing} = nothing
229247)
230248 port === nothing && (port = Genie.config.server_port)
231249 println()
232250 @info "-------------- Starting App --------------"
233- startswith(url, r"https?://"i) || (url = "http://localhost:$port/" * strip(url, ' / ' ))
234- url = URI(url)
235- url = URI(url, query = join(filter(!isempty, ["debug_id=$id", url.query]), ' & ' ))
236- win = if frontend == :electron
251+ uri = URI(url)
252+ isempty(uri.scheme) && (uri = URI(uri; scheme = "http", host = "localhost", port, path = string(' / ' , strip(uri.path, ' / ' ))))
253+ final_uri = add_id(uri, id)
254+
255+ win = if window !== nothing && window.exists
256+ Electron.load(window, final_uri)
257+ window
258+ elseif frontend == :electron
237259 # default to sandbox mode
238260 electron_options = Dict{String, Any}(electron_options)
239261 wp = get!(electron_options, "webPreferences", Dict{String, Any}())
240262 electron_options["webPreferences"] = merge(Dict{String, Any}("sandbox" => true), wp)
241263
242- Window(url , options = electron_options)
264+ Window(final_uri , options = electron_options)
243265 elseif frontend == :browser
244- Genie.Server.openbrowser(url )
266+ Genie.Server.openbrowser(final_uri )
245267 nothing
246268 else
247- HTTP.get(url )
269+ HTTP.get(final_uri )
248270 nothing
249271 end
272+
250273 model = nothing
251274
252275 if backend
253276 model = Stipple.debug_model(id; timeout)
254277 frontend == :none && (model.isready[] = true)
255278 end
256279
257- app = App(model, win, priority, "$url ", electron_options, float(timeout), port)
280+ app = App(model, win, priority, id, "$uri ", electron_options, float(timeout), port)
258281 if model === nothing && win === nothing
259282 @warn("App has neither frontend nor backend")
260283 return app
@@ -288,16 +311,70 @@ end
288311
289312App(context::Module) = App(@eval context Stipple.@type)
290313
314+ """
315+ App(app::App, args...;
316+ timeout::Real = app.__timeout__,
317+ port = app.__port__,
318+ id::String = app.__id__,
319+ backend::Bool = app.__model__ !== nothing,
320+ frontend::Symbol = app.__window__ !== nothing ? :electron : :none,
321+ isready::Function = app -> app.isready === true,
322+ electron_options::Dict{String, <:Any} = app.__electron_options__,
323+ priority::Symbol = app.__priority__,
324+ window::Union{Window, Nothing} = app.__window__
325+ )
326+
327+ Reinitialize an existing app with new settings or URL.
328+
329+ This method allows you to update an existing `App` instance by creating a new app
330+ with different parameters and then transferring all fields to the original instance.
331+ All keyword arguments default to the current app' s settings.
332+
333+ # Arguments
334+ - ` app::App` : The existing app instance to reinitialize.
335+ - ` args...` : Additional positional arguments (typically a new URL ) passed to the main ` App` constructor .
336+
337+ # Keyword Arguments
338+ - `timeout::Real`: Timeout in seconds for app initialization.
339+ - `port`: Port where the Genie server is running.
340+ - `id::String`: Debug ID for the app.
341+ - `backend::Bool`: Whether the backend model should be active.
342+ - `frontend::Symbol`: Frontend type (` :electron` , ` :browser` , or ` :none` ).
343+ - `isready::Function`: Function to check if the backend is ready.
344+ - `electron_options::Dict{String , < : Any}` : Options for the Electron window.
345+ - ` priority:: Symbol ` : Priority for getting/setting properties (` : model` or ` : window ` ).
346+ - ` window :: Union{Window , Nothing}` : Existing window to reuse.
347+
348+ # Returns
349+ The modified ` App` instance.
350+ """
351+ function App(app::App, args...;
352+ timeout::Real = app.__timeout__,
353+ port = app.__port__,
354+ id::String = app.__id__,
355+ backend::Bool = app.__model__ !== nothing,
356+ frontend::Symbol = app.__window__ !== nothing ? :electron : :none, # use the existing window
357+ isready::Function = app -> app.isready === true,
358+ electron_options::Dict{String, <:Any} = app.__electron_options__,
359+ priority::Symbol = app.__priority__,
360+ window::Union{Window, Nothing} = app.__window__
361+ )
362+ a = App(args...; timeout, port, id, backend, frontend, isready, electron_options, priority, window)
363+ for field in fieldnames(App)
364+ setfield!(app, field, getfield(a, field))
365+ end
366+ end
367+
291368macro App()
292369 :(App(@__MODULE__))
293370end
294371
295372function Base.propertynames(app::App)
296373 if app.__model__ !== nothing
297374 tuple(propertynames(app.__model__)..., fieldnames(App)...)
298- elseif app.__window__ !== nothing
375+ elseif app.__window__ !== nothing && app.__window__.exists
299376 fnames = run(app.__window__, """
300- (x => Object.keys(x).filter(k => typeof x[k] !== ' function ' && k !== 'WebChannel'))(window.GENIEMODEL || {})
377+ (x => Object.keys(x).filter(k => typeof x[k] !== 'function' && k !== 'WebChannel'))(window? .GENIEMODEL || {})
301378 """)
302379 tuple(Symbol.(fnames)..., fieldnames(App)...)
303380 else
307384
308385function Base.run(app::App, msg::Union{AbstractString, JSONText}; timeout = 1, clone_result::Union{Bool, Nothing} = true)
309386 if app.__window__ !== nothing
387+ if ! app.__window__.exists
388+ @warn "Cannot run code on window, it probably has been closed."
389+ return nothing
390+ end
310391 msg isa JSONText && (msg = json(msg))
311392 if clone_result === nothing # automatically clone if necessary
312393 try
@@ -378,27 +459,151 @@ function notify_test(app::App, test::Test.Result, test_str::AbstractString = "Te
378459 end
379460end
380461
462+ function merge_uri(base_uri::URI, new_uri::URI)
463+ !isempty(new_uri.scheme) && return new_uri
464+
465+ base_kwargs = filter(!isempty ∘ last, Dict(k => getfield(base_uri, k) for k in fieldnames(URI) if k ∉ [:uri, :query, :fragment]))
466+ new_kwargs = filter(!isempty ∘ last, Dict(k => getfield(new_uri, k) for k in fieldnames(URI) if k !== :uri))
467+ new_path = if startswith(new_uri.path, '/') || base_uri.path == ""
468+ string('/', chopprefix(new_uri.path, "/"))
469+ else
470+ base_url = string('/', chopprefix(base_uri.path, "/"))
471+ relative_url = chopprefix(new_uri.path, "/")
472+ join(filter(!isempty, [base_url, relative_url]), '/')
473+ end
474+ new_kwargs[:path] = new_path
475+ merge!(base_kwargs, new_kwargs)
476+ URI(; base_kwargs...)
477+ end
478+
479+ """
480+ redirect!(app::App, url::Union{String, URI}; id = app.__id__)
481+
482+ Redirect the app to a new URL while preserving the app state and optionally changing the debug ID.
483+
484+ This function updates the app's internal URL state and performs a redirect in the frontend.
485+ If the URL is relative, it will be merged with the current base URL. The function will
486+ attempt to update the window location, or recreate the app connection if necessary.
487+
488+ # Arguments
489+ - ` app:: App` : The app instance to redirect.
490+ - ` url:: Union{String , URI }` : The new URL to navigate to. Can be relative or absolute.
491+
492+ # Keyword Arguments
493+ - ` id = app .__id__ ` : Debug ID for the new page. Defaults to the current app ID.
494+
495+ # Returns
496+ The modified ` App` instance.
497+ """
498+ function redirect!(app::App, url::Union{String, URI}; id = app.__id__)
499+ uri = merge_uri(URI(app.__url__), URI(url))
500+ if isempty(uri.scheme)
501+ uri = URI(app.__url__; path = join(uri.path))
502+ end
503+ app.__url__ = "$uri"
504+ app.__id__ = id
505+ final_uri = add_id(uri, id)
506+ if run(app, "window.location = '$final_uri'") === nothing
507+ App(app, final_uri)
508+ end
509+ return app
510+ end
511+
512+ """
513+ goto(app::App, url::Union{String, URI}; id = app.__id__)
514+
515+ Navigate the app to a new URL by directly setting the window location.
516+
517+ Unlike ` redirect! ` , this function does not update the app's internal state and performs
518+ a simple navigation by setting the window location. The URL is not merged with the base URL.
519+
520+ # Arguments
521+ - ` app:: App` : The app instance to navigate.
522+ - ` url:: Union{String , URI }` : The URL to navigate to.
523+
524+ # Keyword Arguments
525+ - ` id = app .__id__ ` : Debug ID to append to the URL. Defaults to the current app ID.
526+
527+ # Returns
528+ The result of the JavaScript execution (typically ` nothing` ).
529+ """
530+ function goto(app::App, url::Union{String, URI}; id = app.__id__)
531+ uri = add_id(url, id)
532+ run(app, "window.location = '$uri'")
533+ end
534+
535+ """
536+ is_reactive(app::App)
537+
538+ Check if the app's reactive model is ready and available.
539+
540+ This function queries the frontend to determine if the Genie reactive model is loaded
541+ and ready for interaction. It checks the ` window .GENIEMODEL .isready ` property.
542+
543+ # Arguments
544+ - ` app:: App` : The app instance to check.
545+
546+ # Returns
547+ ` true ` if the reactive model is ready, ` false ` otherwise (including if an error occurs).
548+ """
549+ function is_reactive(app::App)
550+ try
551+ run(app, "window?.GENIEMODEL?.isready || false")
552+ catch
553+ false
554+ end
555+ end
556+
557+ """
558+ connect!(app::App; timeout = nothing, port = nothing, isready::Function = app -> app.isready === true)
559+
560+ Ensure the app is connected and recreate the window if it has been closed.
561+
562+ This function checks if the app's window exists and attempts to recreate it if it has been
563+ closed. If the window is already available, it returns ` true ` immediately.
564+
565+ # Arguments
566+ - ` app:: App` : The app instance to connect or reconnect.
567+
568+ # Keyword Arguments
569+ - ` timeout = nothing` : Timeout in seconds for waiting for the app to be ready.
570+ If ` nothing` , uses the app's current timeout setting.
571+ - ` port = nothing` : Port where the Genie server is running. If ` nothing` , uses
572+ the app's current port setting.
573+ - ` isready:: Function = app - > app .isready === true ` : Function to check if the
574+ backend is ready.
575+
576+ # Returns
577+ ` true ` if the connection is successful or if the window already exists, ` false ` if
578+ recreation fails.
579+ """
381580function connect!(app::App; timeout = nothing, port = nothing, isready::Function = app -> app.isready === true)
382- if app.__window__ !== nothing && !app.__window__.exists
383- @info " App window appears to be closed . Recreating the window ... "
384- try
385- a = App(
386- app.__url__,
387- frontend = :electron,
388- electron_options = app.__electron_options__,
389- priority = app.__priority__,
390- backend = app.__model__ !== nothing,
391- timeout = timeout === nothing ? app.__timeout__ : timeout,
392- port = port === nothing ? app.__port__ : port,
393- )
394- app.__model__ = a.__model__
395- app.__window__ = a.__window__
396- app.__port__ = a.__port__
397- app.__timeout__ = a.__timeout__
581+ if app.__window__ !== nothing
582+ if !app.__window__.exists
583+ @info "App window appears to be closed. Recreating the window..."
584+ try
585+ a = App(
586+ app.__url__,
587+ id = app.__id__,
588+ frontend = :electron,
589+ electron_options = app.__electron_options__,
590+ priority = app.__priority__,
591+ backend = app.__model__ !== nothing,
592+ timeout = timeout === nothing ? app.__timeout__ : timeout,
593+ port = port === nothing ? app.__port__ : port,
594+ )
595+ app.__model__ = a.__model__
596+ app.__window__ = a.__window__
597+ app.__port__ = a.__port__
598+ app.__timeout__ = a.__timeout__
599+ true
600+ catch e
601+ @warn "Failed to recreate app window: $e"
602+ false
603+ end
604+ else
605+ # the window is available, but the content has been
398606 true
399- catch e
400- @warn " Failed to recreate app window : $e "
401- false
402607 end
403608 else
404609 true
0 commit comments