use std::fs;
use std::path::{Component, Path, PathBuf};
use regex::Regex;
use serde::Serialize;
use super::runtime::{TYPESCRIPT_REACT, TYPESCRIPT_SDK};
use crate::commands::auth::Framework;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StackUsage {
pub stack: String,
pub export_name: String,
pub import_path: String,
pub from_dir: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub view: Option<String>,
pub auth: UsageAuth,
pub snippet: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reads_call: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub reads: Vec<StackRead>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StackRead {
pub name: String,
pub params: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl StackRead {
pub fn signature(&self) -> String {
format!("{}({})", self.name, self.params.join(", "))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageAuth {
pub option: &'static str,
pub env_var: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppKind {
Node,
Browser(Framework),
}
const NODE_ENTRY: &str = "index.ts";
const SERVER_KEY_ENV: &str = "ARETE_API_KEY";
impl AppKind {
fn auth(self, app_dir: Option<String>) -> UsageAuth {
match self {
AppKind::Node => UsageAuth {
option: "secretKey",
env_var: SERVER_KEY_ENV,
fallback: Some("a4-login"),
command: None,
directory: None,
},
AppKind::Browser(framework) => UsageAuth {
option: "publishableKey",
env_var: framework.env_var(),
fallback: None,
command: Some(format!(
"a4 auth keys create-publishable --origin {} --env-file .env.local",
dev_origin(framework)
)),
directory: app_dir,
},
}
}
}
fn app_dir_from(app_dir: &Path, current: Option<&Path>) -> Option<String> {
let app_dir = fs::canonicalize(app_dir).unwrap_or_else(|_| app_dir.to_path_buf());
let current = current.and_then(|current| fs::canonicalize(current).ok());
match current
.as_deref()
.map(|current| app_dir.strip_prefix(current))
{
Some(Ok(relative)) if relative.as_os_str().is_empty() => None,
Some(Ok(relative)) => Some(relative.display().to_string()),
_ => Some(app_dir.display().to_string()),
}
}
fn app_dir_from_current(app_dir: &Path) -> Option<String> {
app_dir_from(app_dir, std::env::current_dir().ok().as_deref())
}
fn dev_origin(framework: Framework) -> &'static str {
match framework {
Framework::NextJs => "http://localhost:3000",
Framework::Vite => "http://localhost:5173",
Framework::Generic => "<origin>",
}
}
fn browser_env_access(framework: Framework) -> String {
match framework {
Framework::Vite => format!("import.meta.env.{}", framework.env_var()),
Framework::NextJs | Framework::Generic => format!("process.env.{}", framework.env_var()),
}
}
pub fn stack_usage(
alias: &str,
output: &Path,
from_dir: &Path,
app: AppKind,
) -> Option<StackUsage> {
let generated = GeneratedStack::read(output)?;
let import_path = import_specifier(from_dir, &generated.entry);
let export = &generated.export_name;
let view = generated.list_view.as_ref();
let auth = app.auth(app_dir_from_current(from_dir));
let key = if is_identifier(alias) { alias } else { "app" };
let mut snippet = Vec::new();
let mut stream = None;
let run = match app {
AppKind::Browser(framework) => {
if framework == Framework::NextJs {
snippet.push(r#""use client";"#.to_string());
snippet.push(String::new());
}
snippet.push(format!(
r#"import {{ AreteProvider, useArete }} from "{TYPESCRIPT_REACT}";"#
));
snippet.push(format!(r#"import {{ {export} }} from "{import_path}";"#));
snippet.push(String::new());
snippet.push(match &auth.directory {
Some(directory) => format!(
"// A publishable key bound to this app's origin, created by running in {directory}:"
),
None => "// A publishable key bound to this app's origin, created with:".to_string(),
});
if let Some(command) = &auth.command {
snippet.push(format!("// {command}"));
}
snippet.push(format!(
"const publishableKey = {};",
browser_env_access(framework)
));
snippet.push(String::new());
snippet.push("export function App() {".to_string());
snippet.push(" return (".to_string());
snippet.push(format!(
" <AreteProvider stack={{{export}}} auth={{{{ publishableKey }}}}>"
));
snippet.push(" <Rows />".to_string());
snippet.push(" </AreteProvider>".to_string());
snippet.push(" );".to_string());
snippet.push("}".to_string());
snippet.push(String::new());
snippet.push("function Rows() {".to_string());
snippet.push(format!(" const arete = useArete({export});"));
if let Some(view) = view {
snippet.push(format!(
" const rows = arete.views{}.use({{ take: 20 }});",
view.access
));
snippet
.push(" return <pre>{JSON.stringify(rows.data, null, 2)}</pre>;".to_string());
} else {
snippet.push(" return <pre>{arete.status}</pre>;".to_string());
}
snippet.push("}".to_string());
None
}
AppKind::Node => {
snippet.push(format!(
r#"import {{ createSession }} from "{TYPESCRIPT_SDK}";"#
));
snippet.push(format!(r#"import {{ {export} }} from "{import_path}";"#));
snippet.push(String::new());
snippet.push(format!(
"// No auth option: server-side, the SDK uses {SERVER_KEY_ENV} if set, else your a4 login."
));
snippet.push(format!(
"const session = await createSession({{ stacks: {{ {key}: {export} }} }});"
));
snippet.push("try {".to_string());
if let Some(view) = view {
snippet.push(format!(
" const row = await session.stacks.{key}.views{}.getOne({{ timeoutMs: 10_000 }});",
view.access
));
snippet.push(" console.log(row);".to_string());
stream = Some(format!(
"for await (const row of session.stacks.{key}.views{}.use()) {{ ... }}",
view.access
));
} else {
snippet.push(format!(
" console.log(Object.keys(session.stacks.{key}.views));"
));
}
snippet.push("} finally {".to_string());
snippet.push(" session.close();".to_string());
snippet.push("}".to_string());
Some(format!("npx tsx {NODE_ENTRY}"))
}
};
let reads_call = (!generated.reads.is_empty()).then(|| match app {
AppKind::Node => format!("await session.stacks.{key}.read.<name>(...)"),
AppKind::Browser(_) => "arete.read.<name>.use(...)".to_string(),
});
Some(StackUsage {
stack: alias.to_string(),
export_name: generated.export_name.clone(),
import_path,
from_dir: from_dir.display().to_string(),
view: view.map(|view| view.path.clone()),
auth,
snippet,
run,
stream,
fields: None,
reads_call,
reads: generated.reads,
})
}
fn is_identifier(name: &str) -> bool {
let mut characters = name.chars();
characters
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_' || first == '$')
&& characters
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$'))
}
#[derive(Debug, PartialEq, Eq)]
struct GeneratedStack {
entry: PathBuf,
export_name: String,
list_view: Option<ListView>,
reads: Vec<StackRead>,
}
#[derive(Debug, PartialEq, Eq)]
struct ListView {
access: String,
path: String,
}
impl GeneratedStack {
fn read(output: &Path) -> Option<Self> {
let directory = if output.extension().and_then(|ext| ext.to_str()) == Some("ts") {
output.parent()?.to_path_buf()
} else {
output.to_path_buf()
};
let mut files = fs::read_dir(&directory)
.ok()?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path.extension().and_then(|ext| ext.to_str()) == Some("ts")
&& !path.to_string_lossy().ends_with(".d.ts")
})
.filter_map(|path| fs::read_to_string(&path).ok().map(|text| (path, text)))
.collect::<Vec<_>>();
files.sort();
let export = Regex::new(r"(?m)^export const ([A-Z_][A-Z0-9_]*_STACK)\b\s*[:=]")
.expect("stack export regex should compile");
let (entry, export_name) = files.iter().find_map(|(path, text)| {
export
.captures(text)
.map(|captures| (path.clone(), captures[1].to_string()))
})?;
let core_marker = format!("export const {export_name}_CORE = {{");
let list_view = files
.iter()
.find_map(|(_, text)| text.find(&core_marker).map(|at| &text[at..]))
.and_then(first_list_view);
let reads = extension_entry(&directory)
.and_then(|entry| fs::read_to_string(entry).ok())
.map(|text| stack_reads(&text))
.unwrap_or_default();
Some(Self {
entry,
export_name,
list_view,
reads,
})
}
}
fn first_list_view(definition: &str) -> Option<ListView> {
let entity_line = Regex::new(r"^ {4}(.+): \{$").expect("entity regex should compile");
let list_line = Regex::new(r"^ {6}(\w+): listView<[^>]*>\('([^']+)'\)")
.expect("list view regex should compile");
let mut lines = definition.lines().skip_while(|line| *line != " views: {");
lines.next()?;
let mut entity: Option<String> = None;
let mut first = None;
for line in lines {
if line.starts_with(" }") {
break;
}
if let Some(captures) = entity_line.captures(line) {
entity = Some(captures[1].to_string());
continue;
}
if let (Some(entity), Some(captures)) = (&entity, list_line.captures(line)) {
let view = ListView {
access: format!("{}.{}", member(entity), &captures[1]),
path: captures[2].to_string(),
};
if &captures[1] == "latest" {
return Some(view);
}
first.get_or_insert(view);
}
}
first
}
fn extension_entry(directory: &Path) -> Option<PathBuf> {
let manifest: serde_json::Value =
serde_json::from_slice(&fs::read(directory.join("extensions.json")).ok()?).ok()?;
let entry = manifest.get("entry")?.as_str()?;
let path = directory.join(entry);
path.is_file().then_some(path)
}
const MAX_READ_ARGS: usize = 8;
fn stack_reads(extension: &str) -> Vec<StackRead> {
let declaration =
Regex::new(r"\breadArgCounts\s*:\s*\{").expect("read counts regex should compile");
let Some(counts) = declaration
.find_iter(extension)
.find_map(|found| read_counts(&extension[found.end()..]))
else {
return Vec::new();
};
counts
.into_iter()
.map(|(name, count)| {
let (params, title) = match read_function(extension, &name) {
Some((params, title)) => (params, title),
None => {
let params = match count {
Some(count) => (1..=count).map(|n| format!("arg{n}")).collect(),
None => vec!["...".to_string()],
};
(params, None)
}
};
StackRead {
name,
params,
title,
}
})
.collect()
}
fn read_counts(body: &str) -> Option<Vec<(String, Option<usize>)>> {
let mut rest = body;
let mut counts = Vec::new();
loop {
rest = skip_space(rest)?;
if rest.starts_with('}') {
return Some(counts);
}
let (key, after) = object_key(rest)?;
rest = skip_space(skip_space(after)?.strip_prefix(':')?)?;
let count = if let Some(after) = rest.strip_prefix('[') {
let (min, after) = count_literal(skip_space(after)?)?;
let after = skip_space(after)?.strip_prefix(',')?;
let (max, after) = count_literal(skip_space(after)?)?;
rest = skip_space(after)?.strip_prefix(']')?;
min.zip(max).map(|(min, max)| min.max(max))
} else {
let (count, after) = count_literal(rest)?;
rest = after;
count
};
counts.push((key, count.filter(|count| *count <= MAX_READ_ARGS)));
rest = skip_space(rest)?;
if let Some(after) = rest.strip_prefix(',') {
rest = after;
} else if !rest.starts_with('}') {
return None;
}
}
}
fn skip_space(text: &str) -> Option<&str> {
let mut rest = text.trim_start();
loop {
if let Some(comment) = rest.strip_prefix("//") {
rest = comment.find('\n').map_or("", |end| &comment[end..]);
} else if let Some(comment) = rest.strip_prefix("/*") {
rest = &comment[comment.find("*/")? + 2..];
} else {
return Some(rest);
}
rest = rest.trim_start();
}
}
fn object_key(text: &str) -> Option<(String, &str)> {
if let Some(quote) = text.chars().next().filter(|c| *c == '\'' || *c == '"') {
let inner = &text[1..];
let end = inner.find(quote)?;
let key = &inner[..end];
if key.contains(['\\', '\n']) {
return None;
}
return Some((key.to_string(), &inner[end + 1..]));
}
let end = text
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
.unwrap_or(text.len());
let key = &text[..end];
is_identifier(key).then(|| (key.to_string(), &text[end..]))
}
fn count_literal(text: &str) -> Option<(Option<usize>, &str)> {
let end = text
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(text.len());
(end > 0).then(|| (text[..end].parse().ok(), &text[end..]))
}
fn read_function(extension: &str, name: &str) -> Option<(Vec<String>, Option<String>)> {
let candidate = Regex::new(&format!(r"\b(function\s+)?{}\s*\(", regex::escape(name)))
.expect("read function regex should compile");
let found = candidate.captures_iter(extension).find_map(|captures| {
let found = captures.get(0)?;
let (params, after) = parameter_list(&extension[found.end()..])?;
if captures.get(1).is_none() && !starts_body(after) {
return None;
}
let params = params
.into_iter()
.enumerate()
.map(|(index, param)| param_name(¶m, index))
.collect();
let before = extension[..found.start()].trim_end();
let before = before.strip_suffix("async").unwrap_or(before).trim_end();
let title = before
.strip_suffix("*/")
.and_then(|before| before.rfind("/**").map(|at| &before[at + 3..]))
.and_then(doc_title);
Some((params, title))
});
found
}
fn parameter_list(text: &str) -> Option<(Vec<String>, &str)> {
let mut list = String::new();
let mut depth = 0usize;
let mut rest = text;
loop {
if let Some(quote) = rest
.chars()
.next()
.filter(|c| matches!(c, '\'' | '"' | '`'))
{
let end = string_end(&rest[1..], quote)? + 1;
list.push_str(&rest[..end]);
rest = &rest[end..];
continue;
}
if rest.starts_with("//") || rest.starts_with("/*") {
let after = skip_space(rest)?;
list.push(' ');
rest = after;
continue;
}
let character = rest.chars().next()?;
match character {
'(' | '[' | '{' | '<' => depth += 1,
')' if depth == 0 => break,
')' | ']' | '}' => depth = depth.checked_sub(1)?,
'>' if !list.ends_with('=') => depth = depth.checked_sub(1)?,
';' if depth == 0 => return None,
_ => {}
}
list.push(character);
rest = &rest[character.len_utf8()..];
}
let after = &rest[1..];
let params = split_top_level(&list);
let named = params.iter().all(|param| {
let name = param.trim_start_matches("...");
let name = name.split([':', '=']).next().unwrap_or_default().trim();
let name = name.trim_end_matches('?');
is_identifier(name) || name.starts_with('{') || name.starts_with('[')
});
named.then_some((params, after))
}
fn string_end(text: &str, quote: char) -> Option<usize> {
let mut escaped = false;
for (at, character) in text.char_indices() {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == quote {
return Some(at + 1);
}
}
None
}
fn starts_body(text: &str) -> bool {
let text = text.trim_start();
let Some(annotation) = text.strip_prefix(':') else {
return text.starts_with('{');
};
let mut depth = 0usize;
for (at, character) in annotation.char_indices() {
match character {
'{' if depth == 0 => return !annotation[..at].trim().is_empty(),
'(' | '[' | '<' | '{' => depth += 1,
')' | ']' | '}' => match depth.checked_sub(1) {
Some(next) => depth = next,
None => return false,
},
'>' if !annotation[..at].ends_with('=') => match depth.checked_sub(1) {
Some(next) => depth = next,
None => return false,
},
';' | '\n' if depth == 0 => return false,
_ => {}
}
}
false
}
fn doc_title(comment: &str) -> Option<String> {
let lines = comment
.lines()
.map(|line| line.trim().trim_start_matches('*').trim())
.collect::<Vec<_>>();
if let Some(title) = lines.iter().find_map(|line| line.strip_prefix("@title ")) {
return Some(title.trim().to_string());
}
let description = lines
.iter()
.take_while(|line| !line.starts_with('@'))
.copied()
.collect::<Vec<_>>()
.join(" ");
let sentence = description.split(". ").next()?.trim().trim_end_matches('.');
(!sentence.is_empty() && sentence.len() <= 80).then(|| sentence.to_string())
}
fn split_top_level(params: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut current = String::new();
for character in params.chars() {
match character {
'(' | '[' | '{' | '<' => depth += 1,
')' | ']' | '}' | '>' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
parts.push(std::mem::take(&mut current));
continue;
}
_ => {}
}
current.push(character);
}
parts.push(current);
parts
.into_iter()
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect()
}
fn param_name(param: &str, index: usize) -> String {
let param = param.trim_start_matches("...");
let name = param.split([':', '=']).next().unwrap_or_default().trim();
let optional = name.ends_with('?') || param.contains(" = ");
let name = name.trim_end_matches('?');
let name = if is_identifier(name) {
name.to_string()
} else if name.starts_with('{') {
"input".to_string()
} else if name.starts_with('[') {
"args".to_string()
} else {
format!("arg{}", index + 1)
};
if optional {
format!("{name}?")
} else {
name
}
}
fn member(key: &str) -> String {
let unquoted = key
.strip_prefix('\'')
.and_then(|key| key.strip_suffix('\''))
.or_else(|| key.strip_prefix('"').and_then(|key| key.strip_suffix('"')));
match unquoted {
Some(key) => format!("[{}]", serde_json::Value::String(key.to_string())),
None => format!(".{key}"),
}
}
fn import_specifier(from_dir: &Path, entry: &Path) -> String {
let relative = relative_path(from_dir, &entry.with_extension("js"));
let relative = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
if relative.starts_with("../") {
relative
} else {
format!("./{relative}")
}
}
fn relative_path(base: &Path, path: &Path) -> PathBuf {
let base = base.components().collect::<Vec<_>>();
let path_components = path.components().collect::<Vec<_>>();
let shared = base
.iter()
.zip(&path_components)
.take_while(|(left, right)| left == right)
.count();
let mut relative = PathBuf::new();
for component in &base[shared..] {
if !matches!(component, Component::CurDir) {
relative.push("..");
}
}
for component in &path_components[shared..] {
relative.push(component.as_os_str());
}
relative
}
#[cfg(test)]
mod tests {
use super::*;
fn golden(path: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(path)
}
#[test]
fn reads_the_export_and_first_list_view_of_an_installed_stack() {
let output = golden("installed-typescript/stacks/vault");
let generated = GeneratedStack::read(&output).unwrap();
assert_eq!(generated.export_name, "VAULT_STREAM_STACK");
assert_eq!(generated.entry, output.join("vault.ts"));
assert_eq!(
generated.list_view,
Some(ListView {
access: ".Vault.list".into(),
path: "Vault/list".into(),
})
);
assert_eq!(generated.reads, Vec::new());
}
#[test]
fn a_composed_stack_or_program_has_no_stack_usage() {
for output in [
"stack-names/VaultStream/typescript-composition",
"installed-typescript/programs/vault",
] {
assert_eq!(GeneratedStack::read(&golden(output)), None, "{output}");
}
}
#[test]
fn node_usage_imports_the_sdk_and_reads_the_server_key_from_the_environment() {
let output = golden("installed-typescript/stacks/vault");
let app = golden("installed-typescript");
let usage = stack_usage("vault", &output, &app, AppKind::Node).unwrap();
assert_eq!(usage.import_path, "./stacks/vault/vault.js");
assert_eq!(usage.view.as_deref(), Some("Vault/list"));
assert_eq!(usage.run.as_deref(), Some("npx tsx index.ts"));
assert_eq!(
usage.auth,
UsageAuth {
option: "secretKey",
env_var: "ARETE_API_KEY",
fallback: Some("a4-login"),
command: None,
directory: None,
}
);
assert_eq!(
usage.snippet.join("\n"),
r#"import { createSession } from "@usearete/sdk";
import { VAULT_STREAM_STACK } from "./stacks/vault/vault.js";
// No auth option: server-side, the SDK uses ARETE_API_KEY if set, else your a4 login.
const session = await createSession({ stacks: { vault: VAULT_STREAM_STACK } });
try {
const row = await session.stacks.vault.views.Vault.list.getOne({ timeoutMs: 10_000 });
console.log(row);
} finally {
session.close();
}"#
);
assert_eq!(
usage.stream.as_deref(),
Some("for await (const row of session.stacks.vault.views.Vault.list.use()) { ... }")
);
assert!(!usage.snippet.join("\n").contains("publishable"));
assert_eq!((usage.reads_call, usage.reads), (None, Vec::new()));
let usage = stack_usage("vault-v2", &output, &app, AppKind::Node).unwrap();
assert!(usage.snippet[4].contains("{ app: VAULT_STREAM_STACK }"));
assert!(usage.snippet[6].starts_with(" const row = await session.stacks.app."));
}
#[test]
fn node_usage_lists_the_stack_extension_reads() {
let temp = tempfile::tempdir().unwrap();
let output = temp.path().join("stacks/ore");
let golden = golden("installed-typescript/stacks/vault");
fs::create_dir_all(&output).unwrap();
for file in ["vault.ts", "vault-core.ts"] {
fs::copy(golden.join(file), output.join(file)).unwrap();
}
fs::write(
output.join("extensions.json"),
r#"{"entry":"ore-stack-extensions.ts","files":["ore-stack-extensions.ts"]}"#,
)
.unwrap();
fs::write(
output.join("ore-stack-extensions.ts"),
r#"export default defineStackExtensions<typeof CORE>()({
readArgCounts: {
roundState: 1,
currentRound: 0,
claimPreview: [1, 2],
quote: 1,
},
createRead(client) {
/**
* Reads the round entity for a round id.
*
* @title Round state
*/
async function roundState(roundId: bigint) {
return client.views.OreRound.state.get({ roundId });
}
/** The current round, with its phase. */
async function currentRound() {
return null;
}
async function claimPreview(
authority: Address,
bps: bigint | number = BPS_DENOMINATOR,
) {
return null;
}
return { roundState, currentRound, claimPreview, quote: (input) => input };
},
});
"#,
)
.unwrap();
let usage = stack_usage("ore", &output, temp.path(), AppKind::Node).unwrap();
assert_eq!(
usage.reads_call.as_deref(),
Some("await session.stacks.ore.read.<name>(...)")
);
let reads = usage
.reads
.iter()
.map(|read| (read.signature(), read.title.as_deref()))
.collect::<Vec<_>>();
assert_eq!(
reads,
vec![
("roundState(roundId)".to_string(), Some("Round state")),
(
"currentRound()".to_string(),
Some("The current round, with its phase")
),
("claimPreview(authority, bps?)".to_string(), None),
("quote(arg1)".to_string(), None),
]
);
let browser = stack_usage(
"ore",
&output,
temp.path(),
AppKind::Browser(Framework::Vite),
)
.unwrap();
assert_eq!(
browser.reads_call.as_deref(),
Some("arete.read.<name>.use(...)")
);
}
#[test]
fn react_usage_wraps_the_hook_in_a_provider_with_a_publishable_key() {
let output = golden("installed-typescript/stacks/vault");
let app = golden("installed-typescript/programs");
let usage = stack_usage("vault", &output, &app, AppKind::Browser(Framework::Vite)).unwrap();
assert_eq!(usage.import_path, "../stacks/vault/vault.js");
assert_eq!(usage.run, None);
assert_eq!(usage.auth.option, "publishableKey");
assert_eq!(usage.auth.env_var, "VITE_ARETE_PUBLISHABLE_KEY");
assert_eq!(
usage.auth.command.as_deref(),
Some("a4 auth keys create-publishable --origin http://localhost:5173 --env-file .env.local")
);
assert_eq!(
usage.auth.directory.as_deref(),
Some("tests/golden/installed-typescript/programs")
);
assert_eq!(
usage.snippet.join("\n"),
r#"import { AreteProvider, useArete } from "@usearete/react";
import { VAULT_STREAM_STACK } from "../stacks/vault/vault.js";
// A publishable key bound to this app's origin, created by running in tests/golden/installed-typescript/programs:
// a4 auth keys create-publishable --origin http://localhost:5173 --env-file .env.local
const publishableKey = import.meta.env.VITE_ARETE_PUBLISHABLE_KEY;
export function App() {
return (
<AreteProvider stack={VAULT_STREAM_STACK} auth={{ publishableKey }}>
<Rows />
</AreteProvider>
);
}
function Rows() {
const arete = useArete(VAULT_STREAM_STACK);
const rows = arete.views.Vault.list.use({ take: 20 });
return <pre>{JSON.stringify(rows.data, null, 2)}</pre>;
}"#
);
}
#[test]
fn next_usage_is_a_client_component_reading_the_public_env_var() {
let output = golden("installed-typescript/stacks/vault");
let app = golden("installed-typescript");
let usage =
stack_usage("vault", &output, &app, AppKind::Browser(Framework::NextJs)).unwrap();
assert_eq!(usage.snippet[0], r#""use client";"#);
assert!(usage.snippet.contains(
&"const publishableKey = process.env.NEXT_PUBLIC_ARETE_PUBLISHABLE_KEY;".to_string()
));
assert_eq!(
usage.auth.command.as_deref(),
Some("a4 auth keys create-publishable --origin http://localhost:3000 --env-file .env.local")
);
}
#[test]
fn the_key_command_runs_in_the_app_directory() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let web = root.join("apps/-web");
fs::create_dir_all(&web).unwrap();
assert_eq!(app_dir_from(root, Some(root)), None);
assert_eq!(
app_dir_from(&web, Some(root)),
Some(Path::new("apps/-web").display().to_string())
);
let elsewhere = tempfile::tempdir().unwrap();
let absolute = fs::canonicalize(elsewhere.path()).unwrap();
assert_eq!(
app_dir_from(elsewhere.path(), Some(root)),
Some(absolute.display().to_string())
);
let auth = AppKind::Browser(Framework::Vite).auth(Some("apps/-web".into()));
assert_eq!(
auth.command.as_deref(),
Some("a4 auth keys create-publishable --origin http://localhost:5173 --env-file .env.local")
);
assert_eq!(auth.directory.as_deref(), Some("apps/-web"));
let node = AppKind::Node.auth(Some("apps/-web".into()));
assert_eq!((node.command, node.directory), (None, None));
}
#[test]
fn read_counts_in_comments_strings_or_too_large_are_not_trusted() {
let extension = r#"// readArgCounts: { see below
const slashes = /[/*]/;
const quote = /'/;
const note = "readArgCounts: {";
/* currentRound(fake: string) is documented elsewhere */
export default defineStackExtensions<typeof CORE>()({
readArgCounts: {
currentRound: 0,
'round-state': 1,
example: 1000000000,
huge: 99999999999999999999999,
many: [1, 900],
},
createRead(client) {
const id = roundState(7);
async function currentRound(): Promise<{ id: bigint } | null> {
return null;
}
return { currentRound };
},
});
"#;
let reads = stack_reads(extension)
.iter()
.map(StackRead::signature)
.collect::<Vec<_>>();
assert_eq!(
reads,
vec![
"currentRound()",
"round-state(arg1)",
"example(...)",
"huge(...)",
"many(...)"
]
);
let method =
"readArgCounts: { quote: 1 },\nconst x = quote(input);\nasync quote(amount: bigint) {}";
assert_eq!(stack_reads(method)[0].signature(), "quote(amount)");
let commented = r#"readArgCounts: { /* reads */ currentRound: 0, /* accepts an address */ miner: 1, // trailing
// after the trailing comma
/* and a block */ },
async function miner(
authority: Address, // (the miner's owner, not the miner)
/* no more */
) {}"#;
let reads = stack_reads(commented)
.iter()
.map(StackRead::signature)
.collect::<Vec<_>>();
assert_eq!(reads, vec!["currentRound()", "miner(authority)"]);
assert_eq!(read_counts("a: 0, /* unterminated }"), None);
let defaults = r#"readArgCounts: { quote: 1, label: 2 },
async function quote(endpoint: string = "https://example.com") {}
async function label(text = 'a /* b ) \' c', suffix = `)`) {}"#;
let reads = stack_reads(defaults)
.iter()
.map(StackRead::signature)
.collect::<Vec<_>>();
assert_eq!(reads, vec!["quote(endpoint?)", "label(text?, suffix?)"]);
assert_eq!(read_counts("a: 1, b: x }"), None);
assert_eq!(read_counts("a: [1, 2, 3] }"), None);
assert_eq!(
read_counts(" a: 0, \"b\": [1, 2], }"),
Some(vec![("a".into(), Some(0)), ("b".into(), Some(2))])
);
}
#[test]
fn a_latest_view_is_read_before_the_first_list_view() {
let definition = "export const X_STACK_CORE = {\n views: {\n Round: {\n list: listView<Round>('Round/list'),\n latest: listView<Round>('Round/latest'),\n },\n },\n};\n";
assert_eq!(
first_list_view(definition),
Some(ListView {
access: ".Round.latest".into(),
path: "Round/latest".into(),
})
);
}
#[test]
fn view_keys_that_are_not_identifiers_use_brackets() {
let definition = "export const X_STACK_CORE = {\n views: {\n 'round-x': {\n state: stateView<A, { a: string }>('round-x/state', ['a']),\n list: listView<A>('round-x/list'),\n },\n },\n};\n";
assert_eq!(
first_list_view(definition),
Some(ListView {
access: r#"["round-x"].list"#.into(),
path: "round-x/list".into(),
})
);
assert_eq!(
first_list_view("export const X_STACK_CORE = {\n views: {\n },\n};"),
None
);
}
}