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

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