• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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