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. 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 ## 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. 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) 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 ## 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/) 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 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 => { 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 state = state ?? initial.initialState
component = initial.component component = initial.component
root = initial.root root = initial.root
@@ -12,6 +19,10 @@ export const createApp = initial => {
export const handler = fn => (...args) => { export const handler = fn => (...args) => {
state = fn(state, ...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() do_render()
} }

View File

@@ -1,5 +1,5 @@
import {map_object, map_find, filter_object, collect_nodes_with_parents, uniq} import {map_object, map_find, filter_object, collect_nodes_with_parents, uniq,
from './utils.js' set_is_eq} from './utils.js'
import { import {
is_eq, is_child, ancestry, ancestry_inc, map_tree, is_eq, is_child, ancestry, ancestry_inc, map_tree,
find_leaf, find_fn_by_location, find_node, find_error_origin_node, 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 => { 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] 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] const result = s.parse_result.cache[module]
if(result != null) { if(result != null) {
return result return result
@@ -87,10 +92,18 @@ const run_code = (s, dirty_files) => {
return s.files[module] 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 = { const state = {
...s, ...s,
dirty_files,
globals,
parse_result, parse_result,
calltree: null, calltree: null,
modules: null, modules: null,
@@ -121,15 +134,7 @@ const run_code = (s, dirty_files) => {
.map(i => i.node.full_import_path) .map(i => i.node.full_import_path)
) )
if( if(external_imports.length != 0) {
external_imports.length != 0
&&
(
state.external_imports_cache == null
||
external_imports.some(i => state.external_imports_cache[i] == null)
)
) {
// Trigger loading of external modules // Trigger loading of external modules
return {...state, return {...state,
loading_external_imports_state: { loading_external_imports_state: {
@@ -138,18 +143,8 @@ const run_code = (s, dirty_files) => {
} }
} else { } else {
// Modules were loaded and cached, proceed // Modules were loaded and cached, proceed
return external_imports_loaded( return external_imports_loaded(state, state)
state,
state,
state.external_imports_cache == null
? null
: filter_object(
state.external_imports_cache,
(module_name, module) => external_imports.includes(module_name)
),
)
} }
} }
const external_imports_loaded = ( const external_imports_loaded = (
@@ -168,7 +163,6 @@ const external_imports_loaded = (
const state = { const state = {
...s, ...s,
external_imports_cache: external_imports,
loading_external_imports_state: null loading_external_imports_state: null
} }
@@ -204,6 +198,7 @@ const external_imports_loaded = (
state.on_deferred_call, state.on_deferred_call,
state.calltree_changed_token, state.calltree_changed_token,
state.io_trace, state.io_trace,
state.storage,
) )
if(result.then != null) { if(result.then != null) {
@@ -252,10 +247,12 @@ const eval_modules_finished = (state, prev_state, result) => {
const input = (state, code, index) => { const input = (state, code, index) => {
const files = {...state.files, [state.current_module]: code} const files = {...state.files, [state.current_module]: code}
const next = run_code( const with_files = {
set_cursor_position({...state, files}, index), ...state,
[state.current_module] files,
) dirty_files: new Set([...(state.dirty_files ?? []), state.current_module])
}
const next = set_cursor_position(with_files, index)
const effect_save = { const effect_save = {
type: 'write', type: 'write',
args: [ args: [
@@ -519,12 +516,10 @@ const change_current_module = (state, current_module) => {
} }
const change_entrypoint = (state, entrypoint, current_module = entrypoint) => { const change_entrypoint = (state, entrypoint, current_module = entrypoint) => {
return run_code( return {...state,
{...state, entrypoint,
entrypoint, current_module,
current_module, }
}
)
} }
const change_html_file = (state, html_file) => { 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 => { const clear_io_trace = state => {
return run_code({...state, io_trace: null}) return {...state, io_trace: null}
} }
const load_files = (state, dir) => { 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) => { const load_dir = (state, dir, has_file_system_access, entrypoint_settings) => {
// Clear parse cache and rerun code // Clear parse cache and rerun code
const with_dir = load_files(state, dir) const with_dir = load_files(state, dir)
return run_code({ return {
...( ...(
entrypoint_settings == null entrypoint_settings == null
? with_dir ? 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 // 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 // not available because project_dir is not available have errors and the
// errors are cached // errors are cached
parse_result: null, parse_result: {...state.parse_result, cache: {}},
}
external_imports_cache: null,
})
} }
const create_file = (state, dir, current_module) => { const create_file = (state, dir, current_module) => {
return {...load_dir(state, dir, true), current_module} return {...load_dir(state, dir, true), current_module}
} }
const open_app_window = (state, globals) => { const open_app_window = state => ({...state, storage: new Map()})
// 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 get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => { const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => {
const with_files = state.project_dir == null const with_files = state.project_dir == null
@@ -938,6 +918,7 @@ const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => {
return { return {
...with_settings, ...with_settings,
storage: new Map(),
cursor_position_by_file: {[with_settings.current_module]: cursor_pos}, 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 = { export const COMMANDS = {
get_initial_state, get_initial_state,
input, input,
run_code,
open_app_window, open_app_window,
load_dir, load_dir,
create_file, 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 {ValueExplorer} from './value_explorer.js'
import {stringify_for_header} from '../value_explorer_utils.js' import {stringify_for_header} from '../value_explorer_utils.js'
import {el} from './domutils.js' import {el} from './domutils.js'
@@ -98,7 +98,7 @@ export class Editor {
normalize_events(this.ace_editor, { normalize_events(this.ace_editor, {
on_change: () => { on_change: () => {
try { 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) { } catch(e) {
// Do not throw Error to ACE because it breaks typing // Do not throw Error to ACE because it breaks typing
console.error(e) console.error(e)

View File

@@ -6,7 +6,7 @@ import {
exec, exec,
get_state, get_state,
open_directory, open_directory,
reload_app_window, exec_and_reload_app_window,
close_directory, close_directory,
} from '../index.js' } from '../index.js'
@@ -20,16 +20,14 @@ export class Files {
this.render(get_state()) this.render(get_state())
} }
change_entrypoint(e) { change_entrypoint(entrypoint, current_module) {
const file = e.target.value exec_and_reload_app_window('change_entrypoint', entrypoint, current_module)
exec('change_entrypoint', file)
this.ui.editor.focus() this.ui.editor.focus()
} }
change_html_file(e) { change_html_file(e) {
const html_file = e.target.value const html_file = e.target.value
exec('change_html_file', html_file) exec_and_reload_app_window('change_html_file', html_file)
reload_app_window(get_state())
} }
@@ -119,7 +117,7 @@ export class Files {
name: 'js_entrypoint', name: 'js_entrypoint',
value: file.path, value: file.path,
checked: state.entrypoint == 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(), click: e => e.stopPropagation(),
}) })
) )
@@ -210,9 +208,9 @@ export class Files {
// Reload all files for simplicity // Reload all files for simplicity
open_dir(false).then(dir => { open_dir(false).then(dir => {
if(is_dir) { if(is_dir) {
exec('load_dir', dir, true) exec_and_reload_app_window('load_dir', dir, true)
} else { } 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) { if(file.path == null) {
// root of examples dir, do nothing // root of examples dir, do nothing
} else if(file.path == '') { } else if(file.path == '') {
exec('change_entrypoint', '') this.change_entrypoint('')
} else { } else {
const find_node = n => const find_node = n =>
n.path == file.path n.path == file.path
@@ -244,8 +242,7 @@ export class Files {
// in examples mode, on click file we also change entrypoint for // in examples mode, on click file we also change entrypoint for
// simplicity // simplicity
const example = examples.find(e => e.path == example_dir.path) const example = examples.find(e => e.path == example_dir.path)
exec( this.change_entrypoint(
'change_entrypoint',
example.entrypoint, example.entrypoint,
file.kind == 'directory' ? undefined : file.path 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 {Editor} from './editor.js'
import {Files} from './files.js' import {Files} from './files.js'
import {CallTree} from './calltree.js' import {CallTree} from './calltree.js'
@@ -73,7 +73,7 @@ export class UI {
el('a', { el('a', {
'class': 'statusbar_action first', 'class': 'statusbar_action first',
href: 'javascript: void(0)', href: 'javascript: void(0)',
click: () => exec('clear_io_trace') click: () => this.clear_io_trace(),
}, },
'Clear IO trace (F6)' '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() { open_app_window() {
this.toggle_open_app_window_tooltip(false) this.toggle_open_app_window_tooltip(false)
localStorage.onboarding_open_app_window = true localStorage.onboarding_open_app_window = true

View File

@@ -421,6 +421,7 @@ export const eval_modules = (
on_deferred_call, on_deferred_call,
calltree_changed_token, calltree_changed_token,
io_trace, io_trace,
storage,
) => { ) => {
// TODO gensym __cxt, __trace, __trace_call, __calltree_node_by_loc, // TODO gensym __cxt, __trace, __trace_call, __calltree_node_by_loc,
// __await_start, __await_finish, __Multiversion, __create_array, __create_object // __await_start, __await_finish, __Multiversion, __create_array, __create_object
@@ -498,6 +499,8 @@ export const eval_modules = (
is_toplevel_call: true, is_toplevel_call: true,
window: globalThis.app_window, window: globalThis.app_window,
storage,
} }
const result = run(module_fns, cxt, io_trace) 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 * 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) => { export const find_definitions = (ast, globals, scope = {}, closure_scope = {}, module_name) => {
// sanity check // sanity check
@@ -94,7 +96,7 @@ export const find_definitions = (ast, globals, scope = {}, closure_scope = {}, m
} else { } else {
const definition = scope[ast.value] const definition = scope[ast.value]
if(definition == null){ 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()} return {node: {...ast, definition: 'global'}, undeclared: null, closed: new Set()}
} else { } else {
return {node: ast, undeclared: [ast], closed: new Set()} return {node: ast, undeclared: [ast], closed: new Set()}

View File

@@ -52,7 +52,7 @@ const get_html_url = state => {
const on_window_load = w => { const on_window_load = w => {
init_window_service_worker(w) init_window_service_worker(w)
exec( exec(
'open_app_window', 'run_code',
new Set(Object.getOwnPropertyNames(w)) new Set(Object.getOwnPropertyNames(w))
) )
} }
@@ -78,6 +78,7 @@ const open_run_iframe = (state) => {
export const open_app_window = state => { export const open_app_window = state => {
// TODO set_error_handler? Or we dont need to set_error_handler for child // TODO set_error_handler? Or we dont need to set_error_handler for child
// window because error is always caught by parent window handler? // window because error is always caught by parent window handler?
exec('open_app_window')
globalThis.app_window.close() globalThis.app_window.close()
globalThis.app_window = open(get_html_url(state)) globalThis.app_window = open(get_html_url(state))
init_app_window(globalThis.app_window) init_app_window(globalThis.app_window)
@@ -139,10 +140,8 @@ const init_app_window = w => {
add_load_handler() add_load_handler()
} }
export const reload_app_window = state => { const reload_app_window = state => {
// TODO after window location reload, open_app_window command will be fired. // after window location reload, `run_code` command will be fired.
// Maybe we should have separate commands for open_app_window and
// reload_app_window?
globalThis.app_window.location = get_html_url(state) 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 = () => { export const open_directory = () => {
if(globalThis.showDirectoryPicker == null) { if(globalThis.showDirectoryPicker == null) {
throw new Error('Your browser is not supporting File System Access API') throw new Error('Your browser is not supporting File System Access API')
} }
open_dir(true).then(dir => { 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 () => { export const close_directory = async () => {
close_dir() 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) : original.apply(this, args)
if(value?.[Symbol.toStringTag] == 'Promise') { if(value?.[Symbol.toStringTag] == 'Promise') {
// TODO use __original_then, not finally which calls value = value
// patched 'then'? .then(val => {
value = value.finally(() => { value.status = {ok: true, value: val}
if(cxt_copy != cxt) { return val
return })
} .catch(error => {
if(cxt.io_trace_is_replay_aborted) { value.status = {ok: true, error}
// Non necessary throw error
return })
} .finally(() => {
cxt.io_trace.push({type: 'resolution', index}) if(cxt_copy != cxt) {
}) return
}
if(cxt.io_trace_is_replay_aborted) {
// Non necessary
return
}
cxt.io_trace.push({type: 'resolution', index})
})
} }
ok = true 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) { if(next_resolution != null && !cxt.io_trace_resolver_is_set) {
const original_setTimeout = cxt.window.setTimeout.__original
cxt.io_trace_resolver_is_set = true 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) { if(cxt_copy != cxt) {
return return
} }
@@ -180,14 +188,22 @@ const make_patched_method = (window, original, name, use_context) => {
cxt.io_trace[cxt.io_trace_index].type == 'resolution' cxt.io_trace[cxt.io_trace_index].type == 'resolution'
) { ) {
const resolution = cxt.io_trace[cxt.io_trace_index] 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++ cxt.io_trace_index++
if(cxt.io_trace[resolution.index].name == 'setTimeout') { if(cxt.io_trace[resolution.index].name == 'setTimeout') {
resolver() resolve()
} else { } 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 // trace) and instanceof would not work
if(call.value?.[Symbol.toStringTag] == 'Promise') { if(call.value?.[Symbol.toStringTag] == 'Promise') {
// Always make promise originate from app_window // Always make promise originate from app_window
return new cxt.window.Promise(resolve => { return new cxt.window.Promise((resolve, reject) => {
cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, resolve) cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, {resolve, reject})
}) })
} else if(name == 'setTimeout') { } else if(name == 'setTimeout') {
const timeout_cb = args[0] 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 return call.value
} else { } else {
return call.value return call.value

View File

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

View File

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

View File

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

View File

@@ -8,61 +8,47 @@
*/ */
globalThis.Response globalThis.Response
export const run = tests => { if(globalThis.process != null) {
// Runs test, return failure or null if not failed globalThis.NodeVM = await import('node:vm')
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 let iframe
const filter = globalThis.process && globalThis.process.argv[2]
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 context = globalThis.NodeVM.createContext({
const tests_to_run = only == null ? tests : [only]
// Exec each test. After all tests are done, we rethrow first error if process,
// 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) { // for some reason URL is not available inside VM
throw failure URL,
} else {
if(globalThis.process != null) {
console.log('Ok')
}
}
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 { } else {
const test = tests.find(t => t.message.includes(filter)) // We are in browser
if(test == null) { if(iframe != null) {
throw new Error('test not found') globalThis.document.body.removeChild(iframe)
} else {
return run_test(test).then(() => {
if(globalThis.process != null) {
console.log('Ok')
}
})
} }
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_code_error, assert_code_error_async,
assert_versioned_value, assert_value_explorer, assert_selection, assert_versioned_value, assert_value_explorer, assert_selection,
parse_modules, parse_modules,
run_code,
test_initial_state, test_initial_state_async, test_initial_state, test_initial_state_async,
test_deferred_calls_state, test_deferred_calls_state,
print_debug_ct_node, print_debug_ct_node,
command_input_async, input,
patch_builtin, input_async,
original_setTimeout,
} from './utils.js' } from './utils.js'
export const tests = [ export const tests = [
@@ -1012,7 +1012,8 @@ export const tests = [
const spoil_file = {...s, files: {...s.files, 'c': ',,,'}} const spoil_file = {...s, files: {...s.files, 'c': ',,,'}}
// change module '' // 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) assert_equal(s2.parse_result.ok, true)
}), }),
@@ -1110,7 +1111,7 @@ export const tests = [
const index = edited.indexOf('foo_var') const index = edited.indexOf('foo_var')
const {state, effects} = COMMANDS.input( const {state, effects} = input(
initial, initial,
edited, edited,
index 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', () => { test('module external cache error bug', () => {
const code = ` const code = `
// external // external
@@ -1221,7 +1189,7 @@ export const tests = [
` `
// edit code // edit code
const {state} = COMMANDS.input( const {state} = input(
next, next,
edited, edited,
edited.lastIndexOf('foo_var'), edited.lastIndexOf('foo_var'),
@@ -1259,7 +1227,7 @@ export const tests = [
const edited = `` const edited = ``
// edit code // edit code
const {state, effects} = COMMANDS.input( const {state, effects} = input(
next, next,
edited, edited,
0, 0,
@@ -2000,7 +1968,7 @@ const y = x()`
x(2); x(2);
` `
const s3 = COMMANDS.input(s2, invalid, invalid.indexOf('return')).state const s3 = input(s2, invalid, invalid.indexOf('return')).state
const edited = ` const edited = `
const x = foo => { const x = foo => {
@@ -2010,7 +1978,7 @@ const y = x()`
x(2); 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('*')) const res = find_leaf(active_frame(n), edited.indexOf('*'))
@@ -2044,7 +2012,7 @@ const y = x()`
[1,2,3].map(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) 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')) const s3 = COMMANDS.move_cursor(s2, edited.indexOf('import'))
assert_equal(s3.value_explorer.result.value.x, 1) assert_equal(s3.value_explorer.result.value.x, 1)
}), }),
@@ -2098,7 +2066,7 @@ const y = x()`
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) assert_equal(e.active_calltree_node.toplevel, true)
}), }),
@@ -2111,7 +2079,7 @@ const y = x()`
}), }),
'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.current_calltree_node.module, '')
assert_equal(e.active_calltree_node, null) assert_equal(e.active_calltree_node, null)
}), }),
@@ -2143,7 +2111,7 @@ const y = x()`
` `
const moved = COMMANDS.move_cursor(s3, code.indexOf('2')) 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.active_calltree_node, null)
assert_equal(e.current_calltree_node.toplevel, true) assert_equal(e.current_calltree_node.toplevel, true)
}), }),
@@ -2156,7 +2124,7 @@ const y = x()`
x() x()
` `
const i = test_initial_state(code) 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) const left = COMMANDS.calltree.arrow_left(edited)
assert_equal(left.active_calltree_node.toplevel, true) 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 {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 // Make deferred call, calling fn from previous code
i.modules[''].fn(1) 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 // deferred calls must be null, because deferred calls from previous executions
// must be discarded // must be discarded
@@ -3911,7 +3879,7 @@ const y = x()`
} }
await f() 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.active_calltree_node.fn.name, 'f')
assert_equal(next.value_explorer.result.value, 1) 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) 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,
result,
i.eval_modules_state.node,
i.eval_modules_state.toplevel
)
// Make deferred call const s = COMMANDS.eval_modules_finished(
s.modules[''].fn() i,
const state = on_deferred_call(s) i,
assert_equal(get_deferred_calls(state).length, 1) result,
assert_equal(get_deferred_calls(state)[0].value, 1) 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 () => { test('async/await await argument bug', async () => {
@@ -3991,101 +3958,94 @@ const y = x()`
}), }),
test('record io', () => { test('record io', () => {
// Patch Math.random to always return 1 let app_window_patches
patch_builtin('random', () => 1)
const initial = test_initial_state(` // Patch Math.random to always return 1
const x = Math.random() 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 // Now call to Math.random is cached, break it to ensure it was not called
// on next run // 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.value_explorer.result.value, 2)
assert_equal(next.rt_cxt.io_trace_index, 1) assert_equal(next.rt_cxt.io_trace_index, 1)
// Patch Math.random to return 2. // Patch Math.random to return 2.
// TODO The first call to Math.random() is cached with value 1, and the // TODO The first call to Math.random() is cached with value 1, and the
// second shoud return 2 // second shoud return 2
patch_builtin('random', () => 2) app_window_patches = {'Math.random': () => 2}
const replay_failed = COMMANDS.input( const replay_failed = input(
initial, initial,
`const x = Math.random() + Math.random()`, `const x = Math.random() + Math.random()`,
0 0,
{app_window_patches}
).state ).state
// TODO must reuse first cached call? // TODO must reuse first cached call?
assert_equal(replay_failed.value_explorer.result.value, 4) 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 () => { test('record io trace discarded if args does not match', async () => {
// Patch fetch // Patch fetch
patch_builtin('fetch', async () => 'first') let app_window_patches
app_window_patches = {fetch: async () => 'first'}
const initial = await test_initial_state_async(` const initial = await test_initial_state_async(`
console.log(await fetch('url', {method: 'GET'})) console.log(await fetch('url', {method: 'GET'}))
`) `, undefined, {app_window_patches})
assert_equal(initial.logs.logs[0].args[0], 'first') assert_equal(initial.logs.logs[0].args[0], 'first')
// Patch fetch again // 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'})) console.log(await fetch('url', {method: 'POST'}))
`, 0) `, 0, {app_window_patches})
assert_equal(cache_discarded.logs.logs[0].args[0], 'second') assert_equal(cache_discarded.logs.logs[0].args[0], 'second')
// Remove patch
patch_builtin('fetch', null)
}), }),
test('record io fetch rejects', async () => { test('record io fetch rejects', async () => {
// Patch fetch // 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(` const initial = await test_initial_state_async(`
await fetch('url', {method: 'GET'}) await fetch('url', {method: 'GET'})
`) `, undefined, {app_window_patches})
assert_equal(root_calltree_node(initial).error, 'fail') assert_equal(root_calltree_node(initial).error, 'fail')
// Patch fetch again // 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'}) await fetch('url', {method: 'GET'})
`, 0) `, 0, {app_window_patches})
assert_equal(root_calltree_node(initial).error, 'fail') assert_equal(root_calltree_node(initial).error, 'fail')
// Remove patch
patch_builtin('fetch', null)
}), }),
test('record io preserve promise resolution order', async () => { test('record io preserve promise resolution order', async () => {
// Generate fetch function which calls get resolved in reverse order // Generate fetch function which calls get resolved in reverse order
const {fetch, resolve} = new Function(` const calls = []
const calls = [] function fetch(...args) {
return { let resolver
fetch(...args) { const promise = new (globalThis.app_window.Promise)(r => {resolver = r})
let resolver calls.push({resolver, promise, args})
const promise = new Promise(r => resolver = r) return promise
calls.push({resolver, promise, args}) }
return promise
},
resolve() { function resolve() {
[...calls].reverse().forEach(call => call.resolver(...call.args)) [...calls].reverse().forEach(call => call.resolver(...call.args))
}, }
}
`)() let app_window_patches
// Patch fetch // Patch fetch
patch_builtin('fetch', fetch) app_window_patches = {fetch}
const code = ` const code = `
await Promise.all( 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() resolve()
@@ -4106,56 +4066,54 @@ const y = x()`
assert_equal(initial.logs.logs.map(l => l.args[0]), [3,2,1]) assert_equal(initial.logs.logs.map(l => l.args[0]), [3,2,1])
// Break fetch to ensure it is not get called anymore // 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, initial,
code, code,
0 0,
{app_window_patches}
) )
// cached calls to fetch should be resolved in the same (reverse) order as // cached calls to fetch should be resolved in the same (reverse) order as
// on the first run, so first call wins // on the first run, so first call wins
assert_equal(with_cache.logs.logs.map(l => l.args[0]), [3,2,1]) 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 () => { test('record io setTimeout', async () => {
let app_window_patches
// Patch fetch to return result in 10ms // Patch fetch to return result in 10ms
patch_builtin( app_window_patches = {
'fetch', fetch: () => new (globalThis.app_window.Promise)(resolve => setTimeout(resolve, 10))
() => new Promise(resolve => original_setTimeout(resolve, 10)) }
)
const code = ` const code = `
setTimeout(() => console.log('timeout'), 0) setTimeout(() => console.log('timeout'), 0)
await fetch().then(() => console.log('fetch')) 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 // First executed setTimeout, then fetch
assert_equal(i.logs.logs.map(l => l.args[0]), ['timeout', 'fetch']) assert_equal(i.logs.logs.map(l => l.args[0]), ['timeout', 'fetch'])
// Break fetch to ensure it would not be called // 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 // Cache must preserve resolution order
assert_equal(with_cache.logs.logs.map(l => l.args[0]), ['timeout', 'fetch']) assert_equal(with_cache.logs.logs.map(l => l.args[0]), ['timeout', 'fetch'])
patch_builtin('fetch', null)
}), }),
test('record io clear io trace', async () => { test('record io clear io trace', async () => {
const s1 = test_initial_state(`Math.random()`) const s1 = test_initial_state(`Math.random()`)
const rnd = s1.value_explorer.result.value 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) 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( assert_equal(
cleared.value_explorer.result.value == rnd + 1, cleared.value_explorer.result.value == rnd + 1,
false false
@@ -4185,8 +4143,8 @@ const y = x()`
const rnd = i.active_calltree_node.children[0].value const rnd = i.active_calltree_node.children[0].value
// Run two versions of code in parallel // Run two versions of code in parallel
const next = COMMANDS.input(i, `await Promise.resolve()`, 0) const next = input(i, `await Promise.resolve()`, 0)
const next2 = COMMANDS.input(i, `Math.random(1)`, 0).state const next2 = input(i, `Math.random(1)`, 0).state
const next_rnd = i.active_calltree_node.children[0].value const next_rnd = i.active_calltree_node.children[0].value
assert_equal(rnd, next_rnd) assert_equal(rnd, next_rnd)
}), }),
@@ -4211,10 +4169,10 @@ const y = x()`
}), }),
test('record io hangs bug', async () => { test('record io hangs bug', async () => {
patch_builtin( let app_window_patches
'fetch', app_window_patches = {
() => new Promise(resolve => original_setTimeout(resolve, 0)) fetch: () => new (globalThis.app_window.Promise)(resolve => setTimeout(resolve, 0))
) }
const code = ` const code = `
const p = fetch('') const p = fetch('')
@@ -4222,22 +4180,20 @@ const y = x()`
await p 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) assert_equal(i.io_trace.length, 3)
const next_code = `await fetch('')` 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) assert_equal(state.io_trace.length, 2)
patch_builtin('fetch', null)
}), }),
test('record io logs recorded twice bug', () => { test('record io logs recorded twice bug', () => {
const code = `Math.random()` const code = `Math.random()`
const i = test_initial_state(code) const i = test_initial_state(code)
const second = COMMANDS.input( const second = input(
i, i,
`console.log(1); Math.random(); Math.random()`, `console.log(1); Math.random(); Math.random()`,
0 0
@@ -5401,4 +5357,19 @@ const y = x()`
assert_value_explorer(i, expected) 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 {active_frame, pp_calltree, version_number_symbol} from '../src/calltree.js'
import {COMMANDS} from '../src/cmd.js' import {COMMANDS} from '../src/cmd.js'
// external
import {create_app_window} from './run_utils.js'
// external // external
import {with_version_number} from '../src/runtime/runtime.js' import {with_version_number} from '../src/runtime/runtime.js'
Object.assign(globalThis, 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, 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( export const do_parse = code => parse(
code, code,
new Set(Object.getOwnPropertyNames(globalThis.app_window)) new Set(Object.getOwnPropertyNames(globalThis))
) )
export const parse_modules = (entry, modules) => export const parse_modules = (entry, modules) =>
@@ -102,18 +64,48 @@ export const assert_code_error_async = async (codestring, error) => {
assert_equal(result.error, 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 = {}) => { export const test_initial_state = (code, cursor_pos, options = {}) => {
if(cursor_pos < 0) { if(cursor_pos < 0) {
throw new Error('illegal cursor_pos') throw new Error('illegal cursor_pos')
} }
if(typeof(options) != 'object') {
throw new Error('illegal state')
}
const { const {
//entrypoint = '',
current_module, current_module,
project_dir, project_dir,
on_deferred_call, on_deferred_call,
app_window_patches,
} = options } = options
const entrypoint = options.entrypoint ?? '' const entrypoint = options.entrypoint ?? ''
return COMMANDS.open_app_window(
return run_code(
COMMANDS.get_initial_state( COMMANDS.get_initial_state(
{ {
files: typeof(code) == 'object' ? code : { '' : code}, files: typeof(code) == 'object' ? code : { '' : code},
@@ -126,7 +118,7 @@ export const test_initial_state = (code, cursor_pos, options = {}) => {
}, },
cursor_pos 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) => { export const input = (s, code, index, options = {}) => {
const after_input = COMMANDS.input(...args).state 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 const result = await after_input.eval_modules_state.promise
return COMMANDS.eval_modules_finished( return COMMANDS.eval_modules_finished(
after_input, after_input,
@@ -239,3 +242,69 @@ export const test = (message, test, only = false) => {
} }
export const test_only = (message, t) => test(message, t, true) 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)