summaryrefslogtreecommitdiffstats
path: root/ext/wasm/SQLTester/SQLTester.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'ext/wasm/SQLTester/SQLTester.mjs')
-rw-r--r--ext/wasm/SQLTester/SQLTester.mjs1339
1 files changed, 1339 insertions, 0 deletions
diff --git a/ext/wasm/SQLTester/SQLTester.mjs b/ext/wasm/SQLTester/SQLTester.mjs
new file mode 100644
index 0000000..a72399a
--- /dev/null
+++ b/ext/wasm/SQLTester/SQLTester.mjs
@@ -0,0 +1,1339 @@
+/*
+** 2023-08-29
+**
+** The author disclaims copyright to this source code. In place of
+** a legal notice, here is a blessing:
+**
+** May you do good and not evil.
+** May you find forgiveness for yourself and forgive others.
+** May you share freely, never taking more than you give.
+**
+*************************************************************************
+** This file contains the main application entry pointer for the JS
+** implementation of the SQLTester framework.
+**
+** This version is not well-documented because it's a direct port of
+** the Java immplementation, which is documented: in the main SQLite3
+** source tree, see ext/jni/src/org/sqlite/jni/tester/SQLite3Tester.java.
+*/
+
+import sqlite3ApiInit from '/jswasm/sqlite3.mjs';
+
+const sqlite3 = await sqlite3ApiInit();
+
+const log = (...args)=>{
+ console.log('SQLTester:',...args);
+};
+
+/**
+ Try to install vfsName as the new default VFS. Once this succeeds
+ (returns true) then it becomes a no-op on future calls. Throws if
+ vfs registration as the default VFS fails but has no side effects
+ if vfsName is not currently registered.
+*/
+const tryInstallVfs = function f(vfsName){
+ if(f.vfsName) return false;
+ const pVfs = sqlite3.capi.sqlite3_vfs_find(vfsName);
+ if(pVfs){
+ log("Installing",'"'+vfsName+'"',"as default VFS.");
+ const rc = sqlite3.capi.sqlite3_vfs_register(pVfs, 1);
+ if(rc){
+ sqlite3.SQLite3Error.toss(rc,"While trying to register",vfsName,"vfs.");
+ }
+ f.vfsName = vfsName;
+ }
+ return !!pVfs;
+};
+tryInstallVfs.vfsName = undefined;
+
+if( 0 && globalThis.WorkerGlobalScope ){
+ // Try OPFS storage, if available...
+ if( 0 && sqlite3.oo1.OpfsDb ){
+ /* Really slow with these tests */
+ tryInstallVfs("opfs");
+ }
+ if( sqlite3.installOpfsSAHPoolVfs ){
+ await sqlite3.installOpfsSAHPoolVfs({
+ clearOnInit: true,
+ initialCapacity: 15,
+ name: 'opfs-SQLTester'
+ }).then(pool=>{
+ tryInstallVfs(pool.vfsName);
+ }).catch(e=>{
+ log("OpfsSAHPool could not load:",e);
+ });
+ }
+}
+
+const wPost = (function(){
+ return (('undefined'===typeof WorkerGlobalScope)
+ ? ()=>{}
+ : (type, payload)=>{
+ postMessage({type, payload});
+ });
+})();
+//log("WorkerGlobalScope",globalThis.WorkerGlobalScope);
+
+// Return a new enum entry value
+const newE = ()=>Object.create(null);
+
+const newObj = (props)=>Object.assign(newE(), props);
+
+/**
+ Modes for how to escape (or not) column values and names from
+ SQLTester.execSql() to the result buffer output.
+*/
+const ResultBufferMode = Object.assign(Object.create(null),{
+ //! Do not append to result buffer
+ NONE: newE(),
+ //! Append output escaped.
+ ESCAPED: newE(),
+ //! Append output as-is
+ ASIS: newE()
+});
+
+/**
+ Modes to specify how to emit multi-row output from
+ SQLTester.execSql() to the result buffer.
+*/
+const ResultRowMode = newObj({
+ //! Keep all result rows on one line, space-separated.
+ ONLINE: newE(),
+ //! Add a newline between each result row.
+ NEWLINE: newE()
+});
+
+class SQLTesterException extends globalThis.Error {
+ constructor(testScript, ...args){
+ if(testScript){
+ super( [testScript.getOutputPrefix()+": ", ...args].join('') );
+ }else{
+ super( args.join('') );
+ }
+ this.name = 'SQLTesterException';
+ }
+ isFatal() { return false; }
+}
+
+SQLTesterException.toss = (...args)=>{
+ throw new SQLTesterException(...args);
+}
+
+class DbException extends SQLTesterException {
+ constructor(testScript, pDb, rc, closeDb=false){
+ super(testScript, "DB error #"+rc+": "+sqlite3.capi.sqlite3_errmsg(pDb));
+ this.name = 'DbException';
+ if( closeDb ) sqlite3.capi.sqlite3_close_v2(pDb);
+ }
+ isFatal() { return true; }
+}
+
+class TestScriptFailed extends SQLTesterException {
+ constructor(testScript, ...args){
+ super(testScript,...args);
+ this.name = 'TestScriptFailed';
+ }
+ isFatal() { return true; }
+}
+
+class UnknownCommand extends SQLTesterException {
+ constructor(testScript, cmdName){
+ super(testScript, cmdName);
+ this.name = 'UnknownCommand';
+ }
+ isFatal() { return true; }
+}
+
+class IncompatibleDirective extends SQLTesterException {
+ constructor(testScript, ...args){
+ super(testScript,...args);
+ this.name = 'IncompatibleDirective';
+ }
+}
+
+//! For throwing where an expression is required.
+const toss = (errType, ...args)=>{
+ throw new errType(...args);
+};
+
+const __utf8Decoder = new TextDecoder();
+const __utf8Encoder = new TextEncoder('utf-8');
+//! Workaround for Util.utf8Decode()
+const __SAB = ('undefined'===typeof globalThis.SharedArrayBuffer)
+ ? function(){} : globalThis.SharedArrayBuffer;
+
+
+/* Frequently-reused regexes. */
+const Rx = newObj({
+ requiredProperties: / REQUIRED_PROPERTIES:[ \t]*(\S.*)\s*$/,
+ scriptModuleName: / SCRIPT_MODULE_NAME:[ \t]*(\S+)\s*$/,
+ mixedModuleName: / ((MIXED_)?MODULE_NAME):[ \t]*(\S+)\s*$/,
+ command: /^--(([a-z-]+)( .*)?)$/,
+ //! "Special" characters - we have to escape output if it contains any.
+ special: /[\x00-\x20\x22\x5c\x7b\x7d]/,
+ squiggly: /[{}]/
+});
+
+const Util = newObj({
+ toss,
+
+ unlink: function(fn){
+ return 0==sqlite3.wasm.sqlite3_wasm_vfs_unlink(0,fn);
+ },
+
+ argvToString: (list)=>{
+ const m = [...list];
+ m.shift() /* strip command name */;
+ return m.join(" ")
+ },
+
+ utf8Decode: function(arrayBuffer, begin, end){
+ return __utf8Decoder.decode(
+ (arrayBuffer.buffer instanceof __SAB)
+ ? arrayBuffer.slice(begin, end)
+ : arrayBuffer.subarray(begin, end)
+ );
+ },
+
+ utf8Encode: (str)=>__utf8Encoder.encode(str),
+
+ strglob: sqlite3.wasm.xWrap('sqlite3_wasm_SQLTester_strglob','int',
+ ['string','string'])
+})/*Util*/;
+
+class Outer {
+ #lnBuf = [];
+ #verbosity = 0;
+ #logger = console.log.bind(console);
+
+ constructor(func){
+ if(func) this.setFunc(func);
+ }
+
+ logger(...args){
+ if(args.length){
+ this.#logger = args[0];
+ return this;
+ }
+ return this.#logger;
+ }
+
+ out(...args){
+ if( this.getOutputPrefix && !this.#lnBuf.length ){
+ this.#lnBuf.push(this.getOutputPrefix());
+ }
+ this.#lnBuf.push(...args);
+ return this;
+ }
+
+ #outlnImpl(vLevel, ...args){
+ if( this.getOutputPrefix && !this.#lnBuf.length ){
+ this.#lnBuf.push(this.getOutputPrefix());
+ }
+ this.#lnBuf.push(...args,'\n');
+ const msg = this.#lnBuf.join('');
+ this.#lnBuf.length = 0;
+ this.#logger(msg);
+ return this;
+ }
+
+ outln(...args){
+ return this.#outlnImpl(0,...args);
+ }
+
+ outputPrefix(){
+ if( 0==arguments.length ){
+ return (this.getOutputPrefix
+ ? (this.getOutputPrefix() ?? '') : '');
+ }else{
+ this.getOutputPrefix = arguments[0];
+ return this;
+ }
+ }
+
+ static #verboseLabel = ["πŸ”ˆ",/*"πŸ”‰",*/"πŸ”Š","πŸ“’"];
+ verboseN(lvl, args){
+ if( this.#verbosity>=lvl ){
+ this.#outlnImpl(lvl, Outer.#verboseLabel[lvl-1],': ',...args);
+ }
+ }
+ verbose1(...args){ return this.verboseN(1,args); }
+ verbose2(...args){ return this.verboseN(2,args); }
+ verbose3(...args){ return this.verboseN(3,args); }
+
+ verbosity(){
+ const rc = this.#verbosity;
+ if(arguments.length) this.#verbosity = +arguments[0];
+ return rc;
+ }
+
+}/*Outer*/
+
+class SQLTester {
+
+ //! Console output utility.
+ #outer = new Outer().outputPrefix( ()=>'SQLTester: ' );
+ //! List of input scripts.
+ #aScripts = [];
+ //! Test input buffer.
+ #inputBuffer = [];
+ //! Test result buffer.
+ #resultBuffer = [];
+ //! Output representation of SQL NULL.
+ #nullView;
+ metrics = newObj({
+ //! Total tests run
+ nTotalTest: 0,
+ //! Total test script files run
+ nTestFile: 0,
+ //! Test-case count for to the current TestScript
+ nTest: 0,
+ //! Names of scripts which were aborted.
+ failedScripts: []
+ });
+ #emitColNames = false;
+ //! True to keep going regardless of how a test fails.
+ #keepGoing = false;
+ #db = newObj({
+ //! The list of available db handles.
+ list: new Array(7),
+ //! Index into this.list of the current db.
+ iCurrentDb: 0,
+ //! Name of the default db, re-created for each script.
+ initialDbName: "test.db",
+ //! Buffer for REQUIRED_PROPERTIES pragmas.
+ initSql: ['select 1;'],
+ //! (sqlite3*) to the current db.
+ currentDb: function(){
+ return this.list[this.iCurrentDb];
+ }
+ });
+
+ constructor(){
+ this.reset();
+ }
+
+ outln(...args){ return this.#outer.outln(...args); }
+ out(...args){ return this.#outer.out(...args); }
+ outer(...args){
+ if(args.length){
+ this.#outer = args[0];
+ return this;
+ }
+ return this.#outer;
+ }
+ verbose1(...args){ return this.#outer.verboseN(1,args); }
+ verbose2(...args){ return this.#outer.verboseN(2,args); }
+ verbose3(...args){ return this.#outer.verboseN(3,args); }
+ verbosity(...args){
+ const rc = this.#outer.verbosity(...args);
+ return args.length ? this : rc;
+ }
+ setLogger(func){
+ this.#outer.logger(func);
+ return this;
+ }
+
+ incrementTestCounter(){
+ ++this.metrics.nTotalTest;
+ ++this.metrics.nTest;
+ }
+
+ reset(){
+ this.clearInputBuffer();
+ this.clearResultBuffer();
+ this.#clearBuffer(this.#db.initSql);
+ this.closeAllDbs();
+ this.metrics.nTest = 0;
+ this.#nullView = "nil";
+ this.emitColNames = false;
+ this.#db.iCurrentDb = 0;
+ //this.#db.initSql.push("SELECT 1;");
+ }
+
+ appendInput(line, addNL){
+ this.#inputBuffer.push(line);
+ if( addNL ) this.#inputBuffer.push('\n');
+ }
+ appendResult(line, addNL){
+ this.#resultBuffer.push(line);
+ if( addNL ) this.#resultBuffer.push('\n');
+ }
+ appendDbInitSql(sql){
+ this.#db.initSql.push(sql);
+ if( this.currentDb() ){
+ this.execSql(null, true, ResultBufferMode.NONE, null, sql);
+ }
+ }
+
+ #runInitSql(pDb){
+ let rc = 0;
+ for(const sql of this.#db.initSql){
+ this.#outer.verbose2("RUNNING DB INIT CODE: ",sql);
+ rc = this.execSql(pDb, false, ResultBufferMode.NONE, null, sql);
+ if( rc ) break;
+ }
+ return rc;
+ }
+
+#clearBuffer(buffer){
+ buffer.length = 0;
+ return buffer;
+ }
+
+ clearInputBuffer(){ return this.#clearBuffer(this.#inputBuffer); }
+ clearResultBuffer(){return this.#clearBuffer(this.#resultBuffer); }
+
+ getInputText(){ return this.#inputBuffer.join(''); }
+ getResultText(){ return this.#resultBuffer.join(''); }
+
+ #takeBuffer(buffer){
+ const s = buffer.join('');
+ buffer.length = 0;
+ return s;
+ }
+
+ takeInputBuffer(){
+ return this.#takeBuffer(this.#inputBuffer);
+ }
+ takeResultBuffer(){
+ return this.#takeBuffer(this.#resultBuffer);
+ }
+
+ nullValue(){
+ return (0==arguments.length)
+ ? this.#nullView
+ : (this.#nullView = ''+arguments[0]);
+ }
+
+ outputColumnNames(){
+ return (0==arguments.length)
+ ? this.#emitColNames
+ : (this.#emitColNames = !!arguments[0]);
+ }
+
+ currentDbId(){
+ return (0==arguments.length)
+ ? this.#db.iCurrentDb
+ : (this.#affirmDbId(arguments[0]).#db.iCurrentDb = arguments[0]);
+ }
+
+ #affirmDbId(id){
+ if(id<0 || id>=this.#db.list.length){
+ toss(SQLTesterException, "Database index ",id," is out of range.");
+ }
+ return this;
+ }
+
+ currentDb(...args){
+ if( 0!=args.length ){
+ this.#affirmDbId(id).#db.iCurrentDb = id;
+ }
+ return this.#db.currentDb();
+ }
+
+ getDbById(id){
+ return this.#affirmDbId(id).#db.list[id];
+ }
+
+ getCurrentDb(){ return this.#db.list[this.#db.iCurrentDb]; }
+
+
+ closeDb(id) {
+ if( 0==arguments.length ){
+ id = this.#db.iCurrentDb;
+ }
+ const pDb = this.#affirmDbId(id).#db.list[id];
+ if( pDb ){
+ sqlite3.capi.sqlite3_close_v2(pDb);
+ this.#db.list[id] = null;
+ }
+ }
+
+ closeAllDbs(){
+ for(let i = 0; i<this.#db.list.length; ++i){
+ if(this.#db.list[i]){
+ sqlite3.capi.sqlite3_close_v2(this.#db.list[i]);
+ this.#db.list[i] = null;
+ }
+ }
+ this.#db.iCurrentDb = 0;
+ }
+
+ openDb(name, createIfNeeded){
+ if( 3===arguments.length ){
+ const slot = arguments[0];
+ this.#affirmDbId(slot).#db.iCurrentDb = slot;
+ name = arguments[1];
+ createIfNeeded = arguments[2];
+ }
+ this.closeDb();
+ const capi = sqlite3.capi, wasm = sqlite3.wasm;
+ let pDb = 0;
+ let flags = capi.SQLITE_OPEN_READWRITE;
+ if( createIfNeeded ) flags |= capi.SQLITE_OPEN_CREATE;
+ try{
+ let rc;
+ wasm.pstack.call(function(){
+ let ppOut = wasm.pstack.allocPtr();
+ rc = sqlite3.capi.sqlite3_open_v2(name, ppOut, flags, null);
+ pDb = wasm.peekPtr(ppOut);
+ });
+ let sql;
+ if( 0==rc && this.#db.initSql.length > 0){
+ rc = this.#runInitSql(pDb);
+ }
+ if( 0!=rc ){
+ sqlite3.SQLite3Error.toss(
+ rc,
+ "sqlite3 result code",rc+":",
+ (pDb ? sqlite3.capi.sqlite3_errmsg(pDb)
+ : sqlite3.capi.sqlite3_errstr(rc))
+ );
+ }
+ return this.#db.list[this.#db.iCurrentDb] = pDb;
+ }catch(e){
+ sqlite3.capi.sqlite3_close_v2(pDb);
+ throw e;
+ }
+ }
+
+ addTestScript(ts){
+ if( 2===arguments.length ){
+ ts = new TestScript(arguments[0], arguments[1]);
+ }else if(ts instanceof Uint8Array){
+ ts = new TestScript('<unnamed>', ts);
+ }else if('string' === typeof arguments[1]){
+ ts = new TestScript('<unnamed>', Util.utf8Encode(arguments[1]));
+ }
+ if( !(ts instanceof TestScript) ){
+ Util.toss(SQLTesterException, "Invalid argument type for addTestScript()");
+ }
+ this.#aScripts.push(ts);
+ return this;
+ }
+
+ runTests(){
+ const tStart = (new Date()).getTime();
+ let isVerbose = this.verbosity();
+ this.metrics.failedScripts.length = 0;
+ this.metrics.nTotalTest = 0;
+ this.metrics.nTestFile = 0;
+ for(const ts of this.#aScripts){
+ this.reset();
+ ++this.metrics.nTestFile;
+ let threw = false;
+ const timeStart = (new Date()).getTime();
+ let msgTail = '';
+ try{
+ ts.run(this);
+ }catch(e){
+ if(e instanceof SQLTesterException){
+ threw = true;
+ this.outln("πŸ”₯EXCEPTION: ",e);
+ this.metrics.failedScripts.push({script: ts.filename(), message:e.toString()});
+ if( this.#keepGoing ){
+ this.outln("Continuing anyway because of the keep-going option.");
+ }else if( e.isFatal() ){
+ throw e;
+ }
+ }else{
+ throw e;
+ }
+ }finally{
+ const timeEnd = (new Date()).getTime();
+ this.out("🏁", (threw ? "❌" : "βœ…"), " ",
+ this.metrics.nTest, " test(s) in ",
+ (timeEnd-timeStart),"ms. ");
+ const mod = ts.moduleName();
+ if( mod ){
+ this.out( "[",mod,"] " );
+ }
+ this.outln(ts.filename());
+ }
+ }
+ const tEnd = (new Date()).getTime();
+ Util.unlink(this.#db.initialDbName);
+ this.outln("Took ",(tEnd-tStart),"ms. Test count = ",
+ this.metrics.nTotalTest,", script count = ",
+ this.#aScripts.length,(
+ this.metrics.failedScripts.length
+ ? ", failed scripts = "+this.metrics.failedScripts.length
+ : ""
+ )
+ );
+ return this;
+ }
+
+ #setupInitialDb(){
+ if( !this.#db.list[0] ){
+ Util.unlink(this.#db.initialDbName);
+ this.openDb(0, this.#db.initialDbName, true);
+ }else{
+ this.#outer.outln("WARNING: setupInitialDb() was unexpectedly ",
+ "triggered while it is opened.");
+ }
+ }
+
+ #escapeSqlValue(v){
+ if( !v ) return "{}";
+ if( !Rx.special.test(v) ){
+ return v /* no escaping needed */;
+ }
+ if( !Rx.squiggly.test(v) ){
+ return "{"+v+"}";
+ }
+ const sb = ["\""];
+ const n = v.length;
+ for(let i = 0; i < n; ++i){
+ const ch = v.charAt(i);
+ switch(ch){
+ case '\\': sb.push("\\\\"); break;
+ case '"': sb.push("\\\""); break;
+ default:{
+ //verbose("CHAR ",(int)ch," ",ch," octal=",String.format("\\%03o", (int)ch));
+ const ccode = ch.charCodeAt(i);
+ if( ccode < 32 ) sb.push('\\',ccode.toString(8),'o');
+ else sb.push(ch);
+ break;
+ }
+ }
+ }
+ sb.append("\"");
+ return sb.join('');
+ }
+
+ #appendDbErr(pDb, sb, rc){
+ sb.push(sqlite3.capi.sqlite3_js_rc_str(rc), ' ');
+ const msg = this.#escapeSqlValue(sqlite3.capi.sqlite3_errmsg(pDb));
+ if( '{' === msg.charAt(0) ){
+ sb.push(msg);
+ }else{
+ sb.push('{', msg, '}');
+ }
+ }
+
+ #checkDbRc(pDb,rc){
+ sqlite3.oo1.DB.checkRc(pDb, rc);
+ }
+
+ execSql(pDb, throwOnError, appendMode, rowMode, sql){
+ if( !pDb && !this.#db.list[0] ){
+ this.#setupInitialDb();
+ }
+ if( !pDb ) pDb = this.#db.currentDb();
+ const wasm = sqlite3.wasm, capi = sqlite3.capi;
+ sql = (sql instanceof Uint8Array)
+ ? sql
+ : Util.utf8Encode(capi.sqlite3_js_sql_to_string(sql));
+ const self = this;
+ const sb = (ResultBufferMode.NONE===appendMode) ? null : this.#resultBuffer;
+ let rc = 0;
+ wasm.scopedAllocCall(function(){
+ let sqlByteLen = sql.byteLength;
+ const ppStmt = wasm.scopedAlloc(
+ /* output (sqlite3_stmt**) arg and pzTail */
+ (2 * wasm.ptrSizeof) + (sqlByteLen + 1/* SQL + NUL */)
+ );
+ const pzTail = ppStmt + wasm.ptrSizeof /* final arg to sqlite3_prepare_v2() */;
+ let pSql = pzTail + wasm.ptrSizeof;
+ const pSqlEnd = pSql + sqlByteLen;
+ wasm.heap8().set(sql, pSql);
+ wasm.poke8(pSql + sqlByteLen, 0/*NUL terminator*/);
+ let pos = 0, n = 1, spacing = 0;
+ while( pSql && wasm.peek8(pSql) ){
+ wasm.pokePtr([ppStmt, pzTail], 0);
+ rc = capi.sqlite3_prepare_v3(
+ pDb, pSql, sqlByteLen, 0, ppStmt, pzTail
+ );
+ if( 0!==rc ){
+ if(throwOnError){
+ throw new DbException(self, pDb, rc);
+ }else if( sb ){
+ self.#appendDbErr(pDb, sb, rc);
+ }
+ break;
+ }
+ const pStmt = wasm.peekPtr(ppStmt);
+ pSql = wasm.peekPtr(pzTail);
+ sqlByteLen = pSqlEnd - pSql;
+ if(!pStmt) continue /* only whitespace or comments */;
+ if( sb ){
+ const nCol = capi.sqlite3_column_count(pStmt);
+ let colName, val;
+ while( capi.SQLITE_ROW === (rc = capi.sqlite3_step(pStmt)) ) {
+ for( let i=0; i < nCol; ++i ){
+ if( spacing++ > 0 ) sb.push(' ');
+ if( self.#emitColNames ){
+ colName = capi.sqlite3_column_name(pStmt, i);
+ switch(appendMode){
+ case ResultBufferMode.ASIS: sb.push( colName ); break;
+ case ResultBufferMode.ESCAPED:
+ sb.push( self.#escapeSqlValue(colName) );
+ break;
+ default:
+ self.toss("Unhandled ResultBufferMode.");
+ }
+ sb.push(' ');
+ }
+ val = capi.sqlite3_column_text(pStmt, i);
+ if( null===val ){
+ sb.push( self.#nullView );
+ continue;
+ }
+ switch(appendMode){
+ case ResultBufferMode.ASIS: sb.push( val ); break;
+ case ResultBufferMode.ESCAPED:
+ sb.push( self.#escapeSqlValue(val) );
+ break;
+ }
+ }/* column loop */
+ }/* row loop */
+ if( ResultRowMode.NEWLINE === rowMode ){
+ spacing = 0;
+ sb.push('\n');
+ }
+ }else{ // no output but possibly other side effects
+ while( capi.SQLITE_ROW === (rc = capi.sqlite3_step(pStmt)) ) {}
+ }
+ capi.sqlite3_finalize(pStmt);
+ if( capi.SQLITE_ROW===rc || capi.SQLITE_DONE===rc) rc = 0;
+ else if( rc!=0 ){
+ if( sb ){
+ self.#appendDbErr(db, sb, rc);
+ }
+ break;
+ }
+ }/* SQL script loop */;
+ })/*scopedAllocCall()*/;
+ return rc;
+ }
+
+}/*SQLTester*/
+
+class Command {
+ constructor(){
+ }
+
+ process(sqlTester,testScript,argv){
+ SQLTesterException.toss("process() must be overridden");
+ }
+
+ argcCheck(testScript,argv,min,max){
+ const argc = argv.length-1;
+ if(argc<min || (max>=0 && argc>max)){
+ if( min==max ){
+ testScript.toss(argv[0]," requires exactly ",min," argument(s)");
+ }else if(max>0){
+ testScript.toss(argv[0]," requires ",min,"-",max," arguments.");
+ }else{
+ testScript.toss(argv[0]," requires at least ",min," arguments.");
+ }
+ }
+ }
+}
+
+class Cursor {
+ src;
+ sb = [];
+ pos = 0;
+ //! Current line number. Starts at 0 for internal reasons and will
+ // line up with 1-based reality once parsing starts.
+ lineNo = 0 /* yes, zero */;
+ //! Putback value for this.pos.
+ putbackPos = 0;
+ //! Putback line number
+ putbackLineNo = 0;
+ //! Peeked-to pos, used by peekLine() and consumePeeked().
+ peekedPos = 0;
+ //! Peeked-to line number.
+ peekedLineNo = 0;
+
+ constructor(){
+ }
+
+ //! Restore parsing state to the start of the stream.
+ rewind(){
+ this.sb.length = this.pos = this.lineNo
+ = this.putbackPos = this.putbackLineNo
+ = this.peekedPos = this.peekedLineNo = 0;
+ }
+}
+
+class TestScript {
+ #cursor = new Cursor();
+ #moduleName = null;
+ #filename = null;
+ #testCaseName = null;
+ #outer = new Outer().outputPrefix( ()=>this.getOutputPrefix()+': ' );
+
+ constructor(...args){
+ let content, filename;
+ if( 2 == args.length ){
+ filename = args[0];
+ content = args[1];
+ }else if( 1 == args.length ){
+ if(args[0] instanceof Object){
+ const o = args[0];
+ filename = o.name;
+ content = o.content;
+ }else{
+ content = args[0];
+ }
+ }
+ if(!(content instanceof Uint8Array)){
+ if('string' === typeof content){
+ content = Util.utf8Encode(content);
+ }else if((content instanceof ArrayBuffer)
+ ||(content instanceof Array)){
+ content = new Uint8Array(content);
+ }else{
+ toss(Error, "Invalid content type for TestScript constructor.");
+ }
+ }
+ this.#filename = filename;
+ this.#cursor.src = content;
+ }
+
+ moduleName(){
+ return (0==arguments.length)
+ ? this.#moduleName : (this.#moduleName = arguments[0]);
+ }
+
+ testCaseName(){
+ return (0==arguments.length)
+ ? this.#testCaseName : (this.#testCaseName = arguments[0]);
+ }
+ filename(){
+ return (0==arguments.length)
+ ? this.#filename : (this.#filename = arguments[0]);
+ }
+
+ getOutputPrefix() {
+ let rc = "["+(this.#moduleName || '<unnamed>')+"]";
+ if( this.#testCaseName ) rc += "["+this.#testCaseName+"]";
+ if( this.#filename ) rc += '['+this.#filename+']';
+ return rc + " line "+ this.#cursor.lineNo;
+ }
+
+ reset(){
+ this.#testCaseName = null;
+ this.#cursor.rewind();
+ return this;
+ }
+
+ toss(...args){
+ throw new TestScriptFailed(this,...args);
+ }
+
+ verbose1(...args){ return this.#outer.verboseN(1,args); }
+ verbose2(...args){ return this.#outer.verboseN(2,args); }
+ verbose3(...args){ return this.#outer.verboseN(3,args); }
+ verbosity(...args){
+ const rc = this.#outer.verbosity(...args);
+ return args.length ? this : rc;
+ }
+
+ #checkRequiredProperties(tester, props){
+ if(true) return false;
+ let nOk = 0;
+ for(const rp of props){
+ this.verbose2("REQUIRED_PROPERTIES: ",rp);
+ switch(rp){
+ case "RECURSIVE_TRIGGERS":
+ tester.appendDbInitSql("pragma recursive_triggers=on;");
+ ++nOk;
+ break;
+ case "TEMPSTORE_FILE":
+ /* This _assumes_ that the lib is built with SQLITE_TEMP_STORE=1 or 2,
+ which we just happen to know is the case */
+ tester.appendDbInitSql("pragma temp_store=1;");
+ ++nOk;
+ break;
+ case "TEMPSTORE_MEM":
+ /* This _assumes_ that the lib is built with SQLITE_TEMP_STORE=1 or 2,
+ which we just happen to know is the case */
+ tester.appendDbInitSql("pragma temp_store=0;");
+ ++nOk;
+ break;
+ case "AUTOVACUUM":
+ tester.appendDbInitSql("pragma auto_vacuum=full;");
+ ++nOk;
+ break;
+ case "INCRVACUUM":
+ tester.appendDbInitSql("pragma auto_vacuum=incremental;");
+ ++nOk;
+ default:
+ break;
+ }
+ }
+ return props.length == nOk;
+ }
+
+ #checkForDirective(tester,line){
+ if(line.startsWith("#")){
+ throw new IncompatibleDirective(this, "C-preprocessor input: "+line);
+ }else if(line.startsWith("---")){
+ throw new IncompatibleDirective(this, "triple-dash: ",line);
+ }
+ let m = Rx.scriptModuleName.exec(line);
+ if( m ){
+ this.#moduleName = m[1];
+ return;
+ }
+ m = Rx.requiredProperties.exec(line);
+ if( m ){
+ const rp = m[1];
+ if( !this.#checkRequiredProperties( tester, rp.split(/\s+/).filter(v=>!!v) ) ){
+ throw new IncompatibleDirective(this, "REQUIRED_PROPERTIES: "+rp);
+ }
+ }
+
+ m = Rx.mixedModuleName.exec(line);
+ if( m ){
+ throw new IncompatibleDirective(this, m[1]+": "+m[3]);
+ }
+ if( line.indexOf("\n|")>=0 ){
+ throw new IncompatibleDirective(this, "newline-pipe combination.");
+ }
+
+ }
+
+ #getCommandArgv(line){
+ const m = Rx.command.exec(line);
+ return m ? m[1].trim().split(/\s+/) : null;
+ }
+
+
+ #isCommandLine(line, checkForImpl){
+ let m = Rx.command.exec(line);
+ if( m && checkForImpl ){
+ m = !!CommandDispatcher.getCommandByName(m[2]);
+ }
+ return !!m;
+ }
+
+ fetchCommandBody(tester){
+ const sb = [];
+ let line;
+ while( (null !== (line = this.peekLine())) ){
+ this.#checkForDirective(tester, line);
+ if( this.#isCommandLine(line, true) ) break;
+ sb.push(line,"\n");
+ this.consumePeeked();
+ }
+ line = sb.join('');
+ return !!line.trim() ? line : null;
+ }
+
+ run(tester){
+ this.reset();
+ this.#outer.verbosity( tester.verbosity() );
+ this.#outer.logger( tester.outer().logger() );
+ let line, directive, argv = [];
+ while( null != (line = this.getLine()) ){
+ this.verbose3("run() input line: ",line);
+ this.#checkForDirective(tester, line);
+ argv = this.#getCommandArgv(line);
+ if( argv ){
+ this.#processCommand(tester, argv);
+ continue;
+ }
+ tester.appendInput(line,true);
+ }
+ return true;
+ }
+
+ #processCommand(tester, argv){
+ this.verbose2("processCommand(): ",argv[0], " ", Util.argvToString(argv));
+ if(this.#outer.verbosity()>1){
+ const input = tester.getInputText();
+ this.verbose3("processCommand() input buffer = ",input);
+ }
+ CommandDispatcher.dispatch(tester, this, argv);
+ }
+
+ getLine(){
+ const cur = this.#cursor;
+ if( cur.pos==cur.src.byteLength ){
+ return null/*EOF*/;
+ }
+ cur.putbackPos = cur.pos;
+ cur.putbackLineNo = cur.lineNo;
+ cur.sb.length = 0;
+ let b = 0, prevB = 0, i = cur.pos;
+ let doBreak = false;
+ let nChar = 0 /* number of bytes in the aChar char */;
+ const end = cur.src.byteLength;
+ for(; i < end && !doBreak; ++i){
+ b = cur.src[i];
+ switch( b ){
+ case 13/*CR*/: continue;
+ case 10/*NL*/:
+ ++cur.lineNo;
+ if(cur.sb.length>0) doBreak = true;
+ // Else it's an empty string
+ break;
+ default:{
+ /* Multi-byte chars need to be gathered up and appended at
+ one time so that we can get them as string objects. */
+ nChar = 1;
+ switch( b & 0xF0 ){
+ case 0xC0: nChar = 2; break;
+ case 0xE0: nChar = 3; break;
+ case 0xF0: nChar = 4; break;
+ default:
+ if( b > 127 ) this.toss("Invalid character (#"+b+").");
+ break;
+ }
+ if( 1==nChar ){
+ cur.sb.push(String.fromCharCode(b));
+ }else{
+ const aChar = [] /* multi-byte char buffer */;
+ for(let x = 0; (x < nChar) && (i+x < end); ++x) aChar[x] = cur.src[i+x];
+ cur.sb.push(
+ Util.utf8Decode( new Uint8Array(aChar) )
+ );
+ i += nChar-1;
+ }
+ break;
+ }
+ }
+ }
+ cur.pos = i;
+ const rv = cur.sb.join('');
+ if( i==cur.src.byteLength && 0==rv.length ){
+ return null /* EOF */;
+ }
+ return rv;
+ }/*getLine()*/
+
+ /**
+ Fetches the next line then resets the cursor to its pre-call
+ state. consumePeeked() can be used to consume this peeked line
+ without having to re-parse it.
+ */
+ peekLine(){
+ const cur = this.#cursor;
+ const oldPos = cur.pos;
+ const oldPB = cur.putbackPos;
+ const oldPBL = cur.putbackLineNo;
+ const oldLine = cur.lineNo;
+ try {
+ return this.getLine();
+ }finally{
+ cur.peekedPos = cur.pos;
+ cur.peekedLineNo = cur.lineNo;
+ cur.pos = oldPos;
+ cur.lineNo = oldLine;
+ cur.putbackPos = oldPB;
+ cur.putbackLineNo = oldPBL;
+ }
+ }
+
+
+ /**
+ Only valid after calling peekLine() and before calling getLine().
+ This places the cursor to the position it would have been at had
+ the peekLine() had been fetched with getLine().
+ */
+ consumePeeked(){
+ const cur = this.#cursor;
+ cur.pos = cur.peekedPos;
+ cur.lineNo = cur.peekedLineNo;
+ }
+
+ /**
+ Restores the cursor to the position it had before the previous
+ call to getLine().
+ */
+ putbackLine(){
+ const cur = this.#cursor;
+ cur.pos = cur.putbackPos;
+ cur.lineNo = cur.putbackLineNo;
+ }
+
+}/*TestScript*/;
+
+//! --close command
+class CloseDbCommand extends Command {
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,0,1);
+ let id;
+ if(argv.length>1){
+ const arg = argv[1];
+ if( "all" === arg ){
+ t.closeAllDbs();
+ return;
+ }
+ else{
+ id = parseInt(arg);
+ }
+ }else{
+ id = t.currentDbId();
+ }
+ t.closeDb(id);
+ }
+}
+
+//! --column-names command
+class ColumnNamesCommand extends Command {
+ process( st, ts, argv ){
+ this.argcCheck(ts,argv,1);
+ st.outputColumnNames( !!parseInt(argv[1]) );
+ }
+}
+
+//! --db command
+class DbCommand extends Command {
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,1);
+ t.currentDbId( parseInt(argv[1]) );
+ }
+}
+
+//! --glob command
+class GlobCommand extends Command {
+ #negate = false;
+ constructor(negate=false){
+ super();
+ this.#negate = negate;
+ }
+
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,1,-1);
+ t.incrementTestCounter();
+ const sql = t.takeInputBuffer();
+ let rc = t.execSql(null, true, ResultBufferMode.ESCAPED,
+ ResultRowMode.ONELINE, sql);
+ const result = t.getResultText();
+ const sArgs = Util.argvToString(argv);
+ //t2.verbose2(argv[0]," rc = ",rc," result buffer:\n", result,"\nargs:\n",sArgs);
+ const glob = Util.argvToString(argv);
+ rc = Util.strglob(glob, result);
+ if( (this.#negate && 0===rc) || (!this.#negate && 0!==rc) ){
+ ts.toss(argv[0], " mismatch: ", glob," vs input: ",result);
+ }
+ }
+}
+
+//! --notglob command
+class NotGlobCommand extends GlobCommand {
+ constructor(){super(true);}
+}
+
+//! --open command
+class OpenDbCommand extends Command {
+ #createIfNeeded = false;
+ constructor(createIfNeeded=false){
+ super();
+ this.#createIfNeeded = createIfNeeded;
+ }
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,1);
+ t.openDb(argv[1], this.#createIfNeeded);
+ }
+}
+
+//! --new command
+class NewDbCommand extends OpenDbCommand {
+ constructor(){ super(true); }
+}
+
+//! Placeholder dummy/no-op commands
+class NoopCommand extends Command {
+ process(t, ts, argv){}
+}
+
+//! --null command
+class NullCommand extends Command {
+ process(st, ts, argv){
+ this.argcCheck(ts,argv,1);
+ st.nullValue( argv[1] );
+ }
+}
+
+//! --print command
+class PrintCommand extends Command {
+ process(st, ts, argv){
+ st.out(ts.getOutputPrefix(),': ');
+ if( 1==argv.length ){
+ st.out( st.getInputText() );
+ }else{
+ st.outln( Util.argvToString(argv) );
+ }
+ }
+}
+
+//! --result command
+class ResultCommand extends Command {
+ #bufferMode;
+ constructor(resultBufferMode = ResultBufferMode.ESCAPED){
+ super();
+ this.#bufferMode = resultBufferMode;
+ }
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,0,-1);
+ t.incrementTestCounter();
+ const sql = t.takeInputBuffer();
+ //ts.verbose2(argv[0]," SQL =\n",sql);
+ t.execSql(null, false, this.#bufferMode, ResultRowMode.ONELINE, sql);
+ const result = t.getResultText().trim();
+ const sArgs = argv.length>1 ? Util.argvToString(argv) : "";
+ if( result !== sArgs ){
+ t.outln(argv[0]," FAILED comparison. Result buffer:\n",
+ result,"\nExpected result:\n",sArgs);
+ ts.toss(argv[0]+" comparison failed.");
+ }
+ }
+}
+
+//! --json command
+class JsonCommand extends ResultCommand {
+ constructor(){ super(ResultBufferMode.ASIS); }
+}
+
+//! --run command
+class RunCommand extends Command {
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,0,1);
+ const pDb = (1==argv.length)
+ ? t.currentDb() : t.getDbById( parseInt(argv[1]) );
+ const sql = t.takeInputBuffer();
+ const rc = t.execSql(pDb, false, ResultBufferMode.NONE,
+ ResultRowMode.ONELINE, sql);
+ if( 0!==rc && t.verbosity()>0 ){
+ const msg = sqlite3.capi.sqlite3_errmsg(pDb);
+ ts.verbose2(argv[0]," non-fatal command error #",rc,": ",
+ msg,"\nfor SQL:\n",sql);
+ }
+ }
+}
+
+//! --tableresult command
+class TableResultCommand extends Command {
+ #jsonMode;
+ constructor(jsonMode=false){
+ super();
+ this.#jsonMode = jsonMode;
+ }
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,0);
+ t.incrementTestCounter();
+ let body = ts.fetchCommandBody(t);
+ if( null===body ) ts.toss("Missing ",argv[0]," body.");
+ body = body.trim();
+ if( !body.endsWith("\n--end") ){
+ ts.toss(argv[0], " must be terminated with --end\\n");
+ }else{
+ body = body.substring(0, body.length-6);
+ }
+ const globs = body.split(/\s*\n\s*/);
+ if( globs.length < 1 ){
+ ts.toss(argv[0], " requires 1 or more ",
+ (this.#jsonMode ? "json snippets" : "globs"),".");
+ }
+ const sql = t.takeInputBuffer();
+ t.execSql(null, true,
+ this.#jsonMode ? ResultBufferMode.ASIS : ResultBufferMode.ESCAPED,
+ ResultRowMode.NEWLINE, sql);
+ const rbuf = t.getResultText().trim();
+ const res = rbuf.split(/\r?\n/);
+ if( res.length !== globs.length ){
+ ts.toss(argv[0], " failure: input has ", res.length,
+ " row(s) but expecting ",globs.length);
+ }
+ for(let i = 0; i < res.length; ++i){
+ const glob = globs[i].replaceAll(/\s+/g," ").trim();
+ //ts.verbose2(argv[0]," <<",glob,">> vs <<",res[i],">>");
+ if( this.#jsonMode ){
+ if( glob!==res[i] ){
+ ts.toss(argv[0], " json <<",glob, ">> does not match: <<",
+ res[i],">>");
+ }
+ }else if( 0!=Util.strglob(glob, res[i]) ){
+ ts.toss(argv[0], " glob <<",glob,">> does not match: <<",res[i],">>");
+ }
+ }
+ }
+}
+
+//! --json-block command
+class JsonBlockCommand extends TableResultCommand {
+ constructor(){ super(true); }
+}
+
+//! --testcase command
+class TestCaseCommand extends Command {
+ process(tester, script, argv){
+ this.argcCheck(script, argv,1);
+ script.testCaseName(argv[1]);
+ tester.clearResultBuffer();
+ tester.clearInputBuffer();
+ }
+}
+
+
+//! --verbosity command
+class VerbosityCommand extends Command {
+ process(t, ts, argv){
+ this.argcCheck(ts,argv,1);
+ ts.verbosity( parseInt(argv[1]) );
+ }
+}
+
+class CommandDispatcher {
+ static map = newObj();
+
+ static getCommandByName(name){
+ let rv = CommandDispatcher.map[name];
+ if( rv ) return rv;
+ switch(name){
+ case "close": rv = new CloseDbCommand(); break;
+ case "column-names": rv = new ColumnNamesCommand(); break;
+ case "db": rv = new DbCommand(); break;
+ case "glob": rv = new GlobCommand(); break;
+ case "json": rv = new JsonCommand(); break;
+ case "json-block": rv = new JsonBlockCommand(); break;
+ case "new": rv = new NewDbCommand(); break;
+ case "notglob": rv = new NotGlobCommand(); break;
+ case "null": rv = new NullCommand(); break;
+ case "oom": rv = new NoopCommand(); break;
+ case "open": rv = new OpenDbCommand(); break;
+ case "print": rv = new PrintCommand(); break;
+ case "result": rv = new ResultCommand(); break;
+ case "run": rv = new RunCommand(); break;
+ case "tableresult": rv = new TableResultCommand(); break;
+ case "testcase": rv = new TestCaseCommand(); break;
+ case "verbosity": rv = new VerbosityCommand(); break;
+ }
+ if( rv ){
+ CommandDispatcher.map[name] = rv;
+ }
+ return rv;
+ }
+
+ static dispatch(tester, testScript, argv){
+ const cmd = CommandDispatcher.getCommandByName(argv[0]);
+ if( !cmd ){
+ toss(UnknownCommand,testScript,argv[0]);
+ }
+ cmd.process(tester, testScript, argv);
+ }
+}/*CommandDispatcher*/
+
+const namespace = newObj({
+ Command,
+ DbException,
+ IncompatibleDirective,
+ Outer,
+ SQLTester,
+ SQLTesterException,
+ TestScript,
+ TestScriptFailed,
+ UnknownCommand,
+ Util,
+ sqlite3
+});
+
+export {namespace as default};