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

1449 lines, 894 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]
327
328
329def ParseTestFile(test_file, tokens):
330 """
331 test_file: Only for error message
332 """
333 file_metadata = {}
334 test_cases = []
335
336 try:
337 # Skip over the header. Setup code can go here, although would we have to
338 # execute it on every case?
339 while True:
340 line_num, kind, item = tokens.peek()
341 if kind != KEY_VALUE:
342 break
343
344 qualifier, shells, name, value = item
345 if qualifier is not None:
346 raise RuntimeError('Invalid qualifier in spec file metadata')
347 if shells is not None:
348 raise RuntimeError('Invalid shells in spec file metadata')
349
350 file_metadata[name] = value
351
352 tokens.next()
353
354 while True: # Loop over cases
355 test_case = ParseTestCase(tokens)
356 if test_case is None:
357 break
358 test_cases.append(test_case)
359
360 except StopIteration:
361 raise RuntimeError('Unexpected EOF parsing test cases')
362
363 for name in file_metadata:
364 if name not in _META_FIELDS:
365 raise RuntimeError('Invalid file metadata %r in %r' %
366 (name, test_file))
367
368 return file_metadata, test_cases
369
370
371def CreateStringAssertion(d, key, assertions, qualifier=False):
372 found = False
373
374 exp = d.get(key)
375 if exp is not None:
376 a = EqualAssertion(key, exp, qualifier=qualifier)
377 assertions.append(a)
378 found = True
379
380 exp_json = d.get(key + '-json')
381 if exp_json is not None:
382 exp = json.loads(exp_json, encoding='utf-8')
383 a = EqualAssertion(key, exp, qualifier=qualifier)
384 assertions.append(a)
385 found = True
386
387 # For testing invalid unicode
388 exp_repr = d.get(key + '-repr')
389 if exp_repr is not None:
390 exp = eval(exp_repr)
391 a = EqualAssertion(key, exp, qualifier=qualifier)
392 assertions.append(a)
393 found = True
394
395 return found
396
397
398def CreateIntAssertion(d, key, assertions, qualifier=False):
399 exp = d.get(key) # expected
400 if exp is not None:
401 # For now, turn it into int
402 a = EqualAssertion(key, int(exp), qualifier=qualifier)
403 assertions.append(a)
404 return True
405 return False
406
407
408def CreateAssertions(case, sh_label):
409 """Given a raw test case and a shell label, create EqualAssertion instances
410 to run."""
411 assertions = []
412
413 # Whether we found assertions
414 stdout = False
415 stderr = False
416 status = False
417
418 # So the assertion are exactly the same for osh and osh_ALT
419
420 if sh_label.startswith('osh'):
421 case_sh = 'osh'
422 elif sh_label.startswith('bash'):
423 case_sh = 'bash'
424 else:
425 case_sh = sh_label
426
427 if case_sh in case:
428 q = case[case_sh]['qualifier']
429 if CreateStringAssertion(case[case_sh],
430 'stdout',
431 assertions,
432 qualifier=q):
433 stdout = True
434 if CreateStringAssertion(case[case_sh],
435 'stderr',
436 assertions,
437 qualifier=q):
438 stderr = True
439 if CreateIntAssertion(case[case_sh], 'status', assertions,
440 qualifier=q):
441 status = True
442
443 if not stdout:
444 CreateStringAssertion(case, 'stdout', assertions)
445 if not stderr:
446 CreateStringAssertion(case, 'stderr', assertions)
447 if not status:
448 if 'status' in case:
449 CreateIntAssertion(case, 'status', assertions)
450 else:
451 # If the user didn't specify a 'status' assertion, assert that the exit
452 # code is 0.
453 a = EqualAssertion('status', 0)
454 assertions.append(a)
455
456 no_traceback = SubstringAssertion('stderr', 'Traceback (most recent')
457 assertions.append(no_traceback)
458
459 #print 'SHELL', shell
460 #pprint.pprint(case)
461 #print(assertions)
462 return assertions
463
464
465class Result(object):
466 """Result of an stdout/stderr/status assertion or of a (case, shell) cell.
467
468 Order is important: the result of a cell is the minimum of the results of
469 each assertion.
470 """
471 TIMEOUT = 0 # ONLY a cell result, not an assertion result
472 FAIL = 1
473 BUG = 2
474 NI = 3
475 OK = 4
476 PASS = 5
477
478 length = 6 # for loops
479
480
481class EqualAssertion(object):
482 """Check that two values are equal."""
483
484 def __init__(self, key, expected, qualifier=None):
485 self.key = key
486 self.expected = expected # expected value
487 self.qualifier = qualifier # whether this was a special case?
488
489 def __repr__(self):
490 return '<EqualAssertion %s == %r>' % (self.key, self.expected)
491
492 def Check(self, shell, record):
493 actual = record[self.key]
494 if actual != self.expected:
495 if len(str(self.expected)) < 40:
496 msg = '[%s %s] Expected %r, got %r' % (shell, self.key,
497 self.expected, actual)
498 else:
499 msg = '''
500[%s %s]
501Expected %r
502Got %r
503''' % (shell, self.key, self.expected, actual)
504
505 # TODO: Make this better and add a flag for it.
506 if 0:
507 import difflib
508 for line in difflib.unified_diff(self.expected,
509 actual,
510 fromfile='expected',
511 tofile='actual'):
512 print(repr(line))
513
514 return Result.FAIL, msg
515 if self.qualifier == 'BUG': # equal, but known bad
516 return Result.BUG, ''
517 if self.qualifier == 'N-I': # equal, and known UNIMPLEMENTED
518 return Result.NI, ''
519 if self.qualifier == 'OK': # equal, but ok (not ideal)
520 return Result.OK, ''
521 return Result.PASS, '' # ideal behavior
522
523
524class SubstringAssertion(object):
525 """Check that a string like stderr doesn't have a substring."""
526
527 def __init__(self, key, substring):
528 self.key = key
529 self.substring = substring
530
531 def __repr__(self):
532 return '<SubstringAssertion %s == %r>' % (self.key, self.substring)
533
534 def Check(self, shell, record):
535 actual = record[self.key]
536 if self.substring in actual:
537 msg = '[%s %s] Found %r' % (shell, self.key, self.substring)
538 return Result.FAIL, msg
539 return Result.PASS, ''
540
541
542class Stats(object):
543
544 def __init__(self, num_cases, sh_labels):
545 self.counters = collections.defaultdict(int)
546 c = self.counters
547 c['num_cases'] = num_cases
548 c['oils_num_passed'] = 0
549 c['oils_num_failed'] = 0
550 # Number of osh_ALT results that differed from osh.
551 c['oils_ALT_delta'] = 0
552
553 self.by_shell = {}
554 for sh in sh_labels:
555 self.by_shell[sh] = collections.defaultdict(int)
556 self.nonzero_results = collections.defaultdict(int)
557
558 self.tsv_rows = []
559
560 def Inc(self, counter_name):
561 self.counters[counter_name] += 1
562
563 def Get(self, counter_name):
564 return self.counters[counter_name]
565
566 def Set(self, counter_name, val):
567 self.counters[counter_name] = val
568
569 def ReportCell(self, case_num, cell_result, sh_label):
570 self.tsv_rows.append(
571 (str(case_num), sh_label, TEXT_CELLS[cell_result]))
572
573 self.by_shell[sh_label][cell_result] += 1
574 self.nonzero_results[cell_result] += 1
575
576 c = self.counters
577 if cell_result == Result.TIMEOUT:
578 c['num_timeout'] += 1
579 elif cell_result == Result.FAIL:
580 # Special logic: don't count osh_ALT because its failures will be
581 # counted in the delta.
582 if sh_label not in OTHER_OILS:
583 c['num_failed'] += 1
584
585 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
586 c['oils_num_failed'] += 1
587 elif cell_result == Result.BUG:
588 c['num_bug'] += 1
589 elif cell_result == Result.NI:
590 c['num_ni'] += 1
591 elif cell_result == Result.OK:
592 c['num_ok'] += 1
593 elif cell_result == Result.PASS:
594 c['num_passed'] += 1
595 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
596 c['oils_num_passed'] += 1
597 else:
598 raise AssertionError()
599
600 def WriteTsv(self, f):
601 f.write('case\tshell\tresult\n')
602 for row in self.tsv_rows:
603 f.write('\t'.join(row))
604 f.write('\n')
605
606
607PIPE = subprocess.PIPE
608
609
610def RunCases(cases, case_predicate, shells, env, out, opts):
611 """Run a list of test 'cases' for all 'shells' and write output to
612 'out'."""
613 if opts.trace:
614 for _, sh in shells:
615 log('\tshell: %s', sh)
616 print('\twhich $SH: ', end='', file=sys.stderr)
617 subprocess.call(['which', sh])
618
619 #pprint.pprint(cases)
620
621 sh_labels = [sh_label for sh_label, _ in shells]
622
623 out.WriteHeader(sh_labels)
624 stats = Stats(len(cases), sh_labels)
625
626 # Make an environment for each shell. $SH is the path to the shell, so we
627 # can test flags, etc.
628 sh_env = []
629 for _, sh_path in shells:
630 e = dict(env)
631 e[opts.sh_env_var_name] = sh_path
632 sh_env.append(e)
633
634 # Determine which one (if any) is osh-cpython, for comparison against other
635 # shells.
636 osh_cpython_index = -1
637 for i, (sh_label, _) in enumerate(shells):
638 if sh_label in OSH_CPYTHON:
639 osh_cpython_index = i
640 break
641
642 timeout_dir = os.path.abspath('_tmp/spec/timeouts')
643 try:
644 shutil.rmtree(timeout_dir)
645 os.mkdir(timeout_dir)
646 except OSError:
647 pass
648
649 # Now run each case, and print a table.
650 for i, case in enumerate(cases):
651 line_num = case['line_num']
652 desc = case['desc']
653 code = case['code']
654
655 if opts.trace:
656 log('case %d: %s', i, desc)
657
658 if not case_predicate(i, case):
659 stats.Inc('num_skipped')
660 continue
661
662 if opts.do_print:
663 print('#### %s' % case['desc'])
664 print(case['code'])
665 print()
666 continue
667
668 stats.Inc('num_cases_run')
669
670 result_row = []
671
672 for shell_index, (sh_label, sh_path) in enumerate(shells):
673 timeout_file = os.path.join(timeout_dir, '%02d-%s' % (i, sh_label))
674 if opts.timeout:
675 if opts.timeout_bin:
676 # This is what smoosh itself uses. See smoosh/tests/shell_tests.sh
677 # QUIRK: interval can only be a whole number
678 argv = [
679 opts.timeout_bin,
680 '-t',
681 opts.timeout,
682 # Somehow I'm not able to get this timeout file working? I think
683 # it has a bug when using stdin. It waits for the background
684 # process too.
685
686 #'-i', '1',
687 #'-l', timeout_file
688 ]
689 else:
690 # This kills hanging tests properly, but somehow they fail with code
691 # -9?
692 #argv = ['timeout', '-s', 'KILL', opts.timeout]
693
694 # s suffix for seconds
695 argv = ['timeout', opts.timeout + 's']
696 else:
697 argv = []
698 argv.append(sh_path)
699
700 # dash doesn't support -o posix
701 if opts.posix and sh_label != 'dash':
702 argv.extend(['-o', 'posix'])
703
704 if opts.trace:
705 log('\targv: %s', ' '.join(argv))
706
707 case_env = sh_env[shell_index]
708
709 # Unique dir for every test case and shell
710 tmp_base = os.path.normpath(opts.tmp_env) # no . or ..
711 case_tmp_dir = os.path.join(tmp_base, '%02d-%s' % (i, sh_label))
712
713 try:
714 os.makedirs(case_tmp_dir)
715 except OSError as e:
716 if e.errno != errno.EEXIST:
717 raise
718
719 # Some tests assume _tmp exists
720 # TODO: get rid of this in the common case, to save inodes! I guess have
721 # an opt-in setting per FILE, like make_underscore_tmp: true.
722 try:
723 os.mkdir(os.path.join(case_tmp_dir, '_tmp'))
724 except OSError as e:
725 if e.errno != errno.EEXIST:
726 raise
727
728 case_env['TMP'] = case_tmp_dir
729
730 if opts.pyann_out_dir:
731 case_env = dict(case_env)
732 case_env['PYANN_OUT'] = os.path.join(opts.pyann_out_dir,
733 '%d.json' % i)
734
735 try:
736 p = subprocess.Popen(argv,
737 env=case_env,
738 cwd=case_tmp_dir,
739 stdin=PIPE,
740 stdout=PIPE,
741 stderr=PIPE)
742 except OSError as e:
743 print('Error running %r: %s' % (sh_path, e), file=sys.stderr)
744 sys.exit(1)
745
746 p.stdin.write(code)
747
748 actual = {}
749 actual['stdout'], actual['stderr'] = p.communicate()
750
751 actual['status'] = p.wait()
752
753 if opts.timeout_bin and os.path.exists(timeout_file):
754 cell_result = Result.TIMEOUT
755 elif not opts.timeout_bin and actual['status'] == 124:
756 cell_result = Result.TIMEOUT
757 else:
758 messages = []
759 cell_result = Result.PASS
760
761 # TODO: Warn about no assertions? Well it will always test the error
762 # code.
763 assertions = CreateAssertions(case, sh_label)
764 for a in assertions:
765 result, msg = a.Check(sh_label, actual)
766 # The minimum one wins.
767 # If any failed, then the result is FAIL.
768 # If any are OK, but none are FAIL, the result is OK.
769 cell_result = min(cell_result, result)
770 if msg:
771 messages.append(msg)
772
773 if cell_result != Result.PASS or opts.details:
774 d = (i, sh_label, actual['stdout'], actual['stderr'],
775 messages)
776 out.AddDetails(d)
777
778 result_row.append(cell_result)
779
780 stats.ReportCell(i, cell_result, sh_label)
781
782 if sh_label in OTHER_OSH:
783 # This is only an error if we tried to run ANY OSH.
784 if osh_cpython_index == -1:
785 raise RuntimeError(
786 "Couldn't determine index of osh-cpython")
787
788 other_result = result_row[shell_index]
789 cpython_result = result_row[osh_cpython_index]
790 if other_result != cpython_result:
791 stats.Inc('oils_ALT_delta')
792
793 out.WriteRow(i, line_num, result_row, desc)
794
795 return stats
796
797
798# ANSI color constants
799_RESET = '\033[0;0m'
800_BOLD = '\033[1m'
801
802_RED = '\033[31m'
803_GREEN = '\033[32m'
804_YELLOW = '\033[33m'
805_PURPLE = '\033[35m'
806
807TEXT_CELLS = {
808 Result.TIMEOUT: 'TIME',
809 Result.FAIL: 'FAIL',
810 Result.BUG: 'BUG',
811 Result.NI: 'N-I',
812 Result.OK: 'ok',
813 Result.PASS: 'pass',
814}
815
816ANSI_COLORS = {
817 Result.TIMEOUT: _PURPLE,
818 Result.FAIL: _RED,
819 Result.BUG: _YELLOW,
820 Result.NI: _YELLOW,
821 Result.OK: _YELLOW,
822 Result.PASS: _GREEN,
823}
824
825
826def _AnsiCells():
827 lookup = {}
828 for i in xrange(Result.length):
829 lookup[i] = ''.join([ANSI_COLORS[i], _BOLD, TEXT_CELLS[i], _RESET])
830 return lookup
831
832
833ANSI_CELLS = _AnsiCells()
834
835HTML_CELLS = {
836 Result.TIMEOUT: '<td class="timeout">TIME',
837 Result.FAIL: '<td class="fail">FAIL',
838 Result.BUG: '<td class="bug">BUG',
839 Result.NI: '<td class="n-i">N-I',
840 Result.OK: '<td class="ok">ok',
841 Result.PASS: '<td class="pass">pass',
842}
843
844
845def _ValidUtf8String(s):
846 """Return an arbitrary string as a readable utf-8 string.
847
848 We output utf-8 to either HTML or the console. If we get invalid
849 utf-8 as stdout/stderr (which is very possible), then show the ASCII
850 repr().
851 """
852 try:
853 s.decode('utf-8')
854 return s # it decoded OK
855 except UnicodeDecodeError:
856 return repr(s) # ASCII representation
857
858
859class Output(object):
860
861 def __init__(self, f, verbose):
862 self.f = f
863 self.verbose = verbose
864 self.details = []
865
866 def BeginCases(self, test_file):
867 pass
868
869 def WriteHeader(self, sh_labels):
870 pass
871
872 def WriteRow(self, i, line_num, row, desc):
873 pass
874
875 def EndCases(self, sh_labels, stats):
876 pass
877
878 def AddDetails(self, entry):
879 self.details.append(entry)
880
881 # Helper function
882 def _WriteDetailsAsText(self, details):
883 for case_index, shell, stdout, stderr, messages in details:
884 print('case: %d' % case_index, file=self.f)
885 for m in messages:
886 print(m, file=self.f)
887
888 # Assume the terminal can show utf-8, but we don't want random binary.
889 print('%s stdout:' % shell, file=self.f)
890 print(_ValidUtf8String(stdout), file=self.f)
891
892 print('%s stderr:' % shell, file=self.f)
893 print(_ValidUtf8String(stderr), file=self.f)
894
895 print('', file=self.f)
896
897
898class TeeOutput(object):
899 """For multiple outputs in one run, e.g. HTML and TSV.
900
901 UNUSED
902 """
903
904 def __init__(self, outs):
905 self.outs = outs
906
907 def BeginCases(self, test_file):
908 for out in self.outs:
909 out.BeginCases(test_file)
910
911 def WriteHeader(self, sh_labels):
912 for out in self.outs:
913 out.WriteHeader(sh_labels)
914
915 def WriteRow(self, i, line_num, row, desc):
916 for out in self.outs:
917 out.WriteRow(i, line_num, row, desc)
918
919 def EndCases(self, sh_labels, stats):
920 for out in self.outs:
921 out.EndCases(sh_labels, stats)
922
923 def AddDetails(self, entry):
924 for out in self.outs:
925 out.AddDetails(entry)
926
927
928class TsvOutput(Output):
929 """Write a plain-text TSV file.
930
931 UNUSED since we are outputting LONG format with --tsv-output.
932 """
933
934 def WriteHeader(self, sh_labels):
935 self.f.write('case\tline\t') # case number and line number
936 for sh_label in sh_labels:
937 self.f.write(sh_label)
938 self.f.write('\t')
939 self.f.write('\n')
940
941 def WriteRow(self, i, line_num, row, desc):
942 self.f.write('%3d\t%3d\t' % (i, line_num))
943
944 for result in row:
945 c = TEXT_CELLS[result]
946 self.f.write(c)
947 self.f.write('\t')
948
949 # note: 'desc' could use TSV8, but just ignore it for now
950 #self.f.write(desc)
951 self.f.write('\n')
952
953
954class AnsiOutput(Output):
955
956 def BeginCases(self, test_file):
957 self.f.write('%s\n' % test_file)
958
959 def WriteHeader(self, sh_labels):
960 self.f.write(_BOLD)
961 self.f.write('case\tline\t') # case number and line number
962 for sh_label in sh_labels:
963 self.f.write(sh_label)
964 self.f.write('\t')
965 self.f.write(_RESET)
966 self.f.write('\n')
967
968 def WriteRow(self, i, line_num, row, desc):
969 self.f.write('%3d\t%3d\t' % (i, line_num))
970
971 for result in row:
972 c = ANSI_CELLS[result]
973 self.f.write(c)
974 self.f.write('\t')
975
976 self.f.write(desc)
977 self.f.write('\n')
978
979 if self.verbose:
980 self._WriteDetailsAsText(self.details)
981 self.details = []
982
983 def _WriteShellSummary(self, sh_labels, stats):
984 if len(stats.nonzero_results) <= 1: # Skip trivial summaries
985 return
986
987 # Reiterate header
988 self.f.write(_BOLD)
989 self.f.write('\t\t')
990 for sh_label in sh_labels:
991 self.f.write(sh_label)
992 self.f.write('\t')
993 self.f.write(_RESET)
994 self.f.write('\n')
995
996 # Write totals by cell.
997 for result in sorted(stats.nonzero_results, reverse=True):
998 self.f.write('\t%s' % ANSI_CELLS[result])
999 for sh_label in sh_labels:
1000 self.f.write('\t%d' % stats.by_shell[sh_label][result])
1001 self.f.write('\n')
1002
1003 # The bottom row is all the same, but it helps readability.
1004 self.f.write('\ttotal')
1005 for sh_label in sh_labels:
1006 self.f.write('\t%d' % stats.counters['num_cases_run'])
1007 self.f.write('\n')
1008
1009 def EndCases(self, sh_labels, stats):
1010 print()
1011 self._WriteShellSummary(sh_labels, stats)
1012
1013
1014class HtmlOutput(Output):
1015
1016 def __init__(self, f, verbose, spec_name, sh_labels, cases):
1017 Output.__init__(self, f, verbose)
1018 self.spec_name = spec_name
1019 self.sh_labels = sh_labels # saved from header
1020 self.cases = cases # for linking to code
1021 self.row_html = [] # buffered
1022
1023 def _SourceLink(self, line_num, desc):
1024 return '<a href="%s.test.html#L%d">%s</a>' % (self.spec_name, line_num,
1025 cgi.escape(desc))
1026
1027 def BeginCases(self, test_file):
1028 css_urls = ['../../../web/base.css', '../../../web/spec-tests.css']
1029 title = '%s: spec test case results' % self.spec_name
1030 html_head.Write(self.f, title, css_urls=css_urls)
1031
1032 self.f.write('''\
1033 <body class="width60">
1034 <p id="home-link">
1035 <a href=".">spec test index</a>
1036 /
1037 <a href="/">oilshell.org</a>
1038 </p>
1039 <h1>Results for %s</h1>
1040 <table>
1041 ''' % test_file)
1042
1043 def _WriteShellSummary(self, sh_labels, stats):
1044 # NOTE: This table has multiple <thead>, which seems OK.
1045 self.f.write('''
1046<thead>
1047 <tr class="table-header">
1048 ''')
1049
1050 columns = ['status'] + sh_labels + ['']
1051 for c in columns:
1052 self.f.write('<td>%s</td>' % c)
1053
1054 self.f.write('''
1055 </tr>
1056</thead>
1057''')
1058
1059 # Write totals by cell.
1060 for result in sorted(stats.nonzero_results, reverse=True):
1061 self.f.write('<tr>')
1062
1063 self.f.write(HTML_CELLS[result])
1064 self.f.write('</td> ')
1065
1066 for sh_label in sh_labels:
1067 self.f.write('<td>%d</td>' % stats.by_shell[sh_label][result])
1068
1069 self.f.write('<td></td>')
1070 self.f.write('</tr>\n')
1071
1072 # The bottom row is all the same, but it helps readability.
1073 self.f.write('<tr>')
1074 self.f.write('<td>total</td>')
1075 for sh_label in sh_labels:
1076 self.f.write('<td>%d</td>' % stats.counters['num_cases_run'])
1077 self.f.write('<td></td>')
1078 self.f.write('</tr>\n')
1079
1080 # Blank row for space.
1081 self.f.write('<tr>')
1082 for i in xrange(len(sh_labels) + 2):
1083 self.f.write('<td style="height: 2em"></td>')
1084 self.f.write('</tr>\n')
1085
1086 def WriteHeader(self, sh_labels):
1087 f = cStringIO.StringIO()
1088
1089 f.write('''
1090<thead>
1091 <tr class="table-header">
1092 ''')
1093
1094 columns = ['case'] + sh_labels
1095 for c in columns:
1096 f.write('<td>%s</td>' % c)
1097 f.write('<td class="case-desc">description</td>')
1098
1099 f.write('''
1100 </tr>
1101</thead>
1102''')
1103
1104 self.row_html.append(f.getvalue())
1105
1106 def WriteRow(self, i, line_num, row, desc):
1107 f = cStringIO.StringIO()
1108 f.write('<tr>')
1109 f.write('<td>%3d</td>' % i)
1110
1111 show_details = False
1112
1113 for result in row:
1114 c = HTML_CELLS[result]
1115 if result not in (Result.PASS, Result.TIMEOUT): # nothing to show
1116 show_details = True
1117
1118 f.write(c)
1119 f.write('</td>')
1120 f.write('\t')
1121
1122 f.write('<td class="case-desc">')
1123 f.write(self._SourceLink(line_num, desc))
1124 f.write('</td>')
1125 f.write('</tr>\n')
1126
1127 # Show row with details link.
1128 if show_details:
1129 f.write('<tr>')
1130 f.write('<td class="details-row"></td>') # for the number
1131
1132 for col_index, result in enumerate(row):
1133 f.write('<td class="details-row">')
1134 if result != Result.PASS:
1135 sh_label = self.sh_labels[col_index]
1136 f.write('<a href="#details-%s-%s">details</a>' %
1137 (i, sh_label))
1138 f.write('</td>')
1139
1140 f.write('<td class="details-row"></td>') # for the description
1141 f.write('</tr>\n')
1142
1143 self.row_html.append(f.getvalue()) # buffer it
1144
1145 def _WriteStats(self, stats):
1146 self.f.write('%(num_passed)d passed, %(num_ok)d OK, '
1147 '%(num_ni)d not implemented, %(num_bug)d BUG, '
1148 '%(num_failed)d failed, %(num_timeout)d timeouts, '
1149 '%(num_skipped)d cases skipped\n' % stats.counters)
1150
1151 def EndCases(self, sh_labels, stats):
1152 self._WriteShellSummary(sh_labels, stats)
1153
1154 # Write all the buffered rows
1155 for h in self.row_html:
1156 self.f.write(h)
1157
1158 self.f.write('</table>\n')
1159 self.f.write('<pre>')
1160 self._WriteStats(stats)
1161 if stats.Get('oils_num_failed'):
1162 self.f.write('%(oils_num_failed)d failed under osh\n' %
1163 stats.counters)
1164 self.f.write('</pre>')
1165
1166 if self.details:
1167 self._WriteDetails()
1168
1169 self.f.write('</body></html>')
1170
1171 def _WriteDetails(self):
1172 self.f.write("<h2>Details on runs that didn't PASS</h2>")
1173 self.f.write('<table id="details">')
1174
1175 for case_index, sh_label, stdout, stderr, messages in self.details:
1176 self.f.write('<tr>')
1177 self.f.write('<td><a name="details-%s-%s"></a><b>%s</b></td>' %
1178 (case_index, sh_label, sh_label))
1179
1180 self.f.write('<td>')
1181
1182 # Write description and link to the code
1183 case = self.cases[case_index]
1184 line_num = case['line_num']
1185 desc = case['desc']
1186 self.f.write('%d ' % case_index)
1187 self.f.write(self._SourceLink(line_num, desc))
1188 self.f.write('<br/><br/>\n')
1189
1190 for m in messages:
1191 self.f.write('<span class="assertion">%s</span><br/>\n' %
1192 cgi.escape(m))
1193 if messages:
1194 self.f.write('<br/>\n')
1195
1196 def _WriteRaw(s):
1197 self.f.write('<pre>')
1198
1199 # stdout might contain invalid utf-8; make it valid;
1200 valid_utf8 = _ValidUtf8String(s)
1201
1202 self.f.write(cgi.escape(valid_utf8))
1203 self.f.write('</pre>')
1204
1205 self.f.write('<i>stdout:</i> <br/>\n')
1206 _WriteRaw(stdout)
1207
1208 self.f.write('<i>stderr:</i> <br/>\n')
1209 _WriteRaw(stderr)
1210
1211 self.f.write('</td>')
1212 self.f.write('</tr>')
1213
1214 self.f.write('</table>')
1215
1216
1217def MakeTestEnv(opts):
1218 if not opts.tmp_env:
1219 raise RuntimeError('--tmp-env required')
1220 if not opts.path_env:
1221 raise RuntimeError('--path-env required')
1222 env = {
1223 'PATH': opts.path_env,
1224 #'LANG': opts.lang_env,
1225 }
1226 for p in opts.env_pair:
1227 name, value = p.split('=', 1)
1228 env[name] = value
1229
1230 return env
1231
1232
1233def _DefaultSuite(spec_name):
1234 if spec_name.startswith('ysh-'):
1235 suite = 'ysh'
1236 elif spec_name.startswith('hay'): # hay.test.sh is ysh
1237 suite = 'ysh'
1238
1239 elif spec_name.startswith('tea-'):
1240 suite = 'tea'
1241 else:
1242 suite = 'osh'
1243
1244 return suite
1245
1246
1247def ParseTestList(test_files):
1248 for test_file in test_files:
1249 with open(test_file) as f:
1250 tokens = Tokenizer(f)
1251 try:
1252 file_metadata, cases = ParseTestFile(test_file, tokens)
1253 except RuntimeError as e:
1254 log('ERROR in %r', test_file)
1255 raise
1256
1257 tmp = os.path.basename(test_file)
1258 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1259
1260 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1261
1262 tmp = file_metadata.get('tags')
1263 tags = tmp.split() if tmp else []
1264
1265 # Don't need compare_shells, etc. to decide what to run
1266
1267 row = {'spec_name': spec_name, 'suite': suite, 'tags': tags}
1268 #print(row)
1269 yield row
1270
1271
1272def main(argv):
1273 # First check if bash is polluting the environment. Tests rely on the
1274 # environment.
1275 v = os.getenv('RANDOM')
1276 if v is not None:
1277 raise AssertionError('got $RANDOM = %s' % v)
1278 v = os.getenv('PPID')
1279 if v is not None:
1280 raise AssertionError('got $PPID = %s' % v)
1281
1282 p = optparse.OptionParser('%s [options] TEST_FILE shell...' % sys.argv[0])
1283 spec_lib.DefineCommon(p)
1284 spec_lib.DefineShSpec(p)
1285 opts, argv = p.parse_args(argv)
1286
1287 # --print-tagged to figure out what to run
1288 if opts.print_tagged:
1289 to_find = opts.print_tagged
1290 for row in ParseTestList(argv[1:]):
1291 if to_find in row['tags']:
1292 print(row['spec_name'])
1293 return 0
1294
1295 # --print-table to figure out what to run
1296 if opts.print_table:
1297 for row in ParseTestList(argv[1:]):
1298 print('%(suite)s\t%(spec_name)s' % row)
1299 #print(row)
1300 return 0
1301
1302 #
1303 # Now deal with a single file
1304 #
1305
1306 try:
1307 test_file = argv[1]
1308 except IndexError:
1309 p.print_usage()
1310 return 1
1311
1312 with open(test_file) as f:
1313 tokens = Tokenizer(f)
1314 file_metadata, cases = ParseTestFile(test_file, tokens)
1315
1316 # List test cases and return
1317 if opts.do_list:
1318 for i, case in enumerate(cases):
1319 if opts.verbose: # print the raw dictionary for debugging
1320 print(pprint.pformat(case))
1321 else:
1322 print('%d\t%s' % (i, case['desc']))
1323 return 0
1324
1325 # for test/spec-cpp.sh
1326 if opts.print_spec_suite:
1327 tmp = os.path.basename(test_file)
1328 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1329
1330 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1331 print(suite)
1332 return 0
1333
1334 if opts.verbose:
1335 for k, v in file_metadata.items():
1336 print('\t%-20s: %s' % (k, v), file=sys.stderr)
1337 print('', file=sys.stderr)
1338
1339 if opts.oils_bin_dir:
1340
1341 shells = []
1342
1343 if opts.compare_shells:
1344 comp = file_metadata.get('compare_shells')
1345 # Compare 'compare_shells' and Python
1346 shells.extend(comp.split() if comp else [])
1347
1348 # Always run with the Python version
1349 our_shell = file_metadata.get('our_shell', 'osh') # default is OSH
1350 if our_shell != '-':
1351 shells.append(os.path.join(opts.oils_bin_dir, our_shell))
1352
1353 # Legacy OVM/CPython build
1354 if opts.ovm_bin_dir:
1355 shells.append(os.path.join(opts.ovm_bin_dir, our_shell))
1356
1357 # New C++ build
1358 if opts.oils_cpp_bin_dir:
1359 shells.append(os.path.join(opts.oils_cpp_bin_dir, our_shell))
1360
1361 # Overwrite it when --oils-bin-dir is set
1362 # It's no longer a flag
1363 opts.oils_failures_allowed = \
1364 int(file_metadata.get('oils_failures_allowed', 0))
1365
1366 else:
1367 # TODO: remove this mode?
1368 shells = argv[2:]
1369
1370 shell_pairs = spec_lib.MakeShellPairs(shells)
1371
1372 if opts.range:
1373 begin, end = spec_lib.ParseRange(opts.range)
1374 case_predicate = spec_lib.RangePredicate(begin, end)
1375 elif opts.regex:
1376 desc_re = re.compile(opts.regex, re.IGNORECASE)
1377 case_predicate = spec_lib.RegexPredicate(desc_re)
1378 else:
1379 case_predicate = lambda i, case: True
1380
1381 out_f = sys.stderr if opts.do_print else sys.stdout
1382
1383 # Set up output style. Also see asdl/format.py
1384 if opts.format == 'ansi':
1385 out = AnsiOutput(out_f, opts.verbose)
1386
1387 elif opts.format == 'html':
1388 spec_name = os.path.basename(test_file)
1389 spec_name = spec_name.split('.')[0]
1390
1391 sh_labels = [label for label, _ in shell_pairs]
1392
1393 out = HtmlOutput(out_f, opts.verbose, spec_name, sh_labels, cases)
1394
1395 else:
1396 raise AssertionError()
1397
1398 out.BeginCases(os.path.basename(test_file))
1399
1400 env = MakeTestEnv(opts)
1401 stats = RunCases(cases, case_predicate, shell_pairs, env, out, opts)
1402
1403 out.EndCases([sh_label for sh_label, _ in shell_pairs], stats)
1404
1405 if opts.tsv_output:
1406 with open(opts.tsv_output, 'w') as f:
1407 stats.WriteTsv(f)
1408
1409 # TODO: Could --stats-{file,template} be a separate awk step on .tsv files?
1410 stats.Set('oils_failures_allowed', opts.oils_failures_allowed)
1411 if opts.stats_file:
1412 with open(opts.stats_file, 'w') as f:
1413 f.write(opts.stats_template % stats.counters)
1414 f.write('\n') # bash 'read' requires a newline
1415
1416 # spec/smoke.test.sh -> smoke
1417 test_name = os.path.basename(test_file).split('.')[0]
1418
1419 return _SuccessOrFailure(test_name, opts.oils_failures_allowed, stats)
1420
1421
1422def _SuccessOrFailure(test_name, allowed, stats):
1423 all_count = stats.Get('num_failed')
1424 oils_count = stats.Get('oils_num_failed')
1425
1426 # If we got EXACTLY the allowed number of failures, exit 0.
1427 if allowed == all_count and all_count == oils_count:
1428 log('%s: note: Got %d allowed oils failures (exit with code 0)',
1429 test_name, allowed)
1430 return 0
1431 else:
1432 log('')
1433 log(
1434 '%s: FATAL: Got %d failures (%d oils failures), but %d are allowed',
1435 test_name, all_count, oils_count, allowed)
1436 log('')
1437
1438 return 1
1439
1440
1441if __name__ == '__main__':
1442 try:
1443 sys.exit(main(sys.argv))
1444 except KeyboardInterrupt as e:
1445 print('%s: interrupted with Ctrl-C' % sys.argv[0], file=sys.stderr)
1446 sys.exit(1)
1447 except RuntimeError as e:
1448 print('FATAL: %s' % e, file=sys.stderr)
1449 sys.exit(1)