OILS / test / sh_spec.py View on Github | oilshell.org

1481 lines, 917 significant
1#!/usr/bin/env python2
2from __future__ import print_function
3"""
4sh_spec.py -- Test framework to compare shells.
5
6Assertion help:
7 stdout: A single line of expected stdout. Newline is implicit.
8 stdout-json: JSON-encoded string. Use for the empty string (no newline),
9 for unicode chars, etc.
10
11 stderr: Ditto for stderr stream.
12 status: Expected shell return code. If not specified, the case must exit 0.
13
14Results:
15 PASS - we got the ideal, expected value
16 OK - we got a value that was not ideal, but expected
17 For OSH this is behavior that was defined to be different?
18 N-I - Not implemented (e.g. $''). Assertions still checked (in case it
19 starts working)
20 BUG - we verified the value of a known bug
21 FAIL - we got an unexpected value. If the implementation can't be changed,
22 it should be converted to BUG or OK. Otherwise it should be made to
23 PASS.
24
25NOTE: The difference between OK and BUG is a matter of judgement. If the ideal
26behavior is a compile time error (code 2), a runtime error is generally OK.
27
28If ALL shells agree on a broken behavior, they are all marked OK (but our
29implementation will be PASS.) But if the behavior is NOT POSIX compliant, then
30it will be a BUG.
31
32If one shell disagrees with others, that is generally a BUG.
33
34Example test case:
35
36#### hello and fail
37echo hello
38echo world
39exit 1
40## status: 1
41#
42# ignored comment
43#
44## STDOUT:
45hello
46world
47## END
48
49"""
50
51import collections
52import cgi
53import cStringIO
54import errno
55import json
56import optparse
57import os
58import pprint
59import re
60import shutil
61import subprocess
62import sys
63
64from test import spec_lib
65from doctools import html_head
66
67log = spec_lib.log
68
69# Magic strings for other variants of OSH.
70
71# NOTE: osh_ALT is usually _bin/osh -- the release binary.
72# It would be better to rename these osh-cpython and osh-ovm. Have the concept
73# of a suffix?
74
75OSH_CPYTHON = ('osh', 'osh-dbg')
76OTHER_OSH = ('osh_ALT', )
77
78YSH_CPYTHON = ('ysh', 'ysh-dbg')
79OTHER_YSH = ('oil_ALT', )
80
81# For now, only count the Oils CPython failures. TODO: the spec-cpp job should
82# assert the osh-cpp and ysh-cpp deltas.
83OTHER_OILS = OTHER_OSH + OTHER_YSH + ('osh-cpp', 'ysh-cpp')
84
85
86class ParseError(Exception):
87 pass
88
89
90# EXAMPLES:
91## stdout: foo
92## stdout-json: ""
93#
94# In other words, it could be (name, value) or (qualifier, name, value)
95
96KEY_VALUE_RE = re.compile(
97 r'''
98 [#][#] \s+
99 # optional prefix with qualifier and shells
100 (?: (OK|BUG|N-I) \s+ ([\w+/]+) \s+ )?
101 ([\w\-]+) # key
102 :
103 \s* (.*) # value
104''', re.VERBOSE)
105
106END_MULTILINE_RE = re.compile(r'''
107 [#][#] \s+ END
108''', re.VERBOSE)
109
110# Line types
111TEST_CASE_BEGIN = 0 # Starts with ####
112KEY_VALUE = 1 # Metadata
113KEY_VALUE_MULTILINE = 2 # STDOUT STDERR
114END_MULTILINE = 3 # STDOUT STDERR
115PLAIN_LINE = 4 # Uncommented
116EOF = 5
117
118LEX_OUTER = 0 # Ignore blank lines, e.g. for separating cases
119LEX_RAW = 1 # Blank lines are significant
120
121
122class Tokenizer(object):
123 """Modal lexer!"""
124
125 def __init__(self, f):
126 self.f = f
127
128 self.cursor = None
129 self.line_num = 0
130
131 self.next()
132
133 def _ClassifyLine(self, line, lex_mode):
134 if not line: # empty
135 return self.line_num, EOF, ''
136
137 if lex_mode == LEX_OUTER and not line.strip():
138 return None
139
140 if line.startswith('####'):
141 desc = line[4:].strip()
142 return self.line_num, TEST_CASE_BEGIN, desc
143
144 m = KEY_VALUE_RE.match(line)
145 if m:
146 qualifier, shells, name, value = m.groups()
147 # HACK: Expected data should have the newline.
148 if name in ('stdout', 'stderr'):
149 value += '\n'
150
151 if name in ('STDOUT', 'STDERR'):
152 token_type = KEY_VALUE_MULTILINE
153 else:
154 token_type = KEY_VALUE
155 return self.line_num, token_type, (qualifier, shells, name, value)
156
157 m = END_MULTILINE_RE.match(line)
158 if m:
159 return self.line_num, END_MULTILINE, None
160
161 # If it starts with ##, it should be metadata. This finds some typos.
162 if line.lstrip().startswith('##'):
163 raise RuntimeError('Invalid ## line %r' % line)
164
165 if line.lstrip().startswith('#'): # Ignore comments
166 return None # try again
167
168 # Non-empty line that doesn't start with '#'
169 # NOTE: We need the original line to test the whitespace sensitive <<-.
170 # And we need rstrip because we add newlines back below.
171 return self.line_num, PLAIN_LINE, line
172
173 def next(self, lex_mode=LEX_OUTER):
174 """Raises StopIteration when exhausted."""
175 while True:
176 line = self.f.readline()
177 self.line_num += 1
178
179 tok = self._ClassifyLine(line, lex_mode)
180 if tok is not None:
181 break
182
183 self.cursor = tok
184 return self.cursor
185
186 def peek(self):
187 return self.cursor
188
189
190def AddMetadataToCase(case, qualifier, shells, name, value):
191 shells = shells.split('/') # bash/dash/mksh
192 for shell in shells:
193 if shell not in case:
194 case[shell] = {}
195 case[shell][name] = value
196 case[shell]['qualifier'] = qualifier
197
198
199# Format of a test script.
200#
201# -- Code is either literal lines, or a commented out code: value.
202# code = PLAIN_LINE*
203# | '## code:' VALUE
204#
205# -- Key value pairs can be single- or multi-line
206# key_value = '##' KEY ':' VALUE
207# | KEY_VALUE_MULTILINE PLAIN_LINE* END_MULTILINE
208#
209# -- Description, then key-value pairs surrounding code.
210# test_case = '####' DESC
211# key_value*
212# code
213# key_value*
214#
215# -- Should be a blank line after each test case. Leading comments and code
216# -- are OK.
217#
218# test_file =
219# key_value* -- file level metadata
220# (test_case '\n')*
221
222
223def ParseKeyValue(tokens, case):
224 """Parse commented-out metadata in a test case.
225
226 The metadata must be contiguous.
227
228 Args:
229 tokens: Tokenizer
230 case: dictionary to add to
231 """
232 while True:
233 line_num, kind, item = tokens.peek()
234
235 if kind == KEY_VALUE_MULTILINE:
236 qualifier, shells, name, empty_value = item
237 if empty_value:
238 raise ParseError(
239 'Line %d: got value %r for %r, but the value should be on the '
240 'following lines' % (line_num, empty_value, name))
241
242 value_lines = []
243 while True:
244 tokens.next(lex_mode=LEX_RAW) # empty lines aren't skipped
245 _, kind2, item2 = tokens.peek()
246 if kind2 != PLAIN_LINE:
247 break
248 value_lines.append(item2)
249
250 value = ''.join(value_lines)
251
252 name = name.lower() # STDOUT -> stdout
253 if qualifier:
254 AddMetadataToCase(case, qualifier, shells, name, value)
255 else:
256 case[name] = value
257
258 # END token is optional.
259 if kind2 == END_MULTILINE:
260 tokens.next()
261
262 elif kind == KEY_VALUE:
263 qualifier, shells, name, value = item
264
265 if qualifier:
266 AddMetadataToCase(case, qualifier, shells, name, value)
267 else:
268 case[name] = value
269
270 tokens.next()
271
272 else: # Unknown token type
273 break
274
275
276def ParseCodeLines(tokens, case):
277 """Parse uncommented code in a test case."""
278 _, kind, item = tokens.peek()
279 if kind != PLAIN_LINE:
280 raise ParseError('Expected a line of code (got %r, %r)' % (kind, item))
281 code_lines = []
282 while True:
283 _, kind, item = tokens.peek()
284 if kind != PLAIN_LINE:
285 case['code'] = ''.join(code_lines)
286 return
287 code_lines.append(item)
288 tokens.next(lex_mode=LEX_RAW)
289
290
291def ParseTestCase(tokens):
292 """Parse a single test case and return it.
293
294 If at EOF, return None.
295 """
296 line_num, kind, item = tokens.peek()
297 if kind == EOF:
298 return None
299
300 if kind != TEST_CASE_BEGIN:
301 raise RuntimeError("line %d: Expected TEST_CASE_BEGIN, got %r" %
302 (line_num, [kind, item]))
303
304 tokens.next()
305
306 case = {'desc': item, 'line_num': line_num}
307
308 ParseKeyValue(tokens, case)
309
310 # For broken code
311 if 'code' in case: # Got it through a key value pair
312 return case
313
314 ParseCodeLines(tokens, case)
315 ParseKeyValue(tokens, case)
316
317 return case
318
319
320_META_FIELDS = [
321 'our_shell',
322 'compare_shells',
323 'suite',
324 'tags',
325 'oils_failures_allowed',
326 'oils_cpp_failures_allowed',
327]
328
329
330def ParseTestFile(test_file, tokens):
331 """
332 test_file: Only for error message
333 """
334 file_metadata = {}
335 test_cases = []
336
337 try:
338 # Skip over the header. Setup code can go here, although would we have to
339 # execute it on every case?
340 while True:
341 line_num, kind, item = tokens.peek()
342 if kind != KEY_VALUE:
343 break
344
345 qualifier, shells, name, value = item
346 if qualifier is not None:
347 raise RuntimeError('Invalid qualifier in spec file metadata')
348 if shells is not None:
349 raise RuntimeError('Invalid shells in spec file metadata')
350
351 file_metadata[name] = value
352
353 tokens.next()
354
355 while True: # Loop over cases
356 test_case = ParseTestCase(tokens)
357 if test_case is None:
358 break
359 test_cases.append(test_case)
360
361 except StopIteration:
362 raise RuntimeError('Unexpected EOF parsing test cases')
363
364 for name in file_metadata:
365 if name not in _META_FIELDS:
366 raise RuntimeError('Invalid file metadata %r in %r' %
367 (name, test_file))
368
369 return file_metadata, test_cases
370
371
372def CreateStringAssertion(d, key, assertions, qualifier=False):
373 found = False
374
375 exp = d.get(key)
376 if exp is not None:
377 a = EqualAssertion(key, exp, qualifier=qualifier)
378 assertions.append(a)
379 found = True
380
381 exp_json = d.get(key + '-json')
382 if exp_json is not None:
383 exp = json.loads(exp_json, encoding='utf-8')
384 a = EqualAssertion(key, exp, qualifier=qualifier)
385 assertions.append(a)
386 found = True
387
388 # For testing invalid unicode
389 exp_repr = d.get(key + '-repr')
390 if exp_repr is not None:
391 exp = eval(exp_repr)
392 a = EqualAssertion(key, exp, qualifier=qualifier)
393 assertions.append(a)
394 found = True
395
396 return found
397
398
399def CreateIntAssertion(d, key, assertions, qualifier=False):
400 exp = d.get(key) # expected
401 if exp is not None:
402 # For now, turn it into int
403 a = EqualAssertion(key, int(exp), qualifier=qualifier)
404 assertions.append(a)
405 return True
406 return False
407
408
409def CreateAssertions(case, sh_label):
410 """Given a raw test case and a shell label, create EqualAssertion instances
411 to run."""
412 assertions = []
413
414 # Whether we found assertions
415 stdout = False
416 stderr = False
417 status = False
418
419 # So the assertion are exactly the same for osh and osh_ALT
420
421 if sh_label.startswith('osh'):
422 case_sh = 'osh'
423 elif sh_label.startswith('bash'):
424 case_sh = 'bash'
425 else:
426 case_sh = sh_label
427
428 if case_sh in case:
429 q = case[case_sh]['qualifier']
430 if CreateStringAssertion(case[case_sh],
431 'stdout',
432 assertions,
433 qualifier=q):
434 stdout = True
435 if CreateStringAssertion(case[case_sh],
436 'stderr',
437 assertions,
438 qualifier=q):
439 stderr = True
440 if CreateIntAssertion(case[case_sh], 'status', assertions,
441 qualifier=q):
442 status = True
443
444 if not stdout:
445 CreateStringAssertion(case, 'stdout', assertions)
446 if not stderr:
447 CreateStringAssertion(case, 'stderr', assertions)
448 if not status:
449 if 'status' in case:
450 CreateIntAssertion(case, 'status', assertions)
451 else:
452 # If the user didn't specify a 'status' assertion, assert that the exit
453 # code is 0.
454 a = EqualAssertion('status', 0)
455 assertions.append(a)
456
457 no_traceback = SubstringAssertion('stderr', 'Traceback (most recent')
458 assertions.append(no_traceback)
459
460 #print 'SHELL', shell
461 #pprint.pprint(case)
462 #print(assertions)
463 return assertions
464
465
466class Result(object):
467 """Result of an stdout/stderr/status assertion or of a (case, shell) cell.
468
469 Order is important: the result of a cell is the minimum of the results of
470 each assertion.
471 """
472 TIMEOUT = 0 # ONLY a cell result, not an assertion result
473 FAIL = 1
474 BUG = 2
475 NI = 3
476 OK = 4
477 PASS = 5
478
479 length = 6 # for loops
480
481
482class EqualAssertion(object):
483 """Check that two values are equal."""
484
485 def __init__(self, key, expected, qualifier=None):
486 self.key = key
487 self.expected = expected # expected value
488 self.qualifier = qualifier # whether this was a special case?
489
490 def __repr__(self):
491 return '<EqualAssertion %s == %r>' % (self.key, self.expected)
492
493 def Check(self, shell, record):
494 actual = record[self.key]
495 if actual != self.expected:
496 if len(str(self.expected)) < 40:
497 msg = '[%s %s] Expected %r, got %r' % (shell, self.key,
498 self.expected, actual)
499 else:
500 msg = '''
501[%s %s]
502Expected %r
503Got %r
504''' % (shell, self.key, self.expected, actual)
505
506 # TODO: Make this better and add a flag for it.
507 if 0:
508 import difflib
509 for line in difflib.unified_diff(self.expected,
510 actual,
511 fromfile='expected',
512 tofile='actual'):
513 print(repr(line))
514
515 return Result.FAIL, msg
516 if self.qualifier == 'BUG': # equal, but known bad
517 return Result.BUG, ''
518 if self.qualifier == 'N-I': # equal, and known UNIMPLEMENTED
519 return Result.NI, ''
520 if self.qualifier == 'OK': # equal, but ok (not ideal)
521 return Result.OK, ''
522 return Result.PASS, '' # ideal behavior
523
524
525class SubstringAssertion(object):
526 """Check that a string like stderr doesn't have a substring."""
527
528 def __init__(self, key, substring):
529 self.key = key
530 self.substring = substring
531
532 def __repr__(self):
533 return '<SubstringAssertion %s == %r>' % (self.key, self.substring)
534
535 def Check(self, shell, record):
536 actual = record[self.key]
537 if self.substring in actual:
538 msg = '[%s %s] Found %r' % (shell, self.key, self.substring)
539 return Result.FAIL, msg
540 return Result.PASS, ''
541
542
543class Stats(object):
544
545 def __init__(self, num_cases, sh_labels):
546 self.counters = collections.defaultdict(int)
547 c = self.counters
548 c['num_cases'] = num_cases
549 c['oils_num_passed'] = 0
550 c['oils_num_failed'] = 0
551 c['oils_cpp_num_failed'] = 0
552 # Number of osh_ALT results that differed from osh.
553 c['oils_ALT_delta'] = 0
554
555 self.by_shell = {}
556 for sh in sh_labels:
557 self.by_shell[sh] = collections.defaultdict(int)
558 self.nonzero_results = collections.defaultdict(int)
559
560 self.tsv_rows = []
561
562 def Inc(self, counter_name):
563 self.counters[counter_name] += 1
564
565 def Get(self, counter_name):
566 return self.counters[counter_name]
567
568 def Set(self, counter_name, val):
569 self.counters[counter_name] = val
570
571 def ReportCell(self, case_num, cell_result, sh_label):
572 self.tsv_rows.append(
573 (str(case_num), sh_label, TEXT_CELLS[cell_result]))
574
575 self.by_shell[sh_label][cell_result] += 1
576 self.nonzero_results[cell_result] += 1
577
578 c = self.counters
579 if cell_result == Result.TIMEOUT:
580 c['num_timeout'] += 1
581 elif cell_result == Result.FAIL:
582 # Special logic: don't count osh_ALT because its failures will be
583 # counted in the delta.
584 if sh_label not in OTHER_OILS:
585 c['num_failed'] += 1
586
587 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
588 c['oils_num_failed'] += 1
589
590 if sh_label in ('osh-cpp', 'ysh-cpp'):
591 c['oils_cpp_num_failed'] += 1
592 elif cell_result == Result.BUG:
593 c['num_bug'] += 1
594 elif cell_result == Result.NI:
595 c['num_ni'] += 1
596 elif cell_result == Result.OK:
597 c['num_ok'] += 1
598 elif cell_result == Result.PASS:
599 c['num_passed'] += 1
600 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
601 c['oils_num_passed'] += 1
602 else:
603 raise AssertionError()
604
605 def WriteTsv(self, f):
606 f.write('case\tshell\tresult\n')
607 for row in self.tsv_rows:
608 f.write('\t'.join(row))
609 f.write('\n')
610
611
612PIPE = subprocess.PIPE
613
614
615def RunCases(cases, case_predicate, shells, env, out, opts):
616 """Run a list of test 'cases' for all 'shells' and write output to
617 'out'."""
618 if opts.trace:
619 for _, sh in shells:
620 log('\tshell: %s', sh)
621 print('\twhich $SH: ', end='', file=sys.stderr)
622 subprocess.call(['which', sh])
623
624 #pprint.pprint(cases)
625
626 sh_labels = [sh_label for sh_label, _ in shells]
627
628 out.WriteHeader(sh_labels)
629 stats = Stats(len(cases), sh_labels)
630
631 # Make an environment for each shell. $SH is the path to the shell, so we
632 # can test flags, etc.
633 sh_env = []
634 for _, sh_path in shells:
635 e = dict(env)
636 e[opts.sh_env_var_name] = sh_path
637 sh_env.append(e)
638
639 # Determine which one (if any) is osh-cpython, for comparison against other
640 # shells.
641 osh_cpython_index = -1
642 for i, (sh_label, _) in enumerate(shells):
643 if sh_label in OSH_CPYTHON:
644 osh_cpython_index = i
645 break
646
647 timeout_dir = os.path.abspath('_tmp/spec/timeouts')
648 try:
649 shutil.rmtree(timeout_dir)
650 os.mkdir(timeout_dir)
651 except OSError:
652 pass
653
654 # Now run each case, and print a table.
655 for i, case in enumerate(cases):
656 line_num = case['line_num']
657 desc = case['desc']
658 code = case['code']
659
660 if opts.trace:
661 log('case %d: %s', i, desc)
662
663 if not case_predicate(i, case):
664 stats.Inc('num_skipped')
665 continue
666
667 if opts.do_print:
668 print('#### %s' % case['desc'])
669 print(case['code'])
670 print()
671 continue
672
673 stats.Inc('num_cases_run')
674
675 result_row = []
676
677 for shell_index, (sh_label, sh_path) in enumerate(shells):
678 timeout_file = os.path.join(timeout_dir, '%02d-%s' % (i, sh_label))
679 if opts.timeout:
680 if opts.timeout_bin:
681 # This is what smoosh itself uses. See smoosh/tests/shell_tests.sh
682 # QUIRK: interval can only be a whole number
683 argv = [
684 opts.timeout_bin,
685 '-t',
686 opts.timeout,
687 # Somehow I'm not able to get this timeout file working? I think
688 # it has a bug when using stdin. It waits for the background
689 # process too.
690
691 #'-i', '1',
692 #'-l', timeout_file
693 ]
694 else:
695 # This kills hanging tests properly, but somehow they fail with code
696 # -9?
697 #argv = ['timeout', '-s', 'KILL', opts.timeout]
698
699 # s suffix for seconds
700 argv = ['timeout', opts.timeout + 's']
701 else:
702 argv = []
703 argv.append(sh_path)
704
705 # dash doesn't support -o posix
706 if opts.posix and sh_label != 'dash':
707 argv.extend(['-o', 'posix'])
708
709 if opts.trace:
710 log('\targv: %s', ' '.join(argv))
711
712 case_env = sh_env[shell_index]
713
714 # Unique dir for every test case and shell
715 tmp_base = os.path.normpath(opts.tmp_env) # no . or ..
716 case_tmp_dir = os.path.join(tmp_base, '%02d-%s' % (i, sh_label))
717
718 try:
719 os.makedirs(case_tmp_dir)
720 except OSError as e:
721 if e.errno != errno.EEXIST:
722 raise
723
724 # Some tests assume _tmp exists
725 # TODO: get rid of this in the common case, to save inodes! I guess have
726 # an opt-in setting per FILE, like make_underscore_tmp: true.
727 try:
728 os.mkdir(os.path.join(case_tmp_dir, '_tmp'))
729 except OSError as e:
730 if e.errno != errno.EEXIST:
731 raise
732
733 case_env['TMP'] = case_tmp_dir
734
735 if opts.pyann_out_dir:
736 case_env = dict(case_env)
737 case_env['PYANN_OUT'] = os.path.join(opts.pyann_out_dir,
738 '%d.json' % i)
739
740 try:
741 p = subprocess.Popen(argv,
742 env=case_env,
743 cwd=case_tmp_dir,
744 stdin=PIPE,
745 stdout=PIPE,
746 stderr=PIPE)
747 except OSError as e:
748 print('Error running %r: %s' % (sh_path, e), file=sys.stderr)
749 sys.exit(1)
750
751 p.stdin.write(code)
752
753 actual = {}
754 actual['stdout'], actual['stderr'] = p.communicate()
755
756 actual['status'] = p.wait()
757
758 if opts.timeout_bin and os.path.exists(timeout_file):
759 cell_result = Result.TIMEOUT
760 elif not opts.timeout_bin and actual['status'] == 124:
761 cell_result = Result.TIMEOUT
762 else:
763 messages = []
764 cell_result = Result.PASS
765
766 # TODO: Warn about no assertions? Well it will always test the error
767 # code.
768 assertions = CreateAssertions(case, sh_label)
769 for a in assertions:
770 result, msg = a.Check(sh_label, actual)
771 # The minimum one wins.
772 # If any failed, then the result is FAIL.
773 # If any are OK, but none are FAIL, the result is OK.
774 cell_result = min(cell_result, result)
775 if msg:
776 messages.append(msg)
777
778 if cell_result != Result.PASS or opts.details:
779 d = (i, sh_label, actual['stdout'], actual['stderr'],
780 messages)
781 out.AddDetails(d)
782
783 result_row.append(cell_result)
784
785 stats.ReportCell(i, cell_result, sh_label)
786
787 if sh_label in OTHER_OSH:
788 # This is only an error if we tried to run ANY OSH.
789 if osh_cpython_index == -1:
790 raise RuntimeError(
791 "Couldn't determine index of osh-cpython")
792
793 other_result = result_row[shell_index]
794 cpython_result = result_row[osh_cpython_index]
795 if other_result != cpython_result:
796 stats.Inc('oils_ALT_delta')
797
798 out.WriteRow(i, line_num, result_row, desc)
799
800 return stats
801
802
803# ANSI color constants
804_RESET = '\033[0;0m'
805_BOLD = '\033[1m'
806
807_RED = '\033[31m'
808_GREEN = '\033[32m'
809_YELLOW = '\033[33m'
810_PURPLE = '\033[35m'
811
812TEXT_CELLS = {
813 Result.TIMEOUT: 'TIME',
814 Result.FAIL: 'FAIL',
815 Result.BUG: 'BUG',
816 Result.NI: 'N-I',
817 Result.OK: 'ok',
818 Result.PASS: 'pass',
819}
820
821ANSI_COLORS = {
822 Result.TIMEOUT: _PURPLE,
823 Result.FAIL: _RED,
824 Result.BUG: _YELLOW,
825 Result.NI: _YELLOW,
826 Result.OK: _YELLOW,
827 Result.PASS: _GREEN,
828}
829
830
831def _AnsiCells():
832 lookup = {}
833 for i in xrange(Result.length):
834 lookup[i] = ''.join([ANSI_COLORS[i], _BOLD, TEXT_CELLS[i], _RESET])
835 return lookup
836
837
838ANSI_CELLS = _AnsiCells()
839
840HTML_CELLS = {
841 Result.TIMEOUT: '<td class="timeout">TIME',
842 Result.FAIL: '<td class="fail">FAIL',
843 Result.BUG: '<td class="bug">BUG',
844 Result.NI: '<td class="n-i">N-I',
845 Result.OK: '<td class="ok">ok',
846 Result.PASS: '<td class="pass">pass',
847}
848
849
850def _ValidUtf8String(s):
851 """Return an arbitrary string as a readable utf-8 string.
852
853 We output utf-8 to either HTML or the console. If we get invalid
854 utf-8 as stdout/stderr (which is very possible), then show the ASCII
855 repr().
856 """
857 try:
858 s.decode('utf-8')
859 return s # it decoded OK
860 except UnicodeDecodeError:
861 return repr(s) # ASCII representation
862
863
864class Output(object):
865
866 def __init__(self, f, verbose):
867 self.f = f
868 self.verbose = verbose
869 self.details = []
870
871 def BeginCases(self, test_file):
872 pass
873
874 def WriteHeader(self, sh_labels):
875 pass
876
877 def WriteRow(self, i, line_num, row, desc):
878 pass
879
880 def EndCases(self, sh_labels, stats):
881 pass
882
883 def AddDetails(self, entry):
884 self.details.append(entry)
885
886 # Helper function
887 def _WriteDetailsAsText(self, details):
888 for case_index, shell, stdout, stderr, messages in details:
889 print('case: %d' % case_index, file=self.f)
890 for m in messages:
891 print(m, file=self.f)
892
893 # Assume the terminal can show utf-8, but we don't want random binary.
894 print('%s stdout:' % shell, file=self.f)
895 print(_ValidUtf8String(stdout), file=self.f)
896
897 print('%s stderr:' % shell, file=self.f)
898 print(_ValidUtf8String(stderr), file=self.f)
899
900 print('', file=self.f)
901
902
903class TeeOutput(object):
904 """For multiple outputs in one run, e.g. HTML and TSV.
905
906 UNUSED
907 """
908
909 def __init__(self, outs):
910 self.outs = outs
911
912 def BeginCases(self, test_file):
913 for out in self.outs:
914 out.BeginCases(test_file)
915
916 def WriteHeader(self, sh_labels):
917 for out in self.outs:
918 out.WriteHeader(sh_labels)
919
920 def WriteRow(self, i, line_num, row, desc):
921 for out in self.outs:
922 out.WriteRow(i, line_num, row, desc)
923
924 def EndCases(self, sh_labels, stats):
925 for out in self.outs:
926 out.EndCases(sh_labels, stats)
927
928 def AddDetails(self, entry):
929 for out in self.outs:
930 out.AddDetails(entry)
931
932
933class TsvOutput(Output):
934 """Write a plain-text TSV file.
935
936 UNUSED since we are outputting LONG format with --tsv-output.
937 """
938
939 def WriteHeader(self, sh_labels):
940 self.f.write('case\tline\t') # case number and line number
941 for sh_label in sh_labels:
942 self.f.write(sh_label)
943 self.f.write('\t')
944 self.f.write('\n')
945
946 def WriteRow(self, i, line_num, row, desc):
947 self.f.write('%3d\t%3d\t' % (i, line_num))
948
949 for result in row:
950 c = TEXT_CELLS[result]
951 self.f.write(c)
952 self.f.write('\t')
953
954 # note: 'desc' could use TSV8, but just ignore it for now
955 #self.f.write(desc)
956 self.f.write('\n')
957
958
959class AnsiOutput(Output):
960
961 def BeginCases(self, test_file):
962 self.f.write('%s\n' % test_file)
963
964 def WriteHeader(self, sh_labels):
965 self.f.write(_BOLD)
966 self.f.write('case\tline\t') # case number and line number
967 for sh_label in sh_labels:
968 self.f.write(sh_label)
969 self.f.write('\t')
970 self.f.write(_RESET)
971 self.f.write('\n')
972
973 def WriteRow(self, i, line_num, row, desc):
974 self.f.write('%3d\t%3d\t' % (i, line_num))
975
976 for result in row:
977 c = ANSI_CELLS[result]
978 self.f.write(c)
979 self.f.write('\t')
980
981 self.f.write(desc)
982 self.f.write('\n')
983
984 if self.verbose:
985 self._WriteDetailsAsText(self.details)
986 self.details = []
987
988 def _WriteShellSummary(self, sh_labels, stats):
989 if len(stats.nonzero_results) <= 1: # Skip trivial summaries
990 return
991
992 # Reiterate header
993 self.f.write(_BOLD)
994 self.f.write('\t\t')
995 for sh_label in sh_labels:
996 self.f.write(sh_label)
997 self.f.write('\t')
998 self.f.write(_RESET)
999 self.f.write('\n')
1000
1001 # Write totals by cell.
1002 for result in sorted(stats.nonzero_results, reverse=True):
1003 self.f.write('\t%s' % ANSI_CELLS[result])
1004 for sh_label in sh_labels:
1005 self.f.write('\t%d' % stats.by_shell[sh_label][result])
1006 self.f.write('\n')
1007
1008 # The bottom row is all the same, but it helps readability.
1009 self.f.write('\ttotal')
1010 for sh_label in sh_labels:
1011 self.f.write('\t%d' % stats.counters['num_cases_run'])
1012 self.f.write('\n')
1013 self.f.write('\n')
1014
1015 def EndCases(self, sh_labels, stats):
1016 print()
1017 self._WriteShellSummary(sh_labels, stats)
1018
1019
1020class HtmlOutput(Output):
1021
1022 def __init__(self, f, verbose, spec_name, sh_labels, cases):
1023 Output.__init__(self, f, verbose)
1024 self.spec_name = spec_name
1025 self.sh_labels = sh_labels # saved from header
1026 self.cases = cases # for linking to code
1027 self.row_html = [] # buffered
1028
1029 def _SourceLink(self, line_num, desc):
1030 return '<a href="%s.test.html#L%d">%s</a>' % (self.spec_name, line_num,
1031 cgi.escape(desc))
1032
1033 def BeginCases(self, test_file):
1034 css_urls = ['../../../web/base.css', '../../../web/spec-tests.css']
1035 title = '%s: spec test case results' % self.spec_name
1036 html_head.Write(self.f, title, css_urls=css_urls)
1037
1038 self.f.write('''\
1039 <body class="width60">
1040 <p id="home-link">
1041 <a href=".">spec test index</a>
1042 /
1043 <a href="/">oilshell.org</a>
1044 </p>
1045 <h1>Results for %s</h1>
1046 <table>
1047 ''' % test_file)
1048
1049 def _WriteShellSummary(self, sh_labels, stats):
1050 # NOTE: This table has multiple <thead>, which seems OK.
1051 self.f.write('''
1052<thead>
1053 <tr class="table-header">
1054 ''')
1055
1056 columns = ['status'] + sh_labels + ['']
1057 for c in columns:
1058 self.f.write('<td>%s</td>' % c)
1059
1060 self.f.write('''
1061 </tr>
1062</thead>
1063''')
1064
1065 # Write totals by cell.
1066 for result in sorted(stats.nonzero_results, reverse=True):
1067 self.f.write('<tr>')
1068
1069 self.f.write(HTML_CELLS[result])
1070 self.f.write('</td> ')
1071
1072 for sh_label in sh_labels:
1073 self.f.write('<td>%d</td>' % stats.by_shell[sh_label][result])
1074
1075 self.f.write('<td></td>')
1076 self.f.write('</tr>\n')
1077
1078 # The bottom row is all the same, but it helps readability.
1079 self.f.write('<tr>')
1080 self.f.write('<td>total</td>')
1081 for sh_label in sh_labels:
1082 self.f.write('<td>%d</td>' % stats.counters['num_cases_run'])
1083 self.f.write('<td></td>')
1084 self.f.write('</tr>\n')
1085
1086 # Blank row for space.
1087 self.f.write('<tr>')
1088 for i in xrange(len(sh_labels) + 2):
1089 self.f.write('<td style="height: 2em"></td>')
1090 self.f.write('</tr>\n')
1091
1092 def WriteHeader(self, sh_labels):
1093 f = cStringIO.StringIO()
1094
1095 f.write('''
1096<thead>
1097 <tr class="table-header">
1098 ''')
1099
1100 columns = ['case'] + sh_labels
1101 for c in columns:
1102 f.write('<td>%s</td>' % c)
1103 f.write('<td class="case-desc">description</td>')
1104
1105 f.write('''
1106 </tr>
1107</thead>
1108''')
1109
1110 self.row_html.append(f.getvalue())
1111
1112 def WriteRow(self, i, line_num, row, desc):
1113 f = cStringIO.StringIO()
1114 f.write('<tr>')
1115 f.write('<td>%3d</td>' % i)
1116
1117 show_details = False
1118
1119 for result in row:
1120 c = HTML_CELLS[result]
1121 if result not in (Result.PASS, Result.TIMEOUT): # nothing to show
1122 show_details = True
1123
1124 f.write(c)
1125 f.write('</td>')
1126 f.write('\t')
1127
1128 f.write('<td class="case-desc">')
1129 f.write(self._SourceLink(line_num, desc))
1130 f.write('</td>')
1131 f.write('</tr>\n')
1132
1133 # Show row with details link.
1134 if show_details:
1135 f.write('<tr>')
1136 f.write('<td class="details-row"></td>') # for the number
1137
1138 for col_index, result in enumerate(row):
1139 f.write('<td class="details-row">')
1140 if result != Result.PASS:
1141 sh_label = self.sh_labels[col_index]
1142 f.write('<a href="#details-%s-%s">details</a>' %
1143 (i, sh_label))
1144 f.write('</td>')
1145
1146 f.write('<td class="details-row"></td>') # for the description
1147 f.write('</tr>\n')
1148
1149 self.row_html.append(f.getvalue()) # buffer it
1150
1151 def _WriteStats(self, stats):
1152 self.f.write('%(num_passed)d passed, %(num_ok)d OK, '
1153 '%(num_ni)d not implemented, %(num_bug)d BUG, '
1154 '%(num_failed)d failed, %(num_timeout)d timeouts, '
1155 '%(num_skipped)d cases skipped\n' % stats.counters)
1156
1157 def EndCases(self, sh_labels, stats):
1158 self._WriteShellSummary(sh_labels, stats)
1159
1160 # Write all the buffered rows
1161 for h in self.row_html:
1162 self.f.write(h)
1163
1164 self.f.write('</table>\n')
1165 self.f.write('<pre>')
1166 self._WriteStats(stats)
1167 if stats.Get('oils_num_failed'):
1168 self.f.write('%(oils_num_failed)d failed under osh\n' %
1169 stats.counters)
1170 self.f.write('</pre>')
1171
1172 if self.details:
1173 self._WriteDetails()
1174
1175 self.f.write('</body></html>')
1176
1177 def _WriteDetails(self):
1178 self.f.write("<h2>Details on runs that didn't PASS</h2>")
1179 self.f.write('<table id="details">')
1180
1181 for case_index, sh_label, stdout, stderr, messages in self.details:
1182 self.f.write('<tr>')
1183 self.f.write('<td><a name="details-%s-%s"></a><b>%s</b></td>' %
1184 (case_index, sh_label, sh_label))
1185
1186 self.f.write('<td>')
1187
1188 # Write description and link to the code
1189 case = self.cases[case_index]
1190 line_num = case['line_num']
1191 desc = case['desc']
1192 self.f.write('%d ' % case_index)
1193 self.f.write(self._SourceLink(line_num, desc))
1194 self.f.write('<br/><br/>\n')
1195
1196 for m in messages:
1197 self.f.write('<span class="assertion">%s</span><br/>\n' %
1198 cgi.escape(m))
1199 if messages:
1200 self.f.write('<br/>\n')
1201
1202 def _WriteRaw(s):
1203 self.f.write('<pre>')
1204
1205 # stdout might contain invalid utf-8; make it valid;
1206 valid_utf8 = _ValidUtf8String(s)
1207
1208 self.f.write(cgi.escape(valid_utf8))
1209 self.f.write('</pre>')
1210
1211 self.f.write('<i>stdout:</i> <br/>\n')
1212 _WriteRaw(stdout)
1213
1214 self.f.write('<i>stderr:</i> <br/>\n')
1215 _WriteRaw(stderr)
1216
1217 self.f.write('</td>')
1218 self.f.write('</tr>')
1219
1220 self.f.write('</table>')
1221
1222
1223def MakeTestEnv(opts):
1224 if not opts.tmp_env:
1225 raise RuntimeError('--tmp-env required')
1226 if not opts.path_env:
1227 raise RuntimeError('--path-env required')
1228 env = {
1229 'PATH': opts.path_env,
1230 #'LANG': opts.lang_env,
1231 }
1232 for p in opts.env_pair:
1233 name, value = p.split('=', 1)
1234 env[name] = value
1235
1236 return env
1237
1238
1239def _DefaultSuite(spec_name):
1240 if spec_name.startswith('ysh-'):
1241 suite = 'ysh'
1242 elif spec_name.startswith('hay'): # hay.test.sh is ysh
1243 suite = 'ysh'
1244
1245 elif spec_name.startswith('tea-'):
1246 suite = 'tea'
1247 else:
1248 suite = 'osh'
1249
1250 return suite
1251
1252
1253def ParseTestList(test_files):
1254 for test_file in test_files:
1255 with open(test_file) as f:
1256 tokens = Tokenizer(f)
1257 try:
1258 file_metadata, cases = ParseTestFile(test_file, tokens)
1259 except RuntimeError as e:
1260 log('ERROR in %r', test_file)
1261 raise
1262
1263 tmp = os.path.basename(test_file)
1264 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1265
1266 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1267
1268 tmp = file_metadata.get('tags')
1269 tags = tmp.split() if tmp else []
1270
1271 # Don't need compare_shells, etc. to decide what to run
1272
1273 row = {'spec_name': spec_name, 'suite': suite, 'tags': tags}
1274 #print(row)
1275 yield row
1276
1277
1278def main(argv):
1279 # First check if bash is polluting the environment. Tests rely on the
1280 # environment.
1281 v = os.getenv('RANDOM')
1282 if v is not None:
1283 raise AssertionError('got $RANDOM = %s' % v)
1284 v = os.getenv('PPID')
1285 if v is not None:
1286 raise AssertionError('got $PPID = %s' % v)
1287
1288 p = optparse.OptionParser('%s [options] TEST_FILE shell...' % sys.argv[0])
1289 spec_lib.DefineCommon(p)
1290 spec_lib.DefineShSpec(p)
1291 opts, argv = p.parse_args(argv)
1292
1293 # --print-tagged to figure out what to run
1294 if opts.print_tagged:
1295 to_find = opts.print_tagged
1296 for row in ParseTestList(argv[1:]):
1297 if to_find in row['tags']:
1298 print(row['spec_name'])
1299 return 0
1300
1301 # --print-table to figure out what to run
1302 if opts.print_table:
1303 for row in ParseTestList(argv[1:]):
1304 print('%(suite)s\t%(spec_name)s' % row)
1305 #print(row)
1306 return 0
1307
1308 #
1309 # Now deal with a single file
1310 #
1311
1312 try:
1313 test_file = argv[1]
1314 except IndexError:
1315 p.print_usage()
1316 return 1
1317
1318 with open(test_file) as f:
1319 tokens = Tokenizer(f)
1320 file_metadata, cases = ParseTestFile(test_file, tokens)
1321
1322 # List test cases and return
1323 if opts.do_list:
1324 for i, case in enumerate(cases):
1325 if opts.verbose: # print the raw dictionary for debugging
1326 print(pprint.pformat(case))
1327 else:
1328 print('%d\t%s' % (i, case['desc']))
1329 return 0
1330
1331 # for test/spec-cpp.sh
1332 if opts.print_spec_suite:
1333 tmp = os.path.basename(test_file)
1334 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1335
1336 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1337 print(suite)
1338 return 0
1339
1340 if opts.verbose:
1341 for k, v in file_metadata.items():
1342 print('\t%-20s: %s' % (k, v), file=sys.stderr)
1343 print('', file=sys.stderr)
1344
1345 if opts.oils_bin_dir:
1346
1347 shells = []
1348
1349 if opts.compare_shells:
1350 comp = file_metadata.get('compare_shells')
1351 # Compare 'compare_shells' and Python
1352 shells.extend(comp.split() if comp else [])
1353
1354 # Always run with the Python version
1355 our_shell = file_metadata.get('our_shell', 'osh') # default is OSH
1356 if our_shell != '-':
1357 shells.append(os.path.join(opts.oils_bin_dir, our_shell))
1358
1359 # Legacy OVM/CPython build
1360 if opts.ovm_bin_dir:
1361 shells.append(os.path.join(opts.ovm_bin_dir, our_shell))
1362
1363 # New C++ build
1364 if opts.oils_cpp_bin_dir:
1365 shells.append(os.path.join(opts.oils_cpp_bin_dir, our_shell))
1366
1367 # Overwrite it when --oils-bin-dir is set
1368 # It's no longer a flag
1369 opts.oils_failures_allowed = \
1370 int(file_metadata.get('oils_failures_allowed', 0))
1371
1372 else:
1373 # TODO: remove this mode?
1374 shells = argv[2:]
1375
1376 shell_pairs = spec_lib.MakeShellPairs(shells)
1377
1378 if opts.range:
1379 begin, end = spec_lib.ParseRange(opts.range)
1380 case_predicate = spec_lib.RangePredicate(begin, end)
1381 elif opts.regex:
1382 desc_re = re.compile(opts.regex, re.IGNORECASE)
1383 case_predicate = spec_lib.RegexPredicate(desc_re)
1384 else:
1385 case_predicate = lambda i, case: True
1386
1387 out_f = sys.stderr if opts.do_print else sys.stdout
1388
1389 # Set up output style. Also see asdl/format.py
1390 if opts.format == 'ansi':
1391 out = AnsiOutput(out_f, opts.verbose)
1392
1393 elif opts.format == 'html':
1394 spec_name = os.path.basename(test_file)
1395 spec_name = spec_name.split('.')[0]
1396
1397 sh_labels = [label for label, _ in shell_pairs]
1398
1399 out = HtmlOutput(out_f, opts.verbose, spec_name, sh_labels, cases)
1400
1401 else:
1402 raise AssertionError()
1403
1404 out.BeginCases(os.path.basename(test_file))
1405
1406 env = MakeTestEnv(opts)
1407 stats = RunCases(cases, case_predicate, shell_pairs, env, out, opts)
1408
1409 out.EndCases([sh_label for sh_label, _ in shell_pairs], stats)
1410
1411 if opts.tsv_output:
1412 with open(opts.tsv_output, 'w') as f:
1413 stats.WriteTsv(f)
1414
1415 # TODO: Could --stats-{file,template} be a separate awk step on .tsv files?
1416 stats.Set('oils_failures_allowed', opts.oils_failures_allowed)
1417
1418 # If it's not set separately for C++, we default to the allowed number
1419 # above
1420 x = int(
1421 file_metadata.get('oils_cpp_failures_allowed',
1422 opts.oils_failures_allowed))
1423 stats.Set('oils_cpp_failures_allowed', x)
1424
1425 if opts.stats_file:
1426 with open(opts.stats_file, 'w') as f:
1427 f.write(opts.stats_template % stats.counters)
1428 f.write('\n') # bash 'read' requires a newline
1429
1430 # spec/smoke.test.sh -> smoke
1431 test_name = os.path.basename(test_file).split('.')[0]
1432
1433 return _SuccessOrFailure(test_name, stats)
1434
1435
1436def _SuccessOrFailure(test_name, stats):
1437 allowed = stats.Get('oils_failures_allowed')
1438 allowed_cpp = stats.Get('oils_cpp_failures_allowed')
1439
1440 all_count = stats.Get('num_failed')
1441 oils_count = stats.Get('oils_num_failed')
1442 oils_cpp_count = stats.Get('oils_cpp_num_failed')
1443
1444 errors = []
1445 if oils_count != allowed:
1446 errors.append('Got %d Oils failures, but %d are allowed' %
1447 (oils_count, allowed))
1448 else:
1449 if allowed != 0:
1450 log('%s: note: Got %d allowed Oils failures', test_name, allowed)
1451
1452 # TODO: remove special case for 0
1453 if oils_cpp_count != 0 and oils_cpp_count != allowed_cpp:
1454 errors.append('Got %d Oils C++ failures, but %d are allowed' %
1455 (oils_cpp_count, allowed))
1456 else:
1457 if allowed_cpp != 0:
1458 log('%s: note: Got %d allowed Oils C++ failures', test_name,
1459 allowed_cpp)
1460
1461 if all_count != allowed:
1462 errors.append('Got %d total failures, but %d are allowed' %
1463 (all_count, allowed))
1464
1465 if errors:
1466 for msg in errors:
1467 log('%s: FATAL: %s', test_name, msg)
1468 return 1
1469
1470 return 0
1471
1472
1473if __name__ == '__main__':
1474 try:
1475 sys.exit(main(sys.argv))
1476 except KeyboardInterrupt as e:
1477 print('%s: interrupted with Ctrl-C' % sys.argv[0], file=sys.stderr)
1478 sys.exit(1)
1479 except RuntimeError as e:
1480 print('FATAL: %s' % e, file=sys.stderr)
1481 sys.exit(1)