mirror of
https://github.com/leporello-js/leporello-js
synced 2026-01-13 13:04:30 -08:00
reload app_window on every code execution
This commit is contained in:
39
README.md
39
README.md
@@ -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/)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
90
src/cmd.js
90
src/cmd.js
@@ -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,
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}
|
||||
|
||||
17
src/index.js
17
src/index.js
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -76,9 +76,16 @@ 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(() => {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {tests} from './test.js'
|
||||
|
||||
// external
|
||||
import {run} from './run_utils.js'
|
||||
import {run} from './utils.js'
|
||||
|
||||
await run(tests)
|
||||
|
||||
@@ -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
|
||||
globalThis.NodeVM = await import('node:vm')
|
||||
}
|
||||
|
||||
let iframe
|
||||
|
||||
export function create_app_window() {
|
||||
if(globalThis.process != null) {
|
||||
// We are in node.js
|
||||
// `NodeVM` was preloaded earlier
|
||||
|
||||
const context = globalThis.NodeVM.createContext({
|
||||
|
||||
process,
|
||||
|
||||
// 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')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
const get_global_object = globalThis.NodeVM.compileFunction(
|
||||
'return this',
|
||||
[], // args
|
||||
{parsingContext: context}
|
||||
)
|
||||
,
|
||||
null
|
||||
).then(failure => {
|
||||
|
||||
if(failure != null) {
|
||||
throw failure
|
||||
return get_global_object()
|
||||
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
} 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')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
219
test/test.js
219
test/test.js
@@ -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,7 +3923,8 @@ const y = x()`
|
||||
`
|
||||
const {state: i, on_deferred_call} = test_deferred_calls_state(code)
|
||||
|
||||
await i.eval_modules_state.promise.__original_then(result => {
|
||||
const result = await i.eval_modules_state.promise
|
||||
|
||||
const s = COMMANDS.eval_modules_finished(
|
||||
i,
|
||||
i,
|
||||
@@ -3969,8 +3938,6 @@ const y = x()`
|
||||
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) {
|
||||
function fetch(...args) {
|
||||
let resolver
|
||||
const promise = new Promise(r => resolver = r)
|
||||
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)
|
||||
}),
|
||||
]
|
||||
|
||||
165
test/utils.js
165
test/utils.js
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user