reload app_window on every code execution

This commit is contained in:
Dmitry Vasilev
2024-02-23 18:17:50 +08:00
parent cb115bf030
commit 8239e19c89
17 changed files with 437 additions and 350 deletions

View File

@@ -78,10 +78,6 @@ If a module path is a non-local path including a protocol and a host then it is
Now the module is imported as a black box - you cannot debug `BigNumber` methods.
Currently every external is loaded once and cached until Leporello is restarted
(TODO change path to modules every time it changed on disk, since modules are
served from service workers).
## IO
To enhance the interactive experience, Leporello.js traces the calls made to IO functions within your application. This trace can be replayed later, enabling you to program iteratively by making incremental changes to your code and promptly receiving feedback.
@@ -197,6 +193,41 @@ allows to share localStorage between host Leporello.js instance and window
where code is run)
-->
## Saving state between page reloads
Leporello.js allows preserving the state of the application between page reloads. To achieve this, Leporello.js provides a special API:
```javascript
window.leporello.storage.get(key: string)
window.leporello.storage.set(key: string, value: any)
```
Unlike localStorage and sessionStorage, these functions allow saving and retrieving non-serializable objects.
The storage can be cleared using the "(Re)open app window" button.
You can try the online demo [here](https://app.leporello.tech/?example=todos-preact). Create TODO items, then edit the code, and you will observe that your TODOs are preserved.
The code for interacting with the Leporello API is in the file `app.js`. When `app.js` module initializes, it checks whether Leporello.js API is present and loads app state:
```javascript
let state
if(globalThis.leporello) {
// Get initial state from Leporello storage
state = globalThis.leporello.storage.get('state')
}
```
Later, when state changes, it saves it back to the storage:
```javascript
// on state change
if(globalThis.leporello) {
// Save state to Leporello storage to load it after page reload
globalThis.leporello.storage.set('state', state)
}
```
## Run Leporello locally
To run it locally, you need to clone repo to local folder and serve it via HTTPS protocol (HTTPS is required by [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API)). See [How to use HTTPS for local development](https://web.dev/how-to-use-local-https/)

View File

@@ -2,8 +2,15 @@ import {render} from 'https://unpkg.com/preact?module';
let state, component, root
if(globalThis.leporello) {
// See https://github.com/leporello-js/leporello-js?tab=readme-ov-file#saving-state-between-page-reloads
// Get initial state from Leporello storage
state = globalThis.leporello.storage.get('state')
}
export const createApp = initial => {
/* if state is already initialized then preserve it */
/* if state was loaded from Leporello storage then keep it,
* otherwise initialize with initial state */
state = state ?? initial.initialState
component = initial.component
root = initial.root
@@ -12,6 +19,10 @@ export const createApp = initial => {
export const handler = fn => (...args) => {
state = fn(state, ...args)
if(globalThis.leporello) {
// Save state to Leporello storage to load it after page reload
globalThis.leporello.storage.set('state', state)
}
do_render()
}

View File

@@ -1,5 +1,5 @@
import {map_object, map_find, filter_object, collect_nodes_with_parents, uniq}
from './utils.js'
import {map_object, map_find, filter_object, collect_nodes_with_parents, uniq,
set_is_eq} from './utils.js'
import {
is_eq, is_child, ancestry, ancestry_inc, map_tree,
find_leaf, find_fn_by_location, find_node, find_error_origin_node,
@@ -69,14 +69,19 @@ const apply_eval_result = (state, eval_result) => {
}
}
const run_code = (s, dirty_files) => {
const run_code = (s, globals) => {
const is_globals_eq = s.globals == null
? globals == null
: set_is_eq(s.globals, globals)
const parse_result = load_modules(s.entrypoint, module => {
if(dirty_files != null && dirty_files.includes(module)) {
if(s.dirty_files != null && s.dirty_files.has(module)) {
return s.files[module]
}
if(s.parse_result != null) {
// If globals change, then errors for using undeclared identifiers may be
// no longer valid. Do not use cache
if(is_globals_eq) {
const result = s.parse_result.cache[module]
if(result != null) {
return result
@@ -87,10 +92,18 @@ const run_code = (s, dirty_files) => {
return s.files[module]
}
}, s.globals)
}, globals)
const dirty_files = new Set(
[...(s.dirty_files ?? new Set())].filter(file =>
parse_result.modules[file] == null
)
)
const state = {
...s,
dirty_files,
globals,
parse_result,
calltree: null,
modules: null,
@@ -121,15 +134,7 @@ const run_code = (s, dirty_files) => {
.map(i => i.node.full_import_path)
)
if(
external_imports.length != 0
&&
(
state.external_imports_cache == null
||
external_imports.some(i => state.external_imports_cache[i] == null)
)
) {
if(external_imports.length != 0) {
// Trigger loading of external modules
return {...state,
loading_external_imports_state: {
@@ -138,18 +143,8 @@ const run_code = (s, dirty_files) => {
}
} else {
// Modules were loaded and cached, proceed
return external_imports_loaded(
state,
state,
state.external_imports_cache == null
? null
: filter_object(
state.external_imports_cache,
(module_name, module) => external_imports.includes(module_name)
),
)
return external_imports_loaded(state, state)
}
}
const external_imports_loaded = (
@@ -168,7 +163,6 @@ const external_imports_loaded = (
const state = {
...s,
external_imports_cache: external_imports,
loading_external_imports_state: null
}
@@ -204,6 +198,7 @@ const external_imports_loaded = (
state.on_deferred_call,
state.calltree_changed_token,
state.io_trace,
state.storage,
)
if(result.then != null) {
@@ -252,10 +247,12 @@ const eval_modules_finished = (state, prev_state, result) => {
const input = (state, code, index) => {
const files = {...state.files, [state.current_module]: code}
const next = run_code(
set_cursor_position({...state, files}, index),
[state.current_module]
)
const with_files = {
...state,
files,
dirty_files: new Set([...(state.dirty_files ?? []), state.current_module])
}
const next = set_cursor_position(with_files, index)
const effect_save = {
type: 'write',
args: [
@@ -519,12 +516,10 @@ const change_current_module = (state, current_module) => {
}
const change_entrypoint = (state, entrypoint, current_module = entrypoint) => {
return run_code(
{...state,
entrypoint,
current_module,
}
)
return {...state,
entrypoint,
current_module,
}
}
const change_html_file = (state, html_file) => {
@@ -852,7 +847,7 @@ const on_deferred_call = (state, call, calltree_changed_token, logs) => {
}
const clear_io_trace = state => {
return run_code({...state, io_trace: null})
return {...state, io_trace: null}
}
const load_files = (state, dir) => {
@@ -892,7 +887,7 @@ const apply_entrypoint_settings = (state, entrypoint_settings) => {
const load_dir = (state, dir, has_file_system_access, entrypoint_settings) => {
// Clear parse cache and rerun code
const with_dir = load_files(state, dir)
return run_code({
return {
...(
entrypoint_settings == null
? with_dir
@@ -904,30 +899,15 @@ const load_dir = (state, dir, has_file_system_access, entrypoint_settings) => {
// remove cache. We have to clear cache because imports of modules that are
// not available because project_dir is not available have errors and the
// errors are cached
parse_result: null,
external_imports_cache: null,
})
parse_result: {...state.parse_result, cache: {}},
}
}
const create_file = (state, dir, current_module) => {
return {...load_dir(state, dir, true), current_module}
}
const open_app_window = (state, globals) => {
// After we reopen run window, we should reload external modules in the
// context of new window. Clear external_imports_cache
return run_code({
...state,
globals,
external_imports_cache: null,
// Bust parse result cache because list of globals may change
parse_result: null,
// Clear io trace because promises in io_trace become invalid after their
// window close
io_trace: null,
})
}
const open_app_window = state => ({...state, storage: new Map()})
const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => {
const with_files = state.project_dir == null
@@ -938,6 +918,7 @@ const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => {
return {
...with_settings,
storage: new Map(),
cursor_position_by_file: {[with_settings.current_module]: cursor_pos},
}
}
@@ -945,6 +926,7 @@ const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => {
export const COMMANDS = {
get_initial_state,
input,
run_code,
open_app_window,
load_dir,
create_file,

View File

@@ -1,4 +1,4 @@
import {exec, get_state} from '../index.js'
import {exec, get_state, exec_and_reload_app_window} from '../index.js'
import {ValueExplorer} from './value_explorer.js'
import {stringify_for_header} from '../value_explorer_utils.js'
import {el} from './domutils.js'
@@ -98,7 +98,7 @@ export class Editor {
normalize_events(this.ace_editor, {
on_change: () => {
try {
exec('input', this.ace_editor.getValue(), this.get_cursor_position())
exec_and_reload_app_window('input', this.ace_editor.getValue(), this.get_cursor_position())
} catch(e) {
// Do not throw Error to ACE because it breaks typing
console.error(e)

View File

@@ -6,7 +6,7 @@ import {
exec,
get_state,
open_directory,
reload_app_window,
exec_and_reload_app_window,
close_directory,
} from '../index.js'
@@ -20,16 +20,14 @@ export class Files {
this.render(get_state())
}
change_entrypoint(e) {
const file = e.target.value
exec('change_entrypoint', file)
change_entrypoint(entrypoint, current_module) {
exec_and_reload_app_window('change_entrypoint', entrypoint, current_module)
this.ui.editor.focus()
}
change_html_file(e) {
const html_file = e.target.value
exec('change_html_file', html_file)
reload_app_window(get_state())
exec_and_reload_app_window('change_html_file', html_file)
}
@@ -119,7 +117,7 @@ export class Files {
name: 'js_entrypoint',
value: file.path,
checked: state.entrypoint == file.path,
change: e => this.change_entrypoint(e),
change: e => this.change_entrypoint(e.target.value),
click: e => e.stopPropagation(),
})
)
@@ -210,9 +208,9 @@ export class Files {
// Reload all files for simplicity
open_dir(false).then(dir => {
if(is_dir) {
exec('load_dir', dir, true)
exec_and_reload_app_window('load_dir', dir, true)
} else {
exec('create_file', dir, path)
exec_and_reload_app_window('create_file', dir, path)
}
})
}
@@ -229,7 +227,7 @@ export class Files {
if(file.path == null) {
// root of examples dir, do nothing
} else if(file.path == '') {
exec('change_entrypoint', '')
this.change_entrypoint('')
} else {
const find_node = n =>
n.path == file.path
@@ -244,8 +242,7 @@ export class Files {
// in examples mode, on click file we also change entrypoint for
// simplicity
const example = examples.find(e => e.path == example_dir.path)
exec(
'change_entrypoint',
this.change_entrypoint(
example.entrypoint,
file.kind == 'directory' ? undefined : file.path
)

View File

@@ -1,4 +1,4 @@
import {exec, get_state, open_app_window} from '../index.js'
import {exec, get_state, open_app_window, exec_and_reload_app_window} from '../index.js'
import {Editor} from './editor.js'
import {Files} from './files.js'
import {CallTree} from './calltree.js'
@@ -73,7 +73,7 @@ export class UI {
el('a', {
'class': 'statusbar_action first',
href: 'javascript: void(0)',
click: () => exec('clear_io_trace')
click: () => this.clear_io_trace(),
},
'Clear IO trace (F6)'
),
@@ -196,6 +196,10 @@ export class UI {
}
}
clear_io_trace() {
exec_and_reload_app_window('clear_io_trace')
}
open_app_window() {
this.toggle_open_app_window_tooltip(false)
localStorage.onboarding_open_app_window = true

View File

@@ -421,6 +421,7 @@ export const eval_modules = (
on_deferred_call,
calltree_changed_token,
io_trace,
storage,
) => {
// TODO gensym __cxt, __trace, __trace_call, __calltree_node_by_loc,
// __await_start, __await_finish, __Multiversion, __create_array, __create_object
@@ -498,6 +499,8 @@ export const eval_modules = (
is_toplevel_call: true,
window: globalThis.app_window,
storage,
}
const result = run(module_fns, cxt, io_trace)

View File

@@ -80,6 +80,8 @@ const add_trivial_definition = node => {
* will be assigned by the time the closures would be called
*/
const DEFAULT_GLOBALS = new Set(['leporello']) // Leporello.js API
export const find_definitions = (ast, globals, scope = {}, closure_scope = {}, module_name) => {
// sanity check
@@ -94,7 +96,7 @@ export const find_definitions = (ast, globals, scope = {}, closure_scope = {}, m
} else {
const definition = scope[ast.value]
if(definition == null){
if(globals.has(ast.value)) {
if(globals.has(ast.value) || DEFAULT_GLOBALS.has(ast.value)) {
return {node: {...ast, definition: 'global'}, undeclared: null, closed: new Set()}
} else {
return {node: ast, undeclared: [ast], closed: new Set()}

View File

@@ -52,7 +52,7 @@ const get_html_url = state => {
const on_window_load = w => {
init_window_service_worker(w)
exec(
'open_app_window',
'run_code',
new Set(Object.getOwnPropertyNames(w))
)
}
@@ -78,6 +78,7 @@ const open_run_iframe = (state) => {
export const open_app_window = state => {
// TODO set_error_handler? Or we dont need to set_error_handler for child
// window because error is always caught by parent window handler?
exec('open_app_window')
globalThis.app_window.close()
globalThis.app_window = open(get_html_url(state))
init_app_window(globalThis.app_window)
@@ -139,10 +140,8 @@ const init_app_window = w => {
add_load_handler()
}
export const reload_app_window = state => {
// TODO after window location reload, open_app_window command will be fired.
// Maybe we should have separate commands for open_app_window and
// reload_app_window?
const reload_app_window = state => {
// after window location reload, `run_code` command will be fired.
globalThis.app_window.location = get_html_url(state)
}
@@ -154,19 +153,23 @@ const get_entrypoint_settings = () => {
}
}
export const exec_and_reload_app_window = (...exec_args) => {
exec(...exec_args)
reload_app_window(get_state())
}
export const open_directory = () => {
if(globalThis.showDirectoryPicker == null) {
throw new Error('Your browser is not supporting File System Access API')
}
open_dir(true).then(dir => {
exec('load_dir', dir, true, get_entrypoint_settings())
exec_and_reload_app_window('load_dir', dir, true, get_entrypoint_settings())
})
}
export const close_directory = async () => {
close_dir()
exec('load_dir', await examples_dir_promise, false, get_entrypoint_settings())
exec_and_reload_app_window('load_dir', await examples_dir_promise, false, get_entrypoint_settings())
}

View File

@@ -76,18 +76,25 @@ const make_patched_method = (window, original, name, use_context) => {
: original.apply(this, args)
if(value?.[Symbol.toStringTag] == 'Promise') {
// TODO use __original_then, not finally which calls
// patched 'then'?
value = value.finally(() => {
if(cxt_copy != cxt) {
return
}
if(cxt.io_trace_is_replay_aborted) {
// Non necessary
return
}
cxt.io_trace.push({type: 'resolution', index})
})
value = value
.then(val => {
value.status = {ok: true, value: val}
return val
})
.catch(error => {
value.status = {ok: true, error}
throw error
})
.finally(() => {
if(cxt_copy != cxt) {
return
}
if(cxt.io_trace_is_replay_aborted) {
// Non necessary
return
}
cxt.io_trace.push({type: 'resolution', index})
})
}
ok = true
@@ -150,10 +157,11 @@ const make_patched_method = (window, original, name, use_context) => {
)
if(next_resolution != null && !cxt.io_trace_resolver_is_set) {
const original_setTimeout = cxt.window.setTimeout.__original
cxt.io_trace_resolver_is_set = true
original_setTimeout(() => {
// use setTimeout function from host window (because this module was
// loaded as `external` by host window)
setTimeout(() => {
if(cxt_copy != cxt) {
return
}
@@ -180,14 +188,22 @@ const make_patched_method = (window, original, name, use_context) => {
cxt.io_trace[cxt.io_trace_index].type == 'resolution'
) {
const resolution = cxt.io_trace[cxt.io_trace_index]
const resolver = cxt.io_trace_resolvers.get(resolution.index)
const {resolve, reject} = cxt.io_trace_resolvers.get(resolution.index)
cxt.io_trace_index++
if(cxt.io_trace[resolution.index].name == 'setTimeout') {
resolver()
resolve()
} else {
resolver(cxt.io_trace[resolution.index].value)
const promise = cxt.io_trace[resolution.index].value
if(promise.status == null) {
throw new Error('illegal state')
}
if(promise.status.ok) {
resolve(promise.status.value)
} else {
reject(promise.status.error)
}
}
}
}
@@ -203,12 +219,12 @@ const make_patched_method = (window, original, name, use_context) => {
// trace) and instanceof would not work
if(call.value?.[Symbol.toStringTag] == 'Promise') {
// Always make promise originate from app_window
return new cxt.window.Promise(resolve => {
cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, resolve)
return new cxt.window.Promise((resolve, reject) => {
cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, {resolve, reject})
})
} else if(name == 'setTimeout') {
const timeout_cb = args[0]
cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, timeout_cb)
cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, {resolve: timeout_cb})
return call.value
} else {
return call.value

View File

@@ -19,9 +19,8 @@ const gen_to_promise = gen_fn => {
if(result.done){
return result.value
} else {
// If promise
if(result.value?.then != null) {
return result.value.__original_then(
if(result.value?.[Symbol.toStringTag] == 'Promise') {
return result.value.then(
value => next(gen.next(value)),
error => next(gen.throw(error)),
)
@@ -36,7 +35,7 @@ const gen_to_promise = gen_fn => {
const make_promise_with_rejector = cxt => {
let rejector
const p = new cxt.window.Promise(r => rejector = r)
const p = new Promise(r => rejector = r)
return [p, rejector]
}
@@ -107,8 +106,8 @@ const do_run = function*(module_fns, cxt, io_trace){
create_array,
create_object,
)
if(result instanceof cxt.window.Promise) {
yield cxt.window.Promise.race([replay_aborted_promise, result])
if(result?.[Symbol.toStringTag] == 'Promise') {
yield Promise.race([replay_aborted_promise, result])
} else {
yield result
}
@@ -144,7 +143,10 @@ export const run = gen_to_promise(function*(module_fns, cxt, io_trace) {
if(!cxt.window.__is_initialized) {
defineMultiversion(cxt.window)
apply_io_patches(cxt.window)
inject_leporello_api(cxt)
cxt.window.__is_initialized = true
} else {
throw new Error('illegal state')
}
const result = yield* do_run(module_fns, cxt, io_trace)
@@ -154,14 +156,21 @@ export const run = gen_to_promise(function*(module_fns, cxt, io_trace) {
result.rt_cxt.is_recording_deferred_calls = false
// run again without io trace
// TODO reload app_window before second run
return yield* do_run(module_fns, cxt, null)
} else {
return result
}
})
const inject_leporello_api = cxt => {
cxt.window.leporello = { storage: cxt.storage }
}
const apply_promise_patch = cxt => {
if(cxt.window.Promise.prototype.__original_then != null) {
throw new Error('illegal state')
}
const original_then = cxt.window.Promise.prototype.then
cxt.window.Promise.prototype.__original_then = cxt.window.Promise.prototype.then
@@ -197,6 +206,7 @@ const apply_promise_patch = cxt => {
const remove_promise_patch = cxt => {
cxt.window.Promise.prototype.then = cxt.window.Promise.prototype.__original_then
delete cxt.window.Promise.prototype.__original_then
}
export const set_record_call = cxt => {
@@ -339,7 +349,7 @@ const __trace = (cxt, fn, name, argscount, __location, get_closure, has_versione
try {
value = fn(...args)
ok = true
if(value instanceof cxt.window.Promise) {
if(value?.[Symbol.toStringTag] == 'Promise') {
set_record_call(cxt)
}
return value
@@ -485,7 +495,7 @@ const __trace_call = (cxt, fn, context, args, errormessage, is_new = false) => {
value = undefined
}
ok = true
if(value instanceof cxt.window.Promise) {
if(value?.[Symbol.toStringTag] == 'Promise') {
set_record_call(cxt)
}

View File

@@ -10,6 +10,9 @@ export const set_push = (x,y) => new Set([...x, y])
export const set_union = (x,y) => new Set([...x, ...y])
export const set_is_eq = (a, b) =>
a.size === b.size && [...a].every(value => b.has(value))
export const set_diff = (x,y) => {
return new Set([...x].filter(el => !y.has(el)))
}

View File

@@ -11,8 +11,7 @@ const isError = object =>
||
object instanceof globalThis.app_window.Error
const isPromise = object =>
object instanceof globalThis.app_window.Promise
const isPromise = object => object?.[Symbol.toStringTag] == 'Promise'
// Override behaviour for Date, becase Date has toJSON defined
const isDate = object =>

View File

@@ -1,6 +1,6 @@
import {tests} from './test.js'
// external
import {run} from './run_utils.js'
import {run} from './utils.js'
await run(tests)

View File

@@ -8,61 +8,47 @@
*/
globalThis.Response
export const run = tests => {
// Runs test, return failure or null if not failed
const run_test = t => {
return Promise.resolve().then(t.test)
.then(() => null)
.catch(e => {
if(globalThis.process != null) {
// In node.js runner, fail fast
console.error('Failed: ' + t.message)
throw e
} else {
return e
}
})
}
if(globalThis.process != null) {
globalThis.NodeVM = await import('node:vm')
}
// If not run in node, then dont apply filter
const filter = globalThis.process && globalThis.process.argv[2]
let iframe
if(filter == null) {
export function create_app_window() {
if(globalThis.process != null) {
// We are in node.js
// `NodeVM` was preloaded earlier
const only = tests.find(t => t.only)
const tests_to_run = only == null ? tests : [only]
const context = globalThis.NodeVM.createContext({
// Exec each test. After all tests are done, we rethrow first error if
// any. So we will mark root calltree node if one of tests failed
return tests_to_run.reduce(
(failureP, t) =>
Promise.resolve(failureP).then(failure =>
run_test(t).then(next_failure => failure ?? next_failure)
)
,
null
).then(failure => {
process,
if(failure != null) {
throw failure
} else {
if(globalThis.process != null) {
console.log('Ok')
}
}
// for some reason URL is not available inside VM
URL,
console,
setTimeout,
// break fetch because we dont want it to be accidentally called in unit test
fetch: () => {
console.error('Error! fetch called')
},
})
const get_global_object = globalThis.NodeVM.compileFunction(
'return this',
[], // args
{parsingContext: context}
)
return get_global_object()
} else {
const test = tests.find(t => t.message.includes(filter))
if(test == null) {
throw new Error('test not found')
} else {
return run_test(test).then(() => {
if(globalThis.process != null) {
console.log('Ok')
}
})
// We are in browser
if(iframe != null) {
globalThis.document.body.removeChild(iframe)
}
iframe = globalThis.document.createElement('iframe')
document.body.appendChild(iframe)
return iframe.contentWindow
}
}

View File

@@ -23,12 +23,12 @@ import {
assert_code_error, assert_code_error_async,
assert_versioned_value, assert_value_explorer, assert_selection,
parse_modules,
run_code,
test_initial_state, test_initial_state_async,
test_deferred_calls_state,
print_debug_ct_node,
command_input_async,
patch_builtin,
original_setTimeout,
input,
input_async,
} from './utils.js'
export const tests = [
@@ -1012,7 +1012,8 @@ export const tests = [
const spoil_file = {...s, files: {...s.files, 'c': ',,,'}}
// change module ''
const {state: s2} = COMMANDS.input(spoil_file, 'import {c} from "c"', 0)
const {state: s2} = input(spoil_file, 'import {c} from "c"', 0)
assert_equal(s2.dirty_files, new Set())
assert_equal(s2.parse_result.ok, true)
}),
@@ -1110,7 +1111,7 @@ export const tests = [
const index = edited.indexOf('foo_var')
const {state, effects} = COMMANDS.input(
const {state, effects} = input(
initial,
edited,
index
@@ -1164,39 +1165,6 @@ export const tests = [
)
}),
test('module external cache', () => {
const code = `
// external
import {foo_var} from 'foo.js'
console.log(foo_var)
`
const initial = test_initial_state(code)
const next = COMMANDS.external_imports_loaded(initial, initial, {
'foo.js': {
ok: true,
module: {
'foo_var': 'foo_value'
},
}
})
const edited = `
// external
import {foo_var} from 'foo.js'
foo_var
`
const {state} = COMMANDS.input(
next,
edited,
edited.lastIndexOf('foo_var'),
)
// If cache was not used then effects will be `load_external_imports`
assert_equal(state.value_explorer.result.value, 'foo_value')
}),
test('module external cache error bug', () => {
const code = `
// external
@@ -1221,7 +1189,7 @@ export const tests = [
`
// edit code
const {state} = COMMANDS.input(
const {state} = input(
next,
edited,
edited.lastIndexOf('foo_var'),
@@ -1259,7 +1227,7 @@ export const tests = [
const edited = ``
// edit code
const {state, effects} = COMMANDS.input(
const {state, effects} = input(
next,
edited,
0,
@@ -2000,7 +1968,7 @@ const y = x()`
x(2);
`
const s3 = COMMANDS.input(s2, invalid, invalid.indexOf('return')).state
const s3 = input(s2, invalid, invalid.indexOf('return')).state
const edited = `
const x = foo => {
@@ -2010,7 +1978,7 @@ const y = x()`
x(2);
`
const n = COMMANDS.input(s3, edited, edited.indexOf('return')).state
const n = input(s3, edited, edited.indexOf('return')).state
const res = find_leaf(active_frame(n), edited.indexOf('*'))
@@ -2044,7 +2012,7 @@ const y = x()`
[1,2,3].map(x)
`
const e = COMMANDS.input(s4, edited, edited.indexOf('2')).state
const e = input(s4, edited, edited.indexOf('2')).state
const active = active_frame(e)
@@ -2070,7 +2038,7 @@ const y = x()`
}
`
const {state: s2} = COMMANDS.input(s1, edited, edited.indexOf('1'))
const {state: s2} = input(s1, edited, edited.indexOf('1'))
const s3 = COMMANDS.move_cursor(s2, edited.indexOf('import'))
assert_equal(s3.value_explorer.result.value.x, 1)
}),
@@ -2098,7 +2066,7 @@ const y = x()`
x()
`
const e = COMMANDS.input(s3, edited, edited.indexOf('123')).state
const e = input(s3, edited, edited.indexOf('123')).state
assert_equal(e.active_calltree_node.toplevel, true)
}),
@@ -2111,7 +2079,7 @@ const y = x()`
}),
'x'
)
const e = COMMANDS.input(s1, 'export const x = 2', 0).state
const e = input(s1, 'export const x = 2', 0).state
assert_equal(e.current_calltree_node.module, '')
assert_equal(e.active_calltree_node, null)
}),
@@ -2143,7 +2111,7 @@ const y = x()`
`
const moved = COMMANDS.move_cursor(s3, code.indexOf('2'))
const e = COMMANDS.input(moved, edited, edited.indexOf('3')).state
const e = input(moved, edited, edited.indexOf('3')).state
assert_equal(e.active_calltree_node, null)
assert_equal(e.current_calltree_node.toplevel, true)
}),
@@ -2156,7 +2124,7 @@ const y = x()`
x()
`
const i = test_initial_state(code)
const edited = COMMANDS.input(i, code.replace('1', '100'), code.indexOf('1')).state
const edited = input(i, code.replace('1', '100'), code.indexOf('1')).state
const left = COMMANDS.calltree.arrow_left(edited)
assert_equal(left.active_calltree_node.toplevel, true)
}),
@@ -3550,12 +3518,12 @@ const y = x()`
`
const {state: i, on_deferred_call} = test_deferred_calls_state(code)
const input = COMMANDS.input(i, code, 0).state
const state = input(i, code, 0).state
// Make deferred call, calling fn from previous code
i.modules[''].fn(1)
const result = on_deferred_call(input)
const result = on_deferred_call(state)
// deferred calls must be null, because deferred calls from previous executions
// must be discarded
@@ -3911,7 +3879,7 @@ const y = x()`
}
await f()
`
const next = await command_input_async(i, code2, code2.indexOf('1'))
const next = await input_async(i, code2, code2.indexOf('1'))
assert_equal(next.active_calltree_node.fn.name, 'f')
assert_equal(next.value_explorer.result.value, 1)
}),
@@ -3955,22 +3923,21 @@ const y = x()`
`
const {state: i, on_deferred_call} = test_deferred_calls_state(code)
await i.eval_modules_state.promise.__original_then(result => {
const s = COMMANDS.eval_modules_finished(
i,
i,
result,
i.eval_modules_state.node,
i.eval_modules_state.toplevel
)
const result = await i.eval_modules_state.promise
// Make deferred call
s.modules[''].fn()
const state = on_deferred_call(s)
assert_equal(get_deferred_calls(state).length, 1)
assert_equal(get_deferred_calls(state)[0].value, 1)
})
const s = COMMANDS.eval_modules_finished(
i,
i,
result,
i.eval_modules_state.node,
i.eval_modules_state.toplevel
)
// Make deferred call
s.modules[''].fn()
const state = on_deferred_call(s)
assert_equal(get_deferred_calls(state).length, 1)
assert_equal(get_deferred_calls(state)[0].value, 1)
}),
test('async/await await argument bug', async () => {
@@ -3991,101 +3958,94 @@ const y = x()`
}),
test('record io', () => {
// Patch Math.random to always return 1
patch_builtin('random', () => 1)
let app_window_patches
const initial = test_initial_state(`
const x = Math.random()
`)
// Patch Math.random to always return 1
app_window_patches = {'Math.random': () => 1}
const initial = test_initial_state(`const x = Math.random()`, undefined, {
app_window_patches
})
// Now call to Math.random is cached, break it to ensure it was not called
// on next run
patch_builtin('random', () => { throw 'fail' })
app_window_patches = {'Math.random': () => { throw 'fail' }}
const next = COMMANDS.input(initial, `const x = Math.random()*2`, 0).state
const next = input(initial, `const x = Math.random()*2`, 0, {app_window_patches}).state
assert_equal(next.value_explorer.result.value, 2)
assert_equal(next.rt_cxt.io_trace_index, 1)
// Patch Math.random to return 2.
// TODO The first call to Math.random() is cached with value 1, and the
// second shoud return 2
patch_builtin('random', () => 2)
const replay_failed = COMMANDS.input(
app_window_patches = {'Math.random': () => 2}
const replay_failed = input(
initial,
`const x = Math.random() + Math.random()`,
0
0,
{app_window_patches}
).state
// TODO must reuse first cached call?
assert_equal(replay_failed.value_explorer.result.value, 4)
// Remove patch
patch_builtin('random', null)
}),
test('record io trace discarded if args does not match', async () => {
// Patch fetch
patch_builtin('fetch', async () => 'first')
let app_window_patches
app_window_patches = {fetch: async () => 'first'}
const initial = await test_initial_state_async(`
console.log(await fetch('url', {method: 'GET'}))
`)
`, undefined, {app_window_patches})
assert_equal(initial.logs.logs[0].args[0], 'first')
// Patch fetch again
patch_builtin('fetch', async () => 'second')
app_window_patches = {fetch: async () => 'second'}
const cache_discarded = await command_input_async(initial, `
const cache_discarded = await input_async(initial, `
console.log(await fetch('url', {method: 'POST'}))
`, 0)
`, 0, {app_window_patches})
assert_equal(cache_discarded.logs.logs[0].args[0], 'second')
// Remove patch
patch_builtin('fetch', null)
}),
test('record io fetch rejects', async () => {
// Patch fetch
patch_builtin('fetch', () => Promise.reject('fail'))
let app_window_patches
app_window_patches = {fetch: () => globalThis.app_window.Promise.reject('fail')}
const initial = await test_initial_state_async(`
await fetch('url', {method: 'GET'})
`)
`, undefined, {app_window_patches})
assert_equal(root_calltree_node(initial).error, 'fail')
// Patch fetch again
patch_builtin('fetch', () => async () => 'result')
app_window_patches = {fetch: () => async () => 'result'}
const with_cache = await command_input_async(initial, `
const with_cache = await input_async(initial, `
await fetch('url', {method: 'GET'})
`, 0)
`, 0, {app_window_patches})
assert_equal(root_calltree_node(initial).error, 'fail')
// Remove patch
patch_builtin('fetch', null)
}),
test('record io preserve promise resolution order', async () => {
// Generate fetch function which calls get resolved in reverse order
const {fetch, resolve} = new Function(`
const calls = []
return {
fetch(...args) {
let resolver
const promise = new Promise(r => resolver = r)
calls.push({resolver, promise, args})
return promise
},
const calls = []
function fetch(...args) {
let resolver
const promise = new (globalThis.app_window.Promise)(r => {resolver = r})
calls.push({resolver, promise, args})
return promise
}
resolve() {
[...calls].reverse().forEach(call => call.resolver(...call.args))
},
}
`)()
function resolve() {
[...calls].reverse().forEach(call => call.resolver(...call.args))
}
let app_window_patches
// Patch fetch
patch_builtin('fetch', fetch)
app_window_patches = {fetch}
const code = `
await Promise.all(
@@ -4096,7 +4056,7 @@ const y = x()`
)
`
const initial_promise = test_initial_state_async(code)
const initial_promise = test_initial_state_async(code, undefined, {app_window_patches})
resolve()
@@ -4106,56 +4066,54 @@ const y = x()`
assert_equal(initial.logs.logs.map(l => l.args[0]), [3,2,1])
// Break fetch to ensure it is not get called anymore
patch_builtin('fetch', () => {throw 'broken'})
app_window_patches = {fetch: () => {throw 'broken'}}
const with_cache = await command_input_async(
const with_cache = await input_async(
initial,
code,
0
0,
{app_window_patches}
)
// cached calls to fetch should be resolved in the same (reverse) order as
// on the first run, so first call wins
assert_equal(with_cache.logs.logs.map(l => l.args[0]), [3,2,1])
// Remove patch
patch_builtin('fetch', null)
}),
test('record io setTimeout', async () => {
let app_window_patches
// Patch fetch to return result in 10ms
patch_builtin(
'fetch',
() => new Promise(resolve => original_setTimeout(resolve, 10))
)
app_window_patches = {
fetch: () => new (globalThis.app_window.Promise)(resolve => setTimeout(resolve, 10))
}
const code = `
setTimeout(() => console.log('timeout'), 0)
await fetch().then(() => console.log('fetch'))
`
const i = await test_initial_state_async(code)
const i = await test_initial_state_async(code, undefined, {app_window_patches})
// First executed setTimeout, then fetch
assert_equal(i.logs.logs.map(l => l.args[0]), ['timeout', 'fetch'])
// Break fetch to ensure it would not be called
patch_builtin('fetch', async () => {throw 'break'})
app_window_patches = {
fetch: async () => {throw 'break'}
}
const with_cache = await command_input_async(i, code, 0)
const with_cache = await input_async(i, code, 0, {app_window_patches})
// Cache must preserve resolution order
assert_equal(with_cache.logs.logs.map(l => l.args[0]), ['timeout', 'fetch'])
patch_builtin('fetch', null)
}),
test('record io clear io trace', async () => {
const s1 = test_initial_state(`Math.random()`)
const rnd = s1.value_explorer.result.value
const s2 = COMMANDS.input(s1, `Math.random() + 1`, 0).state
const s2 = input(s1, `Math.random() + 1`, 0).state
assert_equal(s2.value_explorer.result.value, rnd + 1)
const cleared = COMMANDS.clear_io_trace(s2)
const cleared = input(COMMANDS.clear_io_trace(s2), `Math.random() + 1`).state
assert_equal(
cleared.value_explorer.result.value == rnd + 1,
false
@@ -4185,8 +4143,8 @@ const y = x()`
const rnd = i.active_calltree_node.children[0].value
// Run two versions of code in parallel
const next = COMMANDS.input(i, `await Promise.resolve()`, 0)
const next2 = COMMANDS.input(i, `Math.random(1)`, 0).state
const next = input(i, `await Promise.resolve()`, 0)
const next2 = input(i, `Math.random(1)`, 0).state
const next_rnd = i.active_calltree_node.children[0].value
assert_equal(rnd, next_rnd)
}),
@@ -4211,10 +4169,10 @@ const y = x()`
}),
test('record io hangs bug', async () => {
patch_builtin(
'fetch',
() => new Promise(resolve => original_setTimeout(resolve, 0))
)
let app_window_patches
app_window_patches = {
fetch: () => new (globalThis.app_window.Promise)(resolve => setTimeout(resolve, 0))
}
const code = `
const p = fetch('')
@@ -4222,22 +4180,20 @@ const y = x()`
await p
`
const i = await test_initial_state_async(code)
const i = await test_initial_state_async(code, undefined, {app_window_patches})
assert_equal(i.io_trace.length, 3)
const next_code = `await fetch('')`
const state = await command_input_async(i, next_code, 0)
const state = await input_async(i, next_code, 0, {app_window_patches})
assert_equal(state.io_trace.length, 2)
patch_builtin('fetch', null)
}),
test('record io logs recorded twice bug', () => {
const code = `Math.random()`
const i = test_initial_state(code)
const second = COMMANDS.input(
const second = input(
i,
`console.log(1); Math.random(); Math.random()`,
0
@@ -5401,4 +5357,19 @@ const y = x()`
assert_value_explorer(i, expected)
}),
test('leporello storage API', () => {
const i = test_initial_state(`
const value = leporello.storage.get('value')
if(value == null) {
leporello.storage.set('value', 1)
}
`)
const with_storage = input(i, 'leporello.storage.get("value")', 0).state
assert_value_explorer(with_storage, 1)
const with_cleared_storage = run_code(
COMMANDS.open_app_window(with_storage)
)
assert_value_explorer(with_cleared_storage, undefined)
}),
]

View File

@@ -3,60 +3,22 @@ import {parse, print_debug_node, load_modules} from '../src/parse_js.js'
import {active_frame, pp_calltree, version_number_symbol} from '../src/calltree.js'
import {COMMANDS} from '../src/cmd.js'
// external
import {create_app_window} from './run_utils.js'
// external
import {with_version_number} from '../src/runtime/runtime.js'
Object.assign(globalThis,
{
// for convenince, to type just `log` instead of `console.log`
// for convenience, to type just `log` instead of `console.log`
log: console.log,
}
)
export const patch_builtin = new Function(`
if(globalThis.process != null ) {
globalThis.app_window = globalThis
} else {
const iframe = globalThis.document.createElement('iframe')
globalThis.document.body.appendChild(iframe)
globalThis.app_window = iframe.contentWindow
}
let originals = globalThis.app_window.__builtins_originals
let patched = globalThis.app_window.__builtins_patched
if(originals == null) {
globalThis.app_window.__original_setTimeout = globalThis.setTimeout
// This code can execute twice when tests are run in self-hosted mode.
// Ensure that patches will be applied only once
originals = globalThis.app_window.__builtins_originals = {}
patched = globalThis.app_window.__builtins_patched = {}
const patch = (obj, name) => {
originals[name] = obj[name]
obj[name] = (...args) => {
return patched[name] == null
? originals[name].apply(null, args)
: patched[name].apply(null, args)
}
}
// Substitute some builtin functions: fetch, setTimeout, Math.random to be
// able to patch them in tests
patch(globalThis.app_window, 'fetch')
patch(globalThis.app_window, 'setTimeout')
patch(globalThis.app_window.Math, 'random')
}
return (name, fn) => {
patched[name] = fn
}
`)()
export const original_setTimeout = globalThis.app_window.__original_setTimeout
export const do_parse = code => parse(
code,
new Set(Object.getOwnPropertyNames(globalThis.app_window))
new Set(Object.getOwnPropertyNames(globalThis))
)
export const parse_modules = (entry, modules) =>
@@ -102,18 +64,48 @@ export const assert_code_error_async = async (codestring, error) => {
assert_equal(result.error, error)
}
function patch_app_window(app_window_patches) {
Object.entries(app_window_patches).forEach(([path, value]) => {
let obj = globalThis.app_window
const path_arr = path.split('.')
path_arr.forEach((el, i) => {
if(i == path_arr.length - 1) {
return
}
obj = obj[el]
})
const prop = path_arr.at(-1)
obj[prop] = value
})
}
export const run_code = (state, app_window_patches) => {
globalThis.app_window = create_app_window()
if(app_window_patches != null) {
patch_app_window(app_window_patches)
}
return COMMANDS.run_code(
state,
state.globals ?? new Set(Object.getOwnPropertyNames(globalThis.app_window))
)
}
export const test_initial_state = (code, cursor_pos, options = {}) => {
if(cursor_pos < 0) {
throw new Error('illegal cursor_pos')
}
if(typeof(options) != 'object') {
throw new Error('illegal state')
}
const {
//entrypoint = '',
current_module,
project_dir,
on_deferred_call,
app_window_patches,
} = options
const entrypoint = options.entrypoint ?? ''
return COMMANDS.open_app_window(
return run_code(
COMMANDS.get_initial_state(
{
files: typeof(code) == 'object' ? code : { '' : code},
@@ -126,7 +118,7 @@ export const test_initial_state = (code, cursor_pos, options = {}) => {
},
cursor_pos
),
new Set(Object.getOwnPropertyNames(globalThis.app_window))
app_window_patches,
)
}
@@ -141,8 +133,19 @@ export const test_initial_state_async = async (code, ...args) => {
)
}
export const command_input_async = async (...args) => {
const after_input = COMMANDS.input(...args).state
export const input = (s, code, index, options = {}) => {
if(typeof(options) != 'object') {
throw new Error('illegal state')
}
const {state, effects} = COMMANDS.input(s, code, index)
return {
state: run_code(state, options.app_window_patches),
effects,
}
}
export const input_async = async (...args) => {
const after_input = input(...args).state
const result = await after_input.eval_modules_state.promise
return COMMANDS.eval_modules_finished(
after_input,
@@ -239,3 +242,69 @@ export const test = (message, test, only = false) => {
}
export const test_only = (message, t) => test(message, t, true)
// Create `run` function like this because we do not want its internals to be
// present in calltree
export const run = new Function('create_app_window', `
return function run(tests) {
// create app window for simple tests, that do not use 'test_initial_state'
globalThis.app_window = create_app_window()
// Runs test, return failure or null if not failed
const run_test = t => {
return Promise.resolve().then(t.test)
.then(() => null)
.catch(e => {
if(globalThis.process != null) {
// In node.js runner, fail fast
console.error('Failed: ' + t.message)
throw e
} else {
return e
}
})
}
// If not run in node, then dont apply filter
const filter = globalThis.process && globalThis.process.argv[2]
if(filter == null) {
const only = tests.find(t => t.only)
const tests_to_run = only == null ? tests : [only]
// Exec each test. After all tests are done, we rethrow first error if
// any. So we will mark root calltree node if one of tests failed
return tests_to_run.reduce(
(failureP, t) =>
Promise.resolve(failureP).then(failure =>
run_test(t).then(next_failure => failure ?? next_failure)
)
,
null
).then(failure => {
if(failure != null) {
throw failure
} else {
if(globalThis.process != null) {
console.log('Ok')
}
}
})
} else {
const test = tests.find(t => t.message.includes(filter))
if(test == null) {
throw new Error('test not found')
} else {
return run_test(test).then(() => {
if(globalThis.process != null) {
console.log('Ok')
}
})
}
}
}
`)(create_app_window)