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

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

View File

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

View File

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

View File

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