1063: use canonical text animator match names and resolve main

This commit is contained in:
Kristian Myram
2026-10-17 20:50:55 +00:00
166 changed files with 6555 additions and 766 deletions
+4
View File
@@ -106,5 +106,9 @@ mod tests_ui_more;
// #760: `$.evalFile` and `BridgeTalk`.
#[cfg(test)]
mod tests_evalfile;
// `/browser/…` is the web editor's file table, an absolute path on wasm; on Windows it has no
// drive, so the script runtime resolves it under the current drive, where the host holds nothing.
#[cfg(all(test, not(windows)))]
mod tests_services;
#[cfg(test)]
mod tests_ui_mouse;
+42
View File
@@ -90,6 +90,34 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> {
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
/// Animator property ids and their stable After Effects scripting names. Shared by lookup,
/// `.matchName`, and `addProperty` so the same property has one name in every direction.
pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[
("anchor", "ADBE Text Anchor Point 3D"),
("position", "ADBE Text Position 3D"),
("scale", "ADBE Text Scale 3D"),
("skew", "ADBE Text Skew"),
("rotation", "ADBE Text Rotation"),
("opacity", "ADBE Text Opacity"),
("fillColor", "ADBE Text Fill Color"),
("fillHue", "ADBE Text Fill Hue"),
("fillSaturation", "ADBE Text Fill Saturation"),
("fillBrightness", "ADBE Text Fill Brightness"),
("fillOpacity", "ADBE Text Fill Opacity"),
("strokeColor", "ADBE Text Stroke Color"),
("strokeHue", "ADBE Text Stroke Hue"),
("strokeSaturation", "ADBE Text Stroke Saturation"),
("strokeBrightness", "ADBE Text Stroke Brightness"),
("strokeOpacity", "ADBE Text Stroke Opacity"),
("strokeWidth", "ADBE Text Stroke Width"),
("tracking", "ADBE Text Tracking Amount"),
("lineAnchor", "ADBE Text Line Anchor"),
("lineSpacing", "ADBE Text Line Spacing"),
("characterOffset", "ADBE Text Character Offset"),
("characterValue", "ADBE Text Character Replace"),
("blur", "ADBE Text Blur"),
];
const TEXT_SELECTORS: &[(&str, &str)] =
&[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")];
@@ -447,6 +475,9 @@ pub fn node(chain: &[&Node]) -> String {
"text" if pid == "animators" => "ADBE Text Animator".into(),
"text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => {
find(TEXT_ANIMATOR_PROPERTIES, id).map(str::to_string).unwrap_or_else(own)
}
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
@@ -497,6 +528,17 @@ mod tests {
assert_eq!(shape_kind("nope"), None);
}
#[test]
fn text_animator_property_names_are_canonical_and_scoped() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties"));
for (id, name) in TEXT_ANIMATOR_PROPERTIES {
let prop = n(id);
assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name);
assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids");
}
}
#[test]
fn text_range_selector_names() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
+3 -41
View File
@@ -447,14 +447,11 @@ fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<
matchnames::node(&ch)
};
let lk = loose(k);
// An animator's Properties group also answers to the After Effects names of its properties.
let animator_props = g.match_id == "properties";
g.children
.iter()
.find(|n| chain_of(n) == k)
.or_else(|| g.children.iter().find(|n| n.name() == k))
.or_else(|| g.children.iter().find(|n| n.match_id() == k))
.or_else(|| g.children.iter().find(|n| animator_props && animator_property_match_name(n.match_id()) == Some(k)))
.or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k)))
.or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk))
.or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m)))
@@ -742,50 +739,15 @@ fn resolve_add(s: &Session, a: &J) -> R {
})
}
/// The After Effects match name and key of each text animator property.
fn animator_property_table() -> &'static [(&'static str, &'static str)] {
&[
("ADBE Text Anchor Point 3D", "anchor"),
("ADBE Text Position 3D", "position"),
("ADBE Text Scale 3D", "scale"),
("ADBE Text Skew", "skew"),
("ADBE Text Rotation", "rotation"),
("ADBE Text Opacity", "opacity"),
("ADBE Text Fill Color", "fillColor"),
("ADBE Text Fill Hue", "fillHue"),
("ADBE Text Fill Saturation", "fillSaturation"),
("ADBE Text Fill Brightness", "fillBrightness"),
("ADBE Text Fill Opacity", "fillOpacity"),
("ADBE Text Stroke Color", "strokeColor"),
("ADBE Text Stroke Hue", "strokeHue"),
("ADBE Text Stroke Saturation", "strokeSaturation"),
("ADBE Text Stroke Brightness", "strokeBrightness"),
("ADBE Text Stroke Opacity", "strokeOpacity"),
("ADBE Text Stroke Width", "strokeWidth"),
("ADBE Text Tracking Amount", "tracking"),
("ADBE Text Line Anchor", "lineAnchor"),
("ADBE Text Line Spacing", "lineSpacing"),
("ADBE Text Character Offset", "characterOffset"),
("ADBE Text Character Replace", "characterValue"),
("ADBE Text Blur", "blur"),
]
}
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
/// name or a display name.
fn text_animator_property(name: &str) -> Option<&'static str> {
let table = animator_property_table();
if let Some((_, k)) = table.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
let table = matchnames::TEXT_ANIMATOR_PROPERTIES;
if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n = loose(name);
table.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
}
/// The After Effects match name of a text animator property key (`tracking` is
/// `ADBE Text Tracking Amount`), for scripts that look a property up by that name (#1063).
fn animator_property_match_name(key: &str) -> Option<&'static str> {
animator_property_table().iter().find(|(_, k)| *k == key).map(|(m, _)| *m)
table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
}
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
+1
View File
@@ -1977,6 +1977,7 @@ var app = {
savePrefAsBool: function () {},
savePrefAsFloat: function () {},
reload: function () {},
saveToDisk: function () {},
},
newProject: function () { __call("file.newProject", {}); return new Project(); },
open: function (file) {
+53 -15
View File
@@ -157,11 +157,16 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
s.state.selected_layers.clear();
}
let r = s.execute_for_script(id, params);
if let Some(c) = target
&& prev != Some(c)
&& s.state.active_comp == Some(c)
&& id != "comp.open"
{
if let Some(c) = target {
leave_comp(s, id, c, prev);
}
r.map_err(|e| e.to_string())
}
/// After a script's command on comp `c`, make `prev` (the comp active before it) active again,
/// unless the command opened `c` on purpose; with no comp to go back to, `c` stays open.
fn leave_comp(s: &mut Session, id: &str, c: effectcraft_engine::project::ItemId, prev: Option<effectcraft_engine::project::ItemId>) {
if prev != Some(c) && s.state.active_comp == Some(c) && id != "comp.open" {
match prev {
Some(p) if s.project.comp(p).is_some() => s.activate_comp(p),
_ => {
@@ -171,7 +176,6 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result<J, String> {
}
}
}
r.map_err(|e| e.to_string())
}
/// Run a keyframe command that acts on the selected keys (`keys.velocity`, `keys.interpolation`,
@@ -195,11 +199,17 @@ fn exec_on_keys(s: &mut Session, id: &str, params: J, targets: &J) -> Result<J,
let key = l.props.find(prop).and_then(|pr| pr.keys.get(usize::try_from(index).ok()?)).ok_or("the keyframe no longer exists")?;
keys.push(KeyRef { layer: LayerId(layer), prop, time: key.time });
}
// Make the keys' comp active before selecting them: activating a comp brings back the key
// selection it had, which would replace these keys and leave the command nothing to act on
// when the comp isn't the active one (#1531).
let prev = s.state.active_comp;
s.activate_comp(cid);
let saved = std::mem::replace(&mut s.state.selected_keys, keys);
let r = exec(s, id, params);
let comp = s.active_comp();
let exists = |k: &KeyRef| comp.and_then(|c| c.layer(k.layer)).and_then(|l| l.props.find(k.prop)).is_some_and(|pr| pr.keys.iter().any(|x| x.time == k.time));
s.state.selected_keys = saved.into_iter().filter(exists).collect();
leave_comp(s, id, cid, prev);
r
}
@@ -336,9 +346,19 @@ pub(crate) fn normalize(p: &str) -> std::path::PathBuf {
// Resolve symlinks where the file (or its folder) exists, so links can't escape the project.
std::fs::canonicalize(&out)
.or_else(|_| out.parent().map(std::fs::canonicalize).unwrap_or(Ok(out.clone())).map(|d| d.join(out.file_name().unwrap_or_default())))
.map(without_verbatim_prefix)
.unwrap_or(out)
}
/// A canonical Windows drive path without its `\\?\` prefix, so it compares with a lexical one
/// (a path whose folder doesn't exist on disk stays lexical).
fn without_verbatim_prefix(p: std::path::PathBuf) -> std::path::PathBuf {
match p.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(plain) if plain.as_bytes().get(1) == Some(&b':') => plain.into(),
_ => p,
}
}
pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
@@ -380,11 +400,14 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
}
let s = &a.session;
match op.as_str() {
"access" => Ok(json!(check_access(s, &path, false)?.exists())),
// Through the session's services, like the reads: hosts without a file system (the
// browser's file table) answer for their own files.
"access" => Ok(json!(s.services.exists(&check_access(s, &path, false)?.to_string_lossy()))),
"exists" | "isDir" => {
// Unreadable locations report "missing" instead of failing.
let Ok(p) = check_access(s, &path, false) else { return Ok(json!(false)) };
Ok(json!(if op == "exists" { p.exists() } else { p.is_dir() }))
let p = p.to_string_lossy();
Ok(json!(if op == "exists" { s.services.exists(&p) } else { s.services.is_dir(&p) }))
}
"read" | "readBinary" => {
let p = check_access(s, &path, false)?;
@@ -563,27 +586,32 @@ fn position(msg: &str, file: &str, code: &str) -> (Option<u32>, Option<u32>) {
let mut it = l[i..].split([':', ')']);
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
};
// The innermost frame in the user's script, or the error's own position.
let frames: Vec<&str> = msg.lines().skip(1).collect();
let frame = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick));
// `ReferenceError: name is not defined`: boa places it at the start of the function that reads
// the name, so the first use of the name in the code is the better position (#1063).
// the name, so the name's first use from that frame's line on is the better position (#1063).
// With no frame to start from, the first use anywhere in the code is (#1161).
let first = msg.lines().next().unwrap_or("");
let undefined = first.split("ReferenceError: ").nth(1).and_then(|r| r.split(" is not defined").next());
if let Some(at) = undefined.and_then(|name| first_use(name, code)) {
let start_line = frame.map_or(1, |(line, _)| line);
if let Some(at) = undefined.and_then(|name| first_use(name, code, start_line)) {
return (Some(at.0), Some(at.1));
}
let frames: Vec<&str> = msg.lines().skip(1).collect();
if let Some((a, b)) = frames.iter().find_map(|l| pick(l)).or_else(|| msg.lines().next().and_then(pick)) {
if let Some((a, b)) = frame {
return (Some(a), Some(b));
}
(None, None)
}
/// The first use of `name` as a whole identifier in `code`, as a 1-based line and column.
fn first_use(name: &str, code: &str) -> Option<(u32, u32)> {
/// The first use of `name` as a whole identifier in `code` on or after `start_line` (1-based), as a
/// 1-based line and column.
fn first_use(name: &str, code: &str, start_line: u32) -> Option<(u32, u32)> {
if name.is_empty() {
return None;
}
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_' || c == b'$';
for (n, line) in code.lines().enumerate() {
for (n, line) in code.lines().enumerate().skip(start_line.saturating_sub(1) as usize) {
let b = line.as_bytes();
let mut from = 0;
while let Some(i) = line.get(from..).and_then(|rest| rest.find(name)).map(|i| i + from) {
@@ -829,4 +857,14 @@ mod nesting_tests {
assert_eq!(out.result, serde_json::json!(2));
assert!(check_nesting(&("(".repeat(MAX_NESTING) + &")".repeat(MAX_NESTING))).is_ok());
}
/// A canonical drive path and a lexical one compare once the `\\?\` prefix is gone; a UNC
/// share and a Unix path are left as they are.
#[test]
fn only_a_verbatim_drive_prefix_is_dropped() {
use std::path::PathBuf;
assert_eq!(without_verbatim_prefix(r"\\?\C:\proj\a.txt".into()), PathBuf::from(r"C:\proj\a.txt"));
assert_eq!(without_verbatim_prefix(r"\\?\UNC\server\share\a.txt".into()), PathBuf::from(r"\\?\UNC\server\share\a.txt"));
assert_eq!(without_verbatim_prefix("/proj/a.txt".into()), PathBuf::from("/proj/a.txt"));
}
}
+110 -2
View File
@@ -187,6 +187,18 @@ fn errors_inside_a_function_wrapper_report_the_failing_line() {
assert_eq!(e.line, Some(3), "{e:?}");
}
/// The first use of an undefined name is the one inside the function that reads it: a mention
/// before it, in a comment, a string or another function, doesn't move the report (#1161).
#[test]
fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function() {
let mut s = session();
let code = "// nope is mentioned here\nvar text = 'nope';\nfunction other() { return nope; }\n(function () {\n var a = 1;\n nope.call();\n})();\n";
let o = run_code(&mut s, code, "earlier.jsx");
let e = o.error.expect("reference error");
assert!(e.message.contains("nope"), "{e:?}");
assert_eq!(e.line, Some(6), "{e:?}");
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
@@ -430,6 +442,32 @@ fn save_frame_to_png_writes_behind_the_file_gate() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #1246: with Warn User When Executing Files on, a script that asserts `confirmed` itself still
/// gets the question; only the user's answer opens the file.
#[test]
fn a_script_cannot_confirm_executing_a_file_for_itself() {
let dir = std::env::temp_dir().join(format!("ec-script-exec-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("run.txt");
std::fs::write(&f, "x").unwrap();
let q = serde_json::to_string(&f.to_string_lossy()).unwrap();
let mut s = session();
// The user allows file access; the warning stays on (the default).
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
let opened = |s: &mut Session| s.drain_events().iter().any(|e| matches!(e, effectcraft_engine::Event::OpenUrl(_)));
for code in [
format!("JSON.stringify(app.run('file.executeFile', {{path: {q}, confirmed: true}}))"),
format!("JSON.stringify(app.run('engine.batch', {{steps: [{{command: 'file.executeFile', params: {{path: {q}, confirmed: true}}}}]}}))"),
format!("new File({q}).execute()"),
] {
let o = ok(&mut s, &code);
assert!(!o.result.to_string().contains("opened") || o.result == json!(false), "{code}: {:?}", o.result);
assert!(!opened(&mut s), "{code} opened the file without asking");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #590: a script can't give itself file and network access (or turn off the warning before
/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can.
#[test]
@@ -1258,6 +1296,36 @@ fn effect_parameter_interpolation_and_ease() {
assert_eq!(o.result, json!([true, true, 16.67, 33.33, true]));
}
/// #1531: the per-key methods act on a comp that isn't the active one too. Activating the keys'
/// comp brought back its own (empty) key selection over the keys named, so the interpolation and
/// the ease were silently dropped once another comp with a layer had been made first.
#[test]
fn key_interpolation_and_ease_apply_outside_the_active_comp() {
let mut s = session();
let o = ok(
&mut s,
r#"
var a = app.project.items.addComp("A", 400, 400, 1, 10, 20); a.layers.addNull();
var c = app.project.items.addComp("C", 400, 400, 1, 10, 20);
var n = c.layers.addNull(); n.threeDLayer = true;
var p = n.property("ADBE Transform Group").property("ADBE Rotate Y");
p.setValueAtTime(1, 0); p.setValueAtTime(2, -178);
for (var j = 1; j <= 2; j++) {
p.setInterpolationTypeAtKey(j, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER);
p.setTemporalEaseAtKey(j, [new KeyframeEase(0, 60)], [new KeyframeEase(0, 60)]);
}
[p.keyOutInterpolationType(1) == KeyframeInterpolationType.BEZIER,
p.keyInInterpolationType(2) == KeyframeInterpolationType.BEZIER,
p.keyOutTemporalEase(1)[0].influence,
Math.round(p.valueAtTime(1.25, false) * 100) / 100]
"#,
);
let v = o.result;
assert_eq!((&v[0], &v[1], &v[2]), (&json!(true), &json!(true), &json!(60)), "{v}");
// Eased, not the linear -44.5.
assert!(v[3].as_f64().is_some_and(|x| (x - -44.5).abs() > 1.0), "{v}");
}
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
#[test]
fn source_point_conversions_follow_the_layer_transform() {
@@ -1376,6 +1444,45 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() {
assert_eq!(o.result, json!([0.5, 0.5]));
}
/// After Effects resolves the Transform properties and Source Text from the layer itself, without
/// naming their group: `layer.property("Position")` (#1348).
#[test]
fn layer_property_resolves_transform_and_source_text_shortcuts() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("x", 100, 100, 1, 5, 30);
var t = c.layers.addText("hi");
var s = c.layers.addSolid([1, 0, 0], "s", 100, 100, 1);
var listed = [t.property("Source Text"), s.property("Position"), s.property("Opacity"), s.property("Scale")].join(", ");
s.property("Position").setValue([30, 40]);
s.property("Opacity").setValue(25);
t.property("Source Text").setValue("bye");
s.threeDLayer = true;
[listed, s.property("Position").value[0], s.property("Opacity").value, t.property("Source Text").value.text,
s.property("ADBE Position").matchName, s("Anchor Point").matchName, s.property("Rotation").matchName,
s.property("X Rotation").matchName, s.property("Orientation").matchName]
"#,
);
assert_eq!(
o.result,
json!([
"[object Property], [object Property], [object Property], [object Property]",
30,
25,
"bye",
"ADBE Position",
"ADBE Anchor Point",
"ADBE Rotate Z",
"ADBE Rotate X",
"ADBE Orientation"
])
);
let (_, c) = comp_named(&s, "x");
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
}
/// A text animator property added by its After Effects name is the one asked for, not its Tracking
/// Type companion, and it can be found again by that name (#1063).
#[test]
@@ -1390,8 +1497,9 @@ fn scripted_text_animator_property_is_the_one_asked_for() {
var props = a.property("ADBE Text Animator Properties");
var added = props.addProperty("ADBE Text Tracking Amount");
added.setValue(20);
[added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name]
[added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name,
added.matchName, props.property(added.matchName).propertyIndex]
"#,
);
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount"]));
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2]));
}
+7
View File
@@ -94,3 +94,10 @@ fn calling_without_a_name_is_a_script_error() {
let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l");
assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" }));
}
#[test]
fn app_preferences_save_to_disk_is_callable() {
let mut s = session();
let o = ok(&mut s, "app.preferences.saveToDisk(); app.preferences.reload(); [typeof app.preferences.saveToDisk, app.preferences.getPrefAsLong('a', 'b')]");
assert_eq!(o.result, json!(["function", 0]));
}
+52
View File
@@ -0,0 +1,52 @@
//! Scripts see the host's files: existence checks go through `Services`, like reads, so a host
//! that keeps files in memory (the web editor's browser storage) answers for its own files.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use effectcraft_engine::{Services, Session};
use crate::run_code;
#[derive(Default)]
struct Mem(Mutex<HashMap<String, Vec<u8>>>);
impl Services for Mem {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
self.0
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(path)
.cloned()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
self.0.lock().unwrap_or_else(PoisonError::into_inner).insert(path.into(), data.into());
Ok(())
}
fn exists(&self, path: &str) -> bool {
self.0.lock().unwrap_or_else(PoisonError::into_inner).contains_key(path)
}
fn is_dir(&self, path: &str) -> bool {
path == "/browser/footage"
}
}
#[test]
fn a_script_opens_a_file_only_the_host_holds() {
let mem = Arc::new(Mem::default());
mem.write_file("/browser/data.txt", b"hello").unwrap();
let mut s = Session { services: mem, ..Default::default() };
crate::install(&mut s);
// A project in a folder that isn't on disk: scripts may read files next to it.
s.path = Some("/browser/project.ecproj".into());
let out = run_code(
&mut s,
r#"var f = new File("/browser/data.txt"); var ok = f.open("r");
writeLn(ok + " " + (ok ? f.read() : f.error));
writeLn(new File("/browser/missing.txt").exists + " " + new Folder("/browser/footage").exists);"#,
"t.jsx",
);
assert!(out.error.is_none(), "{:?}", out.error);
assert_eq!(out.output, vec!["true hello".to_string(), "false true".to_string()]);
}