1#!/usr/bin/env ruby 2 3# Copyright (c) 2024 Huawei Device Co., Ltd. 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16pure_binding = TOPLEVEL_BINDING.dup 17 18require 'yaml' 19require 'erb' 20require 'json' 21require 'ostruct' 22require 'open3' 23require 'fileutils' 24require 'optparse' 25require 'set' 26require 'etc' 27 28require_relative 'src/types' 29require_relative 'src/value_dumper' 30 31$options = { 32 :'chunk-size' => 200, 33 :proc => [Etc.nprocessors, 1].max, 34 :'ts-node' => ['npx', 'ts-node'], 35 :'filter' => /.*/ 36} 37 38module EXIT_CODES 39 OK = true 40 INVALID_OPTIONS = false 41 INVALID_NODE_VERSION = false 42 TESTS_FAILED = false 43end 44 45def print_help 46 puts $optparse 47 puts 'NOTE: this program requires node version 21.4, you can use `n` tool to install it' 48end 49 50$optparse = OptionParser.new do |opts| 51 opts.banner = 'Usage: test-generator [options] --out=DIR --tmp=DIR confs...' 52 opts.on '--run-ets=PANDA', 'used to instantly run es2panda&ark on generated file, PANDA is a path to panda build directory' 53 opts.on '--out=DIR', String, 'path to .sts files output directory' 54 opts.on '--tmp=DIR', String, 'path to temporary directory (where to output .ts and .json files)' 55 opts.on '--chunk-size=NUM', Integer, 'amout of tests in a single file' 56 opts.on '--proc=PROC', Integer, 'number of ruby threads to use, defaults to max available' do |v| 57 $options[:proc] = [v, 1].max 58 end 59 opts.on '--ts-node=PATH', String, 'colon (:) separated list of prefix arguments to run ts-node, defaults to npx:ts-node' do |v| 60 $options[:'ts-node'] = v.split(':') 61 end 62 opts.on '--filter=REGEXP', Regexp, 'test name filter; name consists of categories and method/expr (escaped) strings' 63 opts.on '--help' do 64 print_help 65 exit EXIT_CODES::OK 66 end 67end 68$optparse.parse!(into: $options) 69 70def check_opt(name) 71 if $options[name].nil? 72 puts "--#{name} is not provided" 73 puts $optparse 74 exit EXIT_CODES::INVALID_OPTIONS 75 end 76end 77 78check_opt :out 79check_opt :tmp 80 81FileUtils.rm_rf $options[:out] 82FileUtils.mkdir_p $options[:out] 83FileUtils.mkdir_p $options[:tmp] 84 85$user_binding = pure_binding.dup 86eval("require '#{__dir__}/src/script_module'", $user_binding) 87ScriptClass = Class.new.extend(eval('ScriptModule', $user_binding)) 88 89$template_ts = ERB.new File.read("#{__dir__}/templates/template.ts.erb"), nil, '%' 90$template_ets = ERB.new File.read("#{__dir__}/templates/template.sts.erb"), nil, '%' 91 92def deep_copy(a) 93 Marshal.load(Marshal.dump(a)) 94end 95 96class ThreadPool 97 def initialize(threads_num) 98 @threads_num = threads_num 99 @threads = [] 100 end 101 102 def filter_threads 103 @threads.filter! { |t| t.alive? } 104 end 105 106 def run(*args, &block) 107 # wait while at least 1 task completes 108 while @threads.size >= @threads_num 109 sleep 0.05 110 filter_threads 111 end 112 t = Thread.new do 113 yield *args 114 rescue => e 115 puts e.full_message(highlight: true, order: :top) 116 end 117 @threads << t 118 end 119 120 def join() 121 while @threads.size != 0 122 sleep 0.15 123 filter_threads 124 end 125 end 126end 127 128class Generator 129 attr_reader :test_count, :tests_failed, :test_files, :tests_excluded 130 131 NODE_RETRIES = 5 132 133 module SpecialAction 134 # value is priority, greater value = higher priority 135 SILENCE_WARN = -1 136 REPORT = 0 137 WARN_AS_ERROR = 1 138 EXCLUDE = 2 139 end 140 141 def initialize(filter_pattern) 142 @filter_pattern = filter_pattern 143 144 @tests_by_name = {} 145 146 @test_count = 0 147 @test_files = 0 148 @tests_failed = 0 149 @tests_excluded = 0 150 151 check_node_version 152 end 153 154 def prepare(path, conf_yaml) 155 @conf = OpenStruct.new 156 @conf.self = nil 157 file_name = File.basename(path, ".yaml").gsub(/\s+/, '_').gsub(/[^a-zA-Z0-9_=]/, '_') 158 @conf.category = file_name + "_" 159 @conf.vars = eval('Vars.new', $user_binding) 160 if conf_yaml["top_scope"] 161 @conf.top_scope = conf_yaml["top_scope"] 162 end 163 end 164 165 def check_node_version() 166 begin 167 version = get_command_output(*$options[:'ts-node'], '-e', 'console.log(process.versions.node)') 168 rescue 169 puts 'Failed to check node version. Make sure that you both installed node and ran `npm install` within generator dir' 170 puts "Autodetected generator dir: '#{__dir__}'" 171 puts 172 print_help 173 raise 174 end 175 unless version =~ /21\.4(\..*|\s*$)/ 176 puts "Invalid node version #{version}" 177 puts 178 print_help 179 exit EXIT_CODES::INVALID_NODE_VERSION 180 end 181 end 182 183 def parse_non_endpoint_conf(sub) 184 conf = @conf 185 if sub.has_key?("self") 186 conf.self = sub["self"] 187 conf.self_type = sub["self_type"] 188 end 189 if sub.has_key?("setup") 190 conf.setup = sub["setup"] 191 end 192 (sub["vars"] || {}).each { |k, v| 193 conf.vars[k] = v 194 } 195 (sub["sub"] || []).each { |s| 196 process(s) 197 } 198 end 199 200 def process(sub) 201 old_conf = deep_copy(@conf) 202 if sub["excluded"] 203 @tests_excluded += 1 204 return 205 end 206 parse_non_endpoint_conf sub 207 conf = @conf 208 if sub["method"] || sub["expr"] 209 conf.ret_type = sub["ret_type"] 210 if sub["expr"] 211 name = conf.category + sub["expr"].gsub(/\s+/, '').gsub(/[^a-zA-Z0-9_=]/, '_') 212 is_expr = true 213 else 214 name = conf.category + sub["method"] 215 is_expr = false 216 end 217 return if not (@filter_pattern =~ name) 218 conf.special = (sub["special"] || [OpenStruct.new({ :match => ".*", :action => "report" })]).map { |s| 219 r = OpenStruct.new 220 r.match = Regexp.new s["match"] 221 r.action = case s["action"].strip 222 when "report" 223 SpecialAction::REPORT 224 when "exclude" 225 SpecialAction::EXCLUDE 226 when "warn as error" 227 SpecialAction::WARN_AS_ERROR 228 when "silence warn" 229 SpecialAction::SILENCE_WARN 230 else 231 raise "unknown action #{s["action"].strip}" 232 end 233 r 234 } 235 mandatory = sub["mandatory"] 236 mandatory ||= -1 237 rest = (sub["rest"] || ["emptyRest"]).map { |p| eval(p, conf.vars.instance_eval { binding }) } 238 pars = (sub["params"] || []).map { |p| 239 if p.kind_of? String 240 eval(p, conf.vars.instance_eval { binding }) 241 elsif p.kind_of? Array 242 raise "parameter must be either String (ruby expr) or array of String (plain values)" if !p.all? { |t| t.kind_of? String } 243 ScriptClass::paramOf(*p.map { |x| x.strip }) 244 else 245 raise "invalid parameter" 246 end 247 } 248 tests = @tests_by_name[name] || [] 249 @tests_by_name[name] = tests 250 add = [] 251 if conf.self 252 add = [ScriptClass.paramOf(*conf.self.map { |s| s.strip })] 253 if mandatory != -1 254 mandatory += 1 255 end 256 end 257 gen_params(add + pars, mandatory: mandatory, rest_supp: rest).each { |pars| 258 push = OpenStruct.new 259 push.conf = conf 260 push.self = conf.self 261 push.ts = OpenStruct.new 262 if conf.self 263 raise if pars.ts.size < 1 264 push.ts.self = pars.ts[0] 265 pars.ts = pars.ts[1..] 266 end 267 if push.conf.setup 268 push.conf.setup = push.conf.setup.gsub(/\bpars\b/, pars.ts.join(', ')) 269 end 270 if is_expr 271 push.ts.expr = sub["expr"].gsub(/\bpars\b/, pars.ts.join(', ')) 272 else 273 slf = conf.self ? "self." : "" 274 push.ts.expr = "#{slf}#{sub["method"]}(#{pars.ts.join(', ')})" 275 end 276 tests.push(push) 277 } 278 end 279 ensure 280 @conf = old_conf 281 end 282 283 def run() 284 thread_pool = ThreadPool.new($options[:proc]) 285 @tests_by_name.each { |k, v| 286 thread_pool.run(k, v) do |k, v| 287 run_test k, v 288 rescue 289 @tests_failed += 1 290 raise 291 end 292 } 293 thread_pool.join 294 end 295 296 def get_command_output(*args) 297 stdout_str, status = Open3.capture2e(*args) 298 if status != 0 299 raise "invalid status #{status}\ncommand: #{args.join(' ')}\n\n#{stdout_str}" 300 end 301 stdout_str 302 end 303 304 def run_test(k, test_cases) 305 test_cases = test_cases.filter { |t| 306 !t.conf.special.any? { |s| 307 if s.action == SpecialAction::EXCLUDE && s.match =~ t.ts.expr 308 @tests_excluded += 1 309 true 310 else 311 false 312 end 313 } 314 } 315 @test_count += test_cases.size 316 test_cases.each_slice($options[:'chunk-size']).with_index { |test_cases_current_chunk, chunk_id| 317 @test_files += 1 318 319 # ts part 320 ts_path = File.join $options[:tmp], "#{k}#{chunk_id}.ts" 321 buf = $template_ts.result(binding) 322 File.write ts_path, buf 323 # NOTE retries are a workaround for https://github.com/nodejs/node/issues/51555 324 stdout_str = "" 325 errors = [] 326 NODE_RETRIES.times do |i| 327 stdout_str = get_command_output(*$options[:'ts-node'], ts_path) 328 break 329 rescue => e 330 puts "NOTE: node failed for #{k}, retry: #{i + 1}/#{NODE_RETRIES}" 331 errors.push e 332 end 333 if errors.size == NODE_RETRIES 334 raise "ts-node failed too many times for #{k}\n#{errors.map.with_index { |x, i| "=== retry #{i} ===\n#{x}\n" }.join}" 335 end 336 File.write(File.join($options[:tmp], "#{k}#{chunk_id}.json"), stdout_str) 337 338 # ets part 339 expected = JSON.load(stdout_str) 340 buf = $template_ets.result(binding) 341 ets_path = File.join $options[:out], "#{k}#{chunk_id}.sts" 342 File.write ets_path, buf 343 344 # verify ets 345 if $options[:"run-ets"] 346 panda_build = $options[:"run-ets"] 347 abc_path = File.join $options[:tmp], "#{k}#{chunk_id}.abc" 348 get_command_output("#{panda_build}/bin/es2panda", "--extension=sts", "--opt-level=2", "--output", abc_path, ets_path) 349 res = get_command_output("#{panda_build}/bin/ark", "--no-async-jit=true", "--gc-trigger-type=debug", "--boot-panda-files", "#{panda_build}/plugins/ets/etsstdlib.abc", "--load-runtimes=ets", abc_path, "ETSGLOBAL::main") 350 res.strip! 351 puts "✔ executed ets #{k} #{chunk_id}" 352 if res.size != 0 353 puts res.gsub(/^/, "\t") 354 end 355 else 356 puts "✔ generated #{k} #{chunk_id}" 357 end 358 } 359 rescue 360 raise $!.class, "ruby: failed #{k}" 361 end 362 363 def gen_params(types_supp, rest_supp: [], mandatory: -1) 364 if mandatory < 0 365 mandatory = types_supp.size 366 end 367 res = [[]] 368 types_supp.each_with_index { |type_supp, idx| 369 res = res.flat_map { |old| 370 type_supp.().flat_map { |y| 371 if old.size == idx 372 [old + [y]] + (idx < mandatory ? [] : [old]) 373 else 374 [old] 375 end 376 } 377 } 378 } 379 res.uniq! 380 new_res = [] 381 if rest_supp.size > 0 382 rest_supp.each { |rest_one_supp| 383 added_cases = rest_one_supp.().flat_map { |r| 384 res.map { |o| o.size == types_supp.size ? o + r : o.dup } 385 } 386 new_res.concat(added_cases) 387 } 388 end 389 res = new_res 390 res.map! { |r| 391 p = OpenStruct.new 392 r.map! { |e| e.kind_of?(String) ? e.strip : e } 393 p.ts = p.sts = p.ets = r 394 p 395 } 396 return res 397 end 398end 399 400gen = Generator.new($options[:'filter']) 401 402ARGV.each { |a| 403 puts "reading #{a}" 404 file = YAML.load_file(a) 405 gen.prepare a, file 406 gen.process file 407} 408 409gen.run 410 411puts "total tests: #{gen.test_count}" 412if $options[:'run-ets'] 413 puts "failed files: #{gen.tests_failed}/#{gen.test_files}" 414end 415puts "excluded subtrees: #{gen.tests_excluded}" 416 417if gen.tests_failed != 0 418 puts "Some of tests failed" 419 exit EXIT_CODES::TESTS_FAILED 420end 421